Compare commits

..

35 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS 5abd07d2bf fix(simulation): cache idle worker state 2026-07-24 23:27:51 +03:00
DCCONSTRUCTIONS bc61331604 docs(simulation): record Ackermann motion acceptance 2026-07-24 23:00:07 +03:00
DCCONSTRUCTIONS 49b0f47fea fix(simulation): tolerate cold ROS publisher startup 2026-07-24 22:51:21 +03:00
DCCONSTRUCTIONS 0fb2782f2d fix(simulation): admit cold PX4 rover startup 2026-07-24 22:47:48 +03:00
DCCONSTRUCTIONS 6ad1803aaa fix(simulation): use PX4 rover offboard control level 2026-07-24 22:40:44 +03:00
DCCONSTRUCTIONS 8ace0cd102 fix(simulation): rearm rover during active offboard run 2026-07-24 22:31:43 +03:00
DCCONSTRUCTIONS b9ce5f53a0 perf(simulation): fsync commands without replaying run history 2026-07-24 22:21:25 +03:00
DCCONSTRUCTIONS 8eecb8ed69 perf(simulation): keep rover command path within TTL 2026-07-24 22:14:56 +03:00
DCCONSTRUCTIONS a0e9597cb5 fix(simulation): source ROS setup outside nounset 2026-07-24 22:01:48 +03:00
DCCONSTRUCTIONS 97a3775acf fix(simulation): satisfy worker wrapper shell audit 2026-07-24 21:53:14 +03:00
DCCONSTRUCTIONS f63ed36913 docs(simulation): distinguish live control from archive 2026-07-24 21:51:27 +03:00
DCCONSTRUCTIONS 101707d54c feat(simulation): drive stock rover through PX4 offboard 2026-07-24 21:47:40 +03:00
DCCONSTRUCTIONS 53fd1e3bfc docs(simulation): accept worker-gated polygon ui 2026-07-24 21:29:55 +03:00
DCCONSTRUCTIONS 60e79160c9 fix(simulation): keep rover orbit above ground 2026-07-24 21:16:38 +03:00
DCCONSTRUCTIONS 17a0e7a7f5 fix(simulation): hide launcher panel for polygon 2026-07-24 21:13:17 +03:00
DCCONSTRUCTIONS ee7d1dd26c fix(simulation): open polygon as direct workspace 2026-07-24 21:10:37 +03:00
DCCONSTRUCTIONS 6cc40282e0 feat(simulation): add worker-gated 3d polygon workspace 2026-07-24 21:03:41 +03:00
DCCONSTRUCTIONS 1995dfdf68 docs(simulation): accept S1C live worker 2026-07-24 20:47:08 +03:00
DCCONSTRUCTIONS b7ccca3481 feat(simulation): add Polygon live worker gateway 2026-07-24 20:33:08 +03:00
DCCONSTRUCTIONS 359f0d1d39 docs: define distributed Mission Core topology 2026-07-24 19:40:59 +03:00
DCCONSTRUCTIONS b790f29d59 feat: add Polygon UI-0 run view 2026-07-24 19:02:10 +03:00
DCCONSTRUCTIONS a78c8c83f5 docs(simulation): accept S1B real provider lifecycle 2026-07-24 18:39:59 +03:00
DCCONSTRUCTIONS 6cb14954d4 fix(simulation): stabilize PX4 readiness supervision 2026-07-24 18:34:20 +03:00
DCCONSTRUCTIONS b4e3b3b735 fix(simulation): inspect namespace through netlink 2026-07-24 18:29:52 +03:00
DCCONSTRUCTIONS 01ec12263a feat(simulation): qualify real S1B provider lifecycle 2026-07-24 18:26:43 +03:00
DCCONSTRUCTIONS e1809ac9e1 docs(simulation): record S1 target bootstrap and UI gates 2026-07-24 18:04:11 +03:00
DCCONSTRUCTIONS 630d1ae819 feat(simulation): add S1 worker lifecycle ownership 2026-07-24 17:52:17 +03:00
DCCONSTRUCTIONS 12fba7126d fix(simulation): persist command authority provenance 2026-07-24 17:39:26 +03:00
DCCONSTRUCTIONS fc8c81dbc4 feat(simulation): add S1 qualification run repository 2026-07-24 17:37:55 +03:00
DCCONSTRUCTIONS a19053dfd4 docs(simulation): accept Polygon SIM S0 2026-07-24 17:24:26 +03:00
DCCONSTRUCTIONS 2548bb5740 fix(simulation): accept ROS multi-document samples 2026-07-24 17:21:04 +03:00
DCCONSTRUCTIONS fc5cbdff8a fix(simulation): scope worker git ownership checks 2026-07-24 17:20:10 +03:00
DCCONSTRUCTIONS c9b1009259 feat(simulation): qualify S0 time and resource gates 2026-07-24 17:19:28 +03:00
DCCONSTRUCTIONS 6290fcb2ce feat(simulation): qualify D-only stock rover smoke 2026-07-24 16:51:39 +03:00
DCCONSTRUCTIONS 81761067e9 feat(simulation): establish polygon S0 qualification 2026-07-24 15:29:08 +03:00
54 changed files with 15673 additions and 15 deletions

150
README.md
View File

@ -121,11 +121,149 @@ performance, direct worker transport and bounded live fan-out remain open. See
[ADR 0014](docs/adr/0014-bounded-external-perception-worker.md)
and the [external worker contract](docs/10_EXTERNAL_PERCEPTION_WORKER.md).
## Simulation Polygon
Mission Core now has a parallel Polygon product branch for reproducible
autonomy qualification. Mission Core owns scenarios, qualification-run
lifecycle, authority, canonical contracts, provenance and reports; Gazebo, PX4
SITL, ROS 2, Nav2 and viewers remain replaceable providers. Simulation, replay,
digital twin, HIL and physical shadow keep distinct causal run kinds.
SIM S0 is accepted on the reviewed D-only worker. Its strict profile fixes
loopback-only process isolation, exact provider pins, a 2 ms physics step,
1×/2× RTF/resource budgets and disabled real/direct actuator authority. Two
accepted-profile stock-rover runs and nine digest-bound evidence claims passed;
the target doctor returned `GO`. The doctor remains read-only and a development
host without the private evidence pack still returns `INCOMPLETE`:
```bash
uv run missioncore-sim s0 doctor \
--evidence /path/to/private/evidence.yaml \
--json
```
S1A now adds the first server-owned domain boundary: typed immutable
`QualificationRun` identities, legal lifecycle transitions, canonical
Ackermann/Differential commands, authority generation and TTL validation, and
an append-only crash-safe run/event/command/artifact repository. Active runs
recover as failed/interrupted rather than silently resuming, and reset creates
a new linked run/episode. This is repository-level lifecycle acceptance only;
PX4 command delivery, watchdog/failsafe behavior, navigation behavior and real
actuator control remain unaccepted.
The S1B repository increment adds the transport-neutral
`SimulationApplicationService`, an exact-S0/D-only worker admission guard and a
POSIX process supervisor. Start is idempotent, a second active owner is
rejected, provider processes receive dedicated PGIDs and run-scoped logs, and
shutdown follows the S0 dependency order with residue reported as failure.
Pause/resume/step/reset/stop are owned by the application service through a
worker port.
Commit `6cb1495` has now passed the real D-only `MissionCore-Sim` S1B lifecycle:
the exact immutable source generation started Micro XRCE-DDS Agent and the PX4
v1.17.0/Gazebo Harmonic stock Ackermann rover in a fresh loopback-only
namespace, admitted the run only after world/startup/DDS-writer health markers,
and stopped both owned PGIDs with zero residue. The persisted run reached
`completed`, contains eight lifecycle events and zero control commands. This
run is now visible through UI-0: a direct internal read-only Control Station
surface backed by the same append-only qualification repository. It exposes
identity, terminal reason, provider pins, events, command count, artifact
metadata and the virtual-only authority boundary. Live pause/resume/step/reset,
canonical telemetry, PX4 command mapping, watchdog/failsafe evidence and the
accepted command path remain gated.
S1C commit `b7ccca3` adds the first registered live-worker vertical without
collapsing that boundary. An unprivileged worker agent owns the existing
application service and providers inside a fresh loopback-only network
namespace. Mission Core reaches it through a mode-`0600` Unix socket; React
reaches only same-origin Polygon API routes. The live browser panel can
start/stop the stock rover and show worker/run/provider status. UI-2 commit
`60e7916` adds a worker-gated `Полигон` header root and a browser-native
Three.js Ackermann Rover scene with orbit, zoom, pan, follow and reset. The
deep-link remains for exact-run review; it is no longer the primary operator
entry. Gazebo dynamic-pose ground truth remains explicitly `diagnostic`, not
accepted PX4/ROS 2 telemetry.
The exact D-only UI-2 generation passed 35 target tests and a real UI-driven run
`s1c-60e7916-20260724t182006z-2f164e`: both providers reached readiness, the
browser received live `VehicleState`, the run reached `completed` after
27.74 seconds of Gazebo time, and shutdown left no PX4/Gazebo/XRCE process
residue. The run contained zero commands, so this result does not yet prove
rover motion, PX4 command delivery, watchdog/failsafe behavior, navigation or
safety acceptance.
S1D exact code generation `49b0f47` closes the first virtual Ackermann command
and motion slice. React sends bounded speed/steering intents through the
same-origin Mission Core API and private worker socket. The worker journals each
accepted command before mapping it to PX4 rover-level Offboard setpoints:
`RoverThrottleSetpoint` is calibrated against the stock model's
`RO_MAX_THR_SPEED=3.1`, `RoverSteeringSetpoint` carries normalized steering,
and PX4 retains arming, Offboard loss handling and control allocation. No
`ActuatorMotors` or `ActuatorServos` topic is exposed or published by Mission
Core.
The immutable D-only generation passed 40 focused target tests. Its first cold
start completed in one request after 32.08 seconds. Run
`s1c-49b0f47-20260724t195259z-62edd2` proved 3.74 m straight motion, a curved
turn, reverse motion, 44.6 ms sampled command admission, a 250 ms TTL transition
(`ttl_expired_count` 4 -> 5), stable stopped pose and clean provider shutdown
with no PX4/Gazebo/XRCE residue. This does not accept calibrated closed-loop
speed tracking, Differential Rover, canonical PX4/ROS 2 telemetry,
pause/step/reset, heartbeat/offboard/link-loss cases, Nav2, perception or safety
behavior. Real actuator authority remains false.
Start the worker from the exact immutable D source generation:
```bash
sudo simulation/s1/worker-agent.sh <exact-40-character-mission-core-commit>
```
Then configure the Mission Core backend. When the worker status is available,
open `Полигон` from the Control Station header:
```bash
export MISSIONCORE_POLYGON_RUNS_ROOT=/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/runs
export MISSIONCORE_POLYGON_WORKER_SOCKET=/run/missioncore-sim/worker.sock
export MISSIONCORE_POLYGON_WORKER_CONTROL=internal-virtual-only
export MISSIONCORE_COMMIT=<exact-40-character-mission-core-commit>
uv run uvicorn k1link.web.app:app --host 127.0.0.1 --port 8765
```
```text
http://127.0.0.1:8765/
```
The direct `?workspace=polygon-run&run=<qualification-run-id>` URL remains a
compatible evidence deep-link. The archive API remains GET-only. The worker API
adds status/live GETs and explicit start, command and stop POSTs; POSTs remain
`403` unless the backend has the `internal-virtual-only` gate and an exact
commit. Missing configuration, transport drift and corrupt evidence fail
closed. The browser receives no Unix socket, D root, artifact bytes, PX4
endpoint or direct provider command.
See the [Polygon product/SRS](docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md),
[ADR 0015](docs/adr/0015-simulation-polygon-qualification-boundary.md),
[ADR 0016](docs/adr/0016-distributed-product-edge-and-worker-topology.md) and the
[SIM S0 worker runbook](docs/runbooks/SIM_S0_AI_WORKER.md). The live-worker
launch and acceptance procedure is in
[SIM S1C live-worker runbook](docs/runbooks/SIM_S1C_LIVE_WORKER.md).
Mission Core remains a distributed browser product rather than a
platform-specific desktop executable. React runs in the operator browser;
Gateway/API and Control Plane run as long-lived services; Gazebo/PX4/ROS 2/Nav2
run headless on a registered simulation worker; and a future field vehicle runs
a headless onboard Linux Edge Agent with local-first evidence and
store-and-forward synchronization. Native Gazebo GUI is worker-local
diagnostics. The accepted live rover scene uses canonical state in Three.js;
Rerun remains the recorded spatial-evidence path. Field execution never depends
on streaming a simulator desktop.
## Mission Core Control Station and visualization adapters
The browser application is the universal Mission Core Control Station rather than
a K1-specific Foxglove launcher. Its fixed shell contains six architectural
sections — Center, Fleet, Observation, Missions, Data and System — while the K1
a K1-specific Foxglove launcher. Its fixed shell contains Center, Fleet,
Observation, Missions, Data and System; a seventh Polygon section is inserted
only while an admitted Simulation Worker is available. The K1
BLE/Wi-Fi/live workflow remains isolated as the first real device adapter. Its
React provisioning, acquisition/replay and diagnostic blocks live beside the
plugin manifest under `plugins/xgrids-k1/frontend`; the generic application
@ -219,6 +357,12 @@ standalone release install. The frontend consumes sibling `file:` packages from
revision or content hash. Publishing/vendoring those packages or enforcing an
immutable donor revision remains a packaging and CI prerequisite.
Polygon is implemented as a capability-gated seventh section. When Mission Core
confirms an admitted Simulation Worker, its header button opens one direct
workspace with live 3D inspection and the server-owned qualification
repository. `?workspace=polygon-run[&run=<id>]` remains a compatibility
deep-link. Command and safety acceptance remain separate gates.
The Observation spatial workspace embeds the open-source Rerun Web Viewer
inside the Mission Core shell. It can open a compatible RRD over same-origin
HTTP or a Rerun gRPC/proxy source such as
@ -350,6 +494,8 @@ present.
- [Plugin-owned frontend device workflows](docs/adr/0010-plugin-owned-frontend-device-workflows.md)
- [Laboratory plugin runtime handshake and transport seam](docs/adr/0011-laboratory-plugin-runtime-handshake-and-transport-seam.md)
- [Device-bound K1 command authority](docs/adr/0012-device-bound-k1-command-authority.md)
- [Simulation Polygon qualification boundary](docs/adr/0015-simulation-polygon-qualification-boundary.md)
- [Distributed product, edge and worker topology](docs/adr/0016-distributed-product-edge-and-worker-topology.md)
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
- [Canonical control and durable archive milestone](docs/lab/004_K1_CANONICAL_CONTROL_ARCHIVE_20260719.redacted.md)
- [Session manifest schema](schemas/session-manifest.schema.json)

View File

@ -15,12 +15,14 @@
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@rerun-io/web-viewer": "0.34.1",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"three": "0.185.1"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@types/three": "0.185.1",
"@vitejs/plugin-react": "^4.6.0",
"typescript": "^5.8.3",
"vite": "^7.0.0",
@ -344,6 +346,13 @@
"node": ">=6.9.0"
}
},
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
@ -1223,6 +1232,13 @@
"win32"
]
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1305,6 +1321,35 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.185.1",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.1.tgz",
"integrity": "sha512-db1xTb+EgYF2didW+eudSvVPtn75zo+fGsY8ShQrJY/B5ZBmC2Fiaykv3aImHAlCNEGuMPkPGXBJGLwzu5mC7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"fflate": "~0.8.2",
"meshoptimizer": "~1.1.1"
}
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@ -1503,6 +1548,13 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -1571,6 +1623,13 @@
"yallist": "^3.0.2"
}
},
"node_modules/meshoptimizer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
"dev": true,
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -1758,6 +1817,12 @@
"node": ">=0.10.0"
}
},
"node_modules/three": {
"version": "0.185.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
"integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",

View File

@ -18,12 +18,14 @@
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@rerun-io/web-viewer": "0.34.1",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"three": "0.185.1"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@types/three": "0.185.1",
"@vitejs/plugin-react": "^4.6.0",
"typescript": "^5.8.3",
"vite": "^7.0.0",

View File

@ -46,6 +46,8 @@ import type {
} from "./core/observation/sessionArchive";
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
import { resolvePolygonRunRoute } from "./core/polygon/runArchive";
import { usePolygonWorkerCapability } from "./core/polygon/usePolygonWorkerCapability";
import {
OBSERVATION_WORKSPACE_ID,
OBSERVATION_WORKSPACE_LAYOUT_VERSION,
@ -53,7 +55,7 @@ import {
} from "./core/observation/workspaceLayout";
import {
rootById,
roots,
rootsForCapabilities,
workspaceById,
workspacesForRoot,
type RootId,
@ -149,12 +151,19 @@ function mergeViewerSettings(
export default function App() {
const runtime = useMissionRuntime();
const { selection } = useDevicePluginHost();
const polygonWorker = usePolygonWorkerCapability();
const polygonRunRoute = useMemo(
() => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search),
[],
);
const workspace = useApplicationWorkspace<string>({
navigationOpen: false,
contentExpanded: true,
});
const [activeRoot, setActiveRoot] = useState<RootId | null>(null);
const [activeRoot, setActiveRoot] = useState<RootId | null>(
polygonRunRoute.active ? "polygon" : null,
);
const [sourceUrl, setSourceUrl] = useState("");
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
@ -187,8 +196,13 @@ export default function App() {
const replayActiveRef = useRef(false);
const sceneSettingsCommitterActiveRef = useRef(true);
const sceneSettingsCommitterRef = useRef<LatestAsyncCommitter<SceneSettings> | null>(null);
const polygonRunRouteOpenedRef = useRef(false);
const currentRoot = rootById(activeRoot);
const visibleRoots = useMemo(
() => rootsForCapabilities({ polygonWorkerAvailable: polygonWorker.available }),
[polygonWorker.available],
);
const activeDefinition = workspaceById(workspace.activeView);
const spatialWorkspaceActive = Boolean(
workspace.contentOpen && activeDefinition?.kind === "spatial",
@ -204,6 +218,12 @@ export default function App() {
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
replayActiveRef.current = replayActive;
useEffect(() => {
if (!polygonRunRoute.active || polygonRunRouteOpenedRef.current) return;
polygonRunRouteOpenedRef.current = true;
workspace.openView("polygon-run");
}, [polygonRunRoute.active, workspace]);
if (!sceneSettingsCommitterRef.current) {
sceneSettingsCommitterRef.current = createLatestAsyncCommitter<SceneSettings>({
commit: (settings) => replayActiveRef.current
@ -399,6 +419,10 @@ export default function App() {
const selectRoot = (rootId: RootId) => {
setActiveRoot(rootId);
if (rootId === "polygon") {
workspace.openView("polygon-run");
return;
}
workspace.closeView();
workspace.openNavigation();
};
@ -604,7 +628,7 @@ export default function App() {
<HeaderNavigation
label="Архитектурные блоки пункта управления"
value={activeRoot ?? undefined}
items={roots.map((root) => ({ value: root.id, label: root.label }))}
items={visibleRoots.map((root) => ({ value: root.id, label: root.label }))}
onChange={selectRoot}
/>
</>
@ -627,7 +651,9 @@ export default function App() {
data-nodedc-ui
className="control-station"
data-observation-fullscreen={observationFullscreenActive ? "true" : undefined}
navigationOpen={workspace.navigationOpen && activeRoot !== null}
navigationOpen={
workspace.navigationOpen && activeRoot !== null && activeRoot !== "polygon"
}
contentOpen={workspace.contentOpen && activeDefinition !== null}
contentExpanded={workspace.contentExpanded}
header={header}
@ -641,7 +667,7 @@ export default function App() {
onOpenDevice={() => openView("local-device")}
/>
}
navigation={currentRoot ? (
navigation={currentRoot && activeRoot !== "polygon" ? (
<AdminNavigationPanel
eyebrow="MISSION CORE"
title={currentRoot.title}
@ -718,6 +744,8 @@ export default function App() {
</span>
) : null}
</div>
) : activeDefinition.kind === "polygon-run" ? (
<StatusBadge tone="accent">Virtual only</StatusBadge>
) : (
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
)
@ -746,6 +774,7 @@ export default function App() {
livePerceptionLayers={livePerceptionLayers}
onLivePerceptionLayersChange={changeLivePerceptionLayers}
observationLayout={observationLayout}
polygonRunRoute={polygonRunRoute}
spatialControls={selection?.SpatialControlsView
? {
View: selection.SpatialControlsView,

View File

@ -0,0 +1,527 @@
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 interface PolygonCommandAcceptance {
runId: string;
commandId: string;
sequence: number;
issuedAtSimNs: number;
validUntilSimNs: number;
speedMps: number;
steeringNormalized: number;
armed: boolean;
offboard: boolean;
ttlExpiredCount: 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",
]);
const COMMAND_KEYS = new Set([
"schema_version",
"run_id",
"command_id",
"sequence",
"issued_at_sim_ns",
"valid_until_sim_ns",
"speed_mps",
"steering_normalized",
"authority_scope",
"delivery",
]);
const COMMAND_DELIVERY_KEYS = new Set([
"provider",
"mode",
"armed",
"offboard",
"ttl_expired_count",
]);
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"),
},
};
}
export function decodePolygonCommandAcceptance(payload: unknown): PolygonCommandAcceptance {
const value = record(payload, "Подтверждение команды");
exactKeys(value, COMMAND_KEYS, "Подтверждение команды");
if (
value.schema_version !== "missioncore.command-acceptance/v1" ||
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.");
}
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");
const speedMps = finiteValue(value.speed_mps, "speed_mps");
const steeringNormalized = finiteValue(
value.steering_normalized,
"steering_normalized",
);
if (
sequence < 1 ||
validUntilSimNs <= issuedAtSimNs ||
validUntilSimNs - issuedAtSimNs > 250_000_000 ||
speedMps < -1.5 ||
speedMps > 1.5 ||
steeringNormalized < -1 ||
steeringNormalized > 1
) {
throw new PolygonWorkerContractError("Команда нарушает допустимый S1 envelope.");
}
return {
runId: safeId(value.run_id, "run_id"),
commandId: safeId(value.command_id, "command_id"),
sequence,
issuedAtSimNs,
validUntilSimNs,
speedMps,
steeringNormalized,
armed: booleanValue(delivery.armed, "delivery.armed"),
offboard: booleanValue(delivery.offboard, "delivery.offboard"),
ttlExpiredCount: integerValue(
delivery.ttl_expired_count,
"delivery.ttl_expired_count",
),
};
}
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,
));
}
export async function sendPolygonRoverCommand(
runId: string,
{
speedMps,
steeringNormalized,
idempotencyKey,
signal,
fetcher = globalThis.fetch,
}: {
speedMps: number;
steeringNormalized: number;
idempotencyKey: string;
signal?: AbortSignal;
fetcher?: PolygonWorkerFetch;
},
): Promise<PolygonCommandAcceptance> {
if (!SAFE_ID.test(runId)) {
throw new PolygonWorkerContractError("Некорректный идентификатор активного прогона.");
}
if (
!Number.isFinite(speedMps) ||
!Number.isFinite(steeringNormalized) ||
speedMps < -1.5 ||
speedMps > 1.5 ||
steeringNormalized < -1 ||
steeringNormalized > 1
) {
throw new PolygonWorkerContractError("Команда вышла за допустимый S1 envelope.");
}
return decodePolygonCommandAcceptance(await requestJson(
`/api/v1/polygon/worker/runs/${encodeURIComponent(runId)}/commands`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({
speed_mps: speedMps,
steering_normalized: steeringNormalized,
}),
signal,
},
"Simulation Worker не подтвердил команду ровера.",
fetcher,
));
}

View File

@ -0,0 +1,661 @@
export type PolygonRunState =
| "admitted"
| "starting"
| "running"
| "paused"
| "stopping"
| "completed"
| "failed"
| "aborted";
export interface PolygonRunSummary {
runId: string;
episodeId: string;
kind: string;
state: PolygonRunState;
createdAtUtc: string;
startedAtUtc: string | null;
endedAtUtc: string | null;
terminalReason: string | null;
scenarioGeneration: string;
profileGeneration: string;
missionCoreCommit: string;
hostProfileId: string;
reproducibilityTier: "R0" | "R1" | "R2";
clockDomain: string;
revision: number;
providerIds: string[];
artifactCount: number;
}
export interface PolygonProviderPin {
identifier: string;
version: string;
revision: string;
digest: string | null;
}
export interface PolygonAuthority {
generation: number;
commandTtlMaxNs: number;
heartbeatTimeoutMonotonicNs: number;
simulationOrShadowOnly: boolean;
actuatorAuthority: boolean;
navigationOrSafetyAccepted: boolean;
directActuatorSetpointsAllowed: boolean;
}
export interface PolygonRun extends PolygonRunSummary {
scenarioSha256: string;
profileSha256: string;
hostProfileSha256: string;
seed: number;
parentRunId: string | null;
providers: PolygonProviderPin[];
authority: PolygonAuthority;
}
export interface PolygonRunEvent {
runId: string;
sequence: number;
eventType: string;
observedAtUtc: string;
hostMonotonicNs: number;
simTimeNs: number | null;
payload: Record<string, unknown>;
}
export interface PolygonRunArtifact {
artifactId: string;
kind: string;
relativePath: string;
sha256: string;
byteLength: number;
sourceOfRecord: boolean;
sequence: number;
}
export interface PolygonRunCatalog {
items: PolygonRunSummary[];
total: number;
limitations: string[];
}
export interface PolygonRunDetail {
run: PolygonRun;
events: PolygonRunEvent[];
eventsTotal: number;
eventsTruncated: boolean;
commandCount: number;
artifacts: PolygonRunArtifact[];
limitations: string[];
}
export interface PolygonRunRoute {
active: boolean;
runId: string | null;
error: string | null;
}
export class PolygonRunContractError extends Error {}
export class PolygonRunApiError extends Error {
constructor(message: string, readonly status: number | null = null) {
super(message);
}
}
type PolygonRunFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const SHA256 = /^[a-f0-9]{64}$/;
const GIT_REVISION = /^[a-f0-9]{7,64}$/;
const ISO_WITH_TIMEZONE = /^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/;
const RUN_STATES = new Set<PolygonRunState>([
"admitted",
"starting",
"running",
"paused",
"stopping",
"completed",
"failed",
"aborted",
]);
const REPRODUCIBILITY_TIERS = new Set(["R0", "R1", "R2"]);
const SUMMARY_KEYS = new Set([
"run_id",
"episode_id",
"kind",
"state",
"created_at_utc",
"started_at_utc",
"ended_at_utc",
"terminal_reason",
"scenario_generation",
"profile_generation",
"mission_core_commit",
"host_profile_id",
"reproducibility_tier",
"clock_domain",
"revision",
"provider_ids",
"artifact_count",
]);
const RUN_KEYS = new Set([
...SUMMARY_KEYS,
"scenario_sha256",
"profile_sha256",
"host_profile_sha256",
"seed",
"parent_run_id",
"providers",
"authority",
]);
const PROVIDER_KEYS = new Set(["identifier", "version", "revision", "digest"]);
const AUTHORITY_KEYS = new Set([
"generation",
"command_ttl_max_ns",
"heartbeat_timeout_monotonic_ns",
"simulation_or_shadow_only",
"actuator_authority",
"navigation_or_safety_accepted",
"direct_actuator_setpoints_allowed",
]);
const EVENT_KEYS = new Set([
"schema_version",
"run_id",
"sequence",
"event_type",
"observed_at_utc",
"host_monotonic_ns",
"sim_time_ns",
"payload",
]);
const ARTIFACT_KEYS = new Set([
"artifact_id",
"kind",
"relative_path",
"sha256",
"byte_length",
"source_of_record",
"sequence",
]);
const CATALOG_KEYS = new Set([
"schema_version",
"access",
"items",
"total",
"limitations",
]);
const DETAIL_KEYS = new Set([
"schema_version",
"access",
"run",
"events",
"events_total",
"events_truncated",
"commands",
"artifacts",
"limitations",
]);
const COMMAND_KEYS = new Set(["count", "content_exposed"]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function assertExactKeys(
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 PolygonRunContractError(`${label} содержит неизвестные или отсутствующие поля.`);
}
}
function requireString(value: unknown, field: string, maximum = 512): string {
if (typeof value !== "string") {
throw new PolygonRunContractError(`Поле ${field} должно быть строкой.`);
}
const normalized = value.trim();
if (!normalized || normalized.length > maximum) {
throw new PolygonRunContractError(`Поле ${field} имеет недопустимую длину.`);
}
return normalized;
}
function requireId(value: unknown, field: string): string {
const identifier = requireString(value, field, 128);
if (!SAFE_ID.test(identifier)) {
throw new PolygonRunContractError(`Поле ${field} содержит небезопасный идентификатор.`);
}
return identifier;
}
function requireInteger(value: unknown, field: string, minimum = 0): number {
if (
typeof value !== "number" ||
!Number.isSafeInteger(value) ||
value < minimum
) {
throw new PolygonRunContractError(`Поле ${field} должно быть безопасным целым числом.`);
}
return value;
}
function requireTimestamp(value: unknown, field: string): string {
const timestamp = requireString(value, field, 64);
if (!ISO_WITH_TIMEZONE.test(timestamp) || !Number.isFinite(Date.parse(timestamp))) {
throw new PolygonRunContractError(`Поле ${field} должно быть ISO-датой с часовым поясом.`);
}
return timestamp;
}
function optionalTimestamp(value: unknown, field: string): string | null {
return value === null ? null : requireTimestamp(value, field);
}
function optionalString(value: unknown, field: string, maximum = 512): string | null {
return value === null ? null : requireString(value, field, maximum);
}
function requireBoolean(value: unknown, field: string): boolean {
if (typeof value !== "boolean") {
throw new PolygonRunContractError(`Поле ${field} должно быть boolean.`);
}
return value;
}
function requireSha256(value: unknown, field: string): string {
const digest = requireString(value, field, 64);
if (!SHA256.test(digest)) {
throw new PolygonRunContractError(`Поле ${field} должно содержать SHA-256.`);
}
return digest;
}
function decodeLimitations(value: unknown): string[] {
if (!Array.isArray(value) || value.length > 16) {
throw new PolygonRunContractError("Ограничения UI-0 должны быть ограниченным массивом.");
}
return value.map((entry, index) => requireString(entry, `limitations[${index}]`, 1_000));
}
function decodeSummary(
value: unknown,
expectedKeys: ReadonlySet<string> = SUMMARY_KEYS,
): PolygonRunSummary {
if (!isRecord(value)) {
throw new PolygonRunContractError("Сводка прогона должна быть объектом.");
}
assertExactKeys(value, expectedKeys, "Сводка прогона");
const stateValue = requireString(value.state, "state", 32);
if (!RUN_STATES.has(stateValue as PolygonRunState)) {
throw new PolygonRunContractError("Прогон содержит неизвестное состояние.");
}
const tier = requireString(value.reproducibility_tier, "reproducibility_tier", 2);
if (!REPRODUCIBILITY_TIERS.has(tier)) {
throw new PolygonRunContractError("Прогон содержит неизвестный уровень воспроизводимости.");
}
const commit = requireString(value.mission_core_commit, "mission_core_commit", 64);
if (!GIT_REVISION.test(commit)) {
throw new PolygonRunContractError("Прогон не содержит допустимую ревизию Mission Core.");
}
if (!Array.isArray(value.provider_ids) || value.provider_ids.length > 32) {
throw new PolygonRunContractError("Поле provider_ids должно быть ограниченным массивом.");
}
const providerIds = value.provider_ids.map((entry, index) =>
requireId(entry, `provider_ids[${index}]`));
if (new Set(providerIds).size !== providerIds.length) {
throw new PolygonRunContractError("Поле provider_ids содержит повторения.");
}
const startedAtUtc = optionalTimestamp(value.started_at_utc, "started_at_utc");
const endedAtUtc = optionalTimestamp(value.ended_at_utc, "ended_at_utc");
if (startedAtUtc && endedAtUtc && Date.parse(endedAtUtc) < Date.parse(startedAtUtc)) {
throw new PolygonRunContractError("Прогон завершился раньше времени запуска.");
}
return {
runId: requireId(value.run_id, "run_id"),
episodeId: requireId(value.episode_id, "episode_id"),
kind: requireId(value.kind, "kind"),
state: stateValue as PolygonRunState,
createdAtUtc: requireTimestamp(value.created_at_utc, "created_at_utc"),
startedAtUtc,
endedAtUtc,
terminalReason: optionalString(value.terminal_reason, "terminal_reason"),
scenarioGeneration: requireId(value.scenario_generation, "scenario_generation"),
profileGeneration: requireId(value.profile_generation, "profile_generation"),
missionCoreCommit: commit,
hostProfileId: requireId(value.host_profile_id, "host_profile_id"),
reproducibilityTier: tier as "R0" | "R1" | "R2",
clockDomain: requireString(value.clock_domain, "clock_domain", 128),
revision: requireInteger(value.revision, "revision"),
providerIds,
artifactCount: requireInteger(value.artifact_count, "artifact_count"),
};
}
function decodeProvider(value: unknown, index: number): PolygonProviderPin {
if (!isRecord(value)) {
throw new PolygonRunContractError(`providers[${index}] должен быть объектом.`);
}
assertExactKeys(value, PROVIDER_KEYS, `providers[${index}]`);
const digest = value.digest === null ? null : requireSha256(value.digest, `providers[${index}].digest`);
return {
identifier: requireId(value.identifier, `providers[${index}].identifier`),
version: requireString(value.version, `providers[${index}].version`, 128),
revision: requireString(value.revision, `providers[${index}].revision`, 128),
digest,
};
}
function decodeAuthority(value: unknown): PolygonAuthority {
if (!isRecord(value)) {
throw new PolygonRunContractError("authority должен быть объектом.");
}
assertExactKeys(value, AUTHORITY_KEYS, "authority");
return {
generation: requireInteger(value.generation, "authority.generation", 1),
commandTtlMaxNs: requireInteger(value.command_ttl_max_ns, "authority.command_ttl_max_ns", 1),
heartbeatTimeoutMonotonicNs: requireInteger(
value.heartbeat_timeout_monotonic_ns,
"authority.heartbeat_timeout_monotonic_ns",
1,
),
simulationOrShadowOnly: requireBoolean(
value.simulation_or_shadow_only,
"authority.simulation_or_shadow_only",
),
actuatorAuthority: requireBoolean(value.actuator_authority, "authority.actuator_authority"),
navigationOrSafetyAccepted: requireBoolean(
value.navigation_or_safety_accepted,
"authority.navigation_or_safety_accepted",
),
directActuatorSetpointsAllowed: requireBoolean(
value.direct_actuator_setpoints_allowed,
"authority.direct_actuator_setpoints_allowed",
),
};
}
function decodeRun(value: unknown): PolygonRun {
if (!isRecord(value)) {
throw new PolygonRunContractError("Прогон должен быть объектом.");
}
const summary = decodeSummary(value, RUN_KEYS);
if (!Array.isArray(value.providers) || value.providers.length > 32) {
throw new PolygonRunContractError("providers должен быть ограниченным массивом.");
}
const providers = value.providers.map(decodeProvider);
if (
providers.length !== summary.providerIds.length ||
providers.some((provider, index) => provider.identifier !== summary.providerIds[index])
) {
throw new PolygonRunContractError("Провайдеры прогона не совпадают со сводкой.");
}
const parentRunId = value.parent_run_id === null
? null
: requireId(value.parent_run_id, "parent_run_id");
return {
...summary,
scenarioSha256: requireSha256(value.scenario_sha256, "scenario_sha256"),
profileSha256: requireSha256(value.profile_sha256, "profile_sha256"),
hostProfileSha256: requireSha256(value.host_profile_sha256, "host_profile_sha256"),
seed: requireInteger(value.seed, "seed"),
parentRunId,
providers,
authority: decodeAuthority(value.authority),
};
}
function decodeEvent(value: unknown, index: number, runId: string): PolygonRunEvent {
if (!isRecord(value)) {
throw new PolygonRunContractError(`events[${index}] должен быть объектом.`);
}
assertExactKeys(value, EVENT_KEYS, `events[${index}]`);
if (value.schema_version !== "missioncore.qualification-event/v1") {
throw new PolygonRunContractError(`events[${index}] содержит неизвестную схему.`);
}
if (!isRecord(value.payload)) {
throw new PolygonRunContractError(`events[${index}].payload должен быть объектом.`);
}
const eventRunId = requireId(value.run_id, `events[${index}].run_id`);
if (eventRunId !== runId) {
throw new PolygonRunContractError(`events[${index}] относится к другому прогону.`);
}
return {
runId: eventRunId,
sequence: requireInteger(value.sequence, `events[${index}].sequence`, 1),
eventType: requireId(value.event_type, `events[${index}].event_type`),
observedAtUtc: requireTimestamp(value.observed_at_utc, `events[${index}].observed_at_utc`),
hostMonotonicNs: requireInteger(
value.host_monotonic_ns,
`events[${index}].host_monotonic_ns`,
),
simTimeNs: value.sim_time_ns === null
? null
: requireInteger(value.sim_time_ns, `events[${index}].sim_time_ns`),
payload: value.payload,
};
}
function decodeArtifact(value: unknown, index: number): PolygonRunArtifact {
if (!isRecord(value)) {
throw new PolygonRunContractError(`artifacts[${index}] должен быть объектом.`);
}
assertExactKeys(value, ARTIFACT_KEYS, `artifacts[${index}]`);
const relativePath = requireString(value.relative_path, `artifacts[${index}].relative_path`, 512);
const segments = relativePath.split("/");
if (
relativePath.startsWith("/") ||
relativePath.includes("\\") ||
segments.some((segment) => !segment || segment === "." || segment === "..")
) {
throw new PolygonRunContractError(`artifacts[${index}] содержит небезопасный путь.`);
}
return {
artifactId: requireId(value.artifact_id, `artifacts[${index}].artifact_id`),
kind: requireId(value.kind, `artifacts[${index}].kind`),
relativePath,
sha256: requireSha256(value.sha256, `artifacts[${index}].sha256`),
byteLength: requireInteger(value.byte_length, `artifacts[${index}].byte_length`),
sourceOfRecord: requireBoolean(
value.source_of_record,
`artifacts[${index}].source_of_record`,
),
sequence: requireInteger(value.sequence, `artifacts[${index}].sequence`, 1),
};
}
export function decodePolygonRunCatalog(payload: unknown): PolygonRunCatalog {
if (!isRecord(payload)) {
throw new PolygonRunContractError("Каталог прогонов должен быть объектом.");
}
assertExactKeys(payload, CATALOG_KEYS, "Каталог прогонов");
if (
payload.schema_version !== "missioncore.polygon-run-catalog/v1" ||
payload.access !== "read-only"
) {
throw new PolygonRunContractError("Каталог не является поддерживаемым read-only контрактом.");
}
if (!Array.isArray(payload.items) || payload.items.length > 100) {
throw new PolygonRunContractError("Каталог должен содержать ограниченный массив items.");
}
const items = payload.items.map((item) => decodeSummary(item));
if (new Set(items.map(({ runId }) => runId)).size !== items.length) {
throw new PolygonRunContractError("Каталог содержит повторяющиеся прогоны.");
}
const total = requireInteger(payload.total, "total");
if (total < items.length) {
throw new PolygonRunContractError("Размер каталога меньше числа возвращённых прогонов.");
}
return {
items,
total,
limitations: decodeLimitations(payload.limitations),
};
}
export function decodePolygonRunDetail(payload: unknown): PolygonRunDetail {
if (!isRecord(payload)) {
throw new PolygonRunContractError("Детали прогона должны быть объектом.");
}
assertExactKeys(payload, DETAIL_KEYS, "Детали прогона");
if (
payload.schema_version !== "missioncore.polygon-run-detail/v1" ||
payload.access !== "read-only"
) {
throw new PolygonRunContractError("Детали не являются поддерживаемым read-only контрактом.");
}
const run = decodeRun(payload.run);
if (!Array.isArray(payload.events) || payload.events.length > 500) {
throw new PolygonRunContractError("Журнал событий должен быть ограниченным массивом.");
}
const events = payload.events.map((event, index) => decodeEvent(event, index, run.runId));
for (let index = 1; index < events.length; index += 1) {
if (events[index].sequence !== events[index - 1].sequence + 1) {
throw new PolygonRunContractError("Возвращённый фрагмент событий не является непрерывным.");
}
}
const eventsTotal = requireInteger(payload.events_total, "events_total");
const eventsTruncated = requireBoolean(payload.events_truncated, "events_truncated");
if (eventsTotal < events.length || eventsTruncated !== (eventsTotal !== events.length)) {
throw new PolygonRunContractError("Метаданные усечения событий противоречат журналу.");
}
if (!isRecord(payload.commands)) {
throw new PolygonRunContractError("commands должен быть объектом.");
}
assertExactKeys(payload.commands, COMMAND_KEYS, "commands");
if (payload.commands.content_exposed !== false) {
throw new PolygonRunContractError("UI-0 не принимает содержимое команд.");
}
if (!Array.isArray(payload.artifacts) || payload.artifacts.length > 500) {
throw new PolygonRunContractError("Артефакты должны быть ограниченным массивом.");
}
const artifacts = payload.artifacts.map(decodeArtifact);
return {
run,
events,
eventsTotal,
eventsTruncated,
commandCount: requireInteger(payload.commands.count, "commands.count"),
artifacts,
limitations: decodeLimitations(payload.limitations),
};
}
export function resolvePolygonRunRoute(search: string): PolygonRunRoute {
const parameters = new URLSearchParams(search);
const workspaceValues = parameters.getAll("workspace");
if (workspaceValues.length !== 1 || workspaceValues[0] !== "polygon-run") {
return { active: false, runId: null, error: null };
}
const runValues = parameters.getAll("run");
if (runValues.length === 0) {
return { active: true, runId: null, error: null };
}
if (runValues.length !== 1 || !SAFE_ID.test(runValues[0])) {
return {
active: true,
runId: null,
error: "Прямая ссылка содержит некорректный идентификатор прогона.",
};
}
return { active: true, runId: runValues[0], error: null };
}
async function responseBody(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().startsWith("application/json")) {
throw new PolygonRunContractError("Polygon API вернул ответ не в формате JSON.");
}
try {
return await response.json();
} catch {
throw new PolygonRunContractError("Polygon API вернул повреждённый JSON.");
}
}
function apiErrorMessage(body: unknown, fallback: string): string {
if (!isRecord(body) || typeof body.detail !== "string") return fallback;
const detail = body.detail.trim();
return detail && detail.length <= 1_000 ? detail : fallback;
}
async function getJson(
url: string,
fallback: string,
signal: AbortSignal | undefined,
fetcher: PolygonRunFetch,
): Promise<unknown> {
let response: Response;
try {
response = await fetcher(url, {
method: "GET",
headers: { Accept: "application/json" },
signal,
});
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new PolygonRunApiError(fallback);
}
const body = await responseBody(response);
if (!response.ok) {
throw new PolygonRunApiError(
apiErrorMessage(body, `${fallback} HTTP ${response.status}.`),
response.status,
);
}
return body;
}
export async function fetchPolygonRunCatalog({
signal,
limit = 20,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
limit?: number;
fetcher?: PolygonRunFetch;
} = {}): Promise<PolygonRunCatalog> {
const admittedLimit = Number.isInteger(limit) && limit >= 1 && limit <= 100 ? limit : 20;
const body = await getJson(
`/api/v1/polygon/runs?limit=${admittedLimit}`,
"Не удалось загрузить каталог прогонов Полигона.",
signal,
fetcher,
);
return decodePolygonRunCatalog(body);
}
export async function fetchPolygonRunDetail(
runId: string,
{
signal,
eventLimit = 200,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
eventLimit?: number;
fetcher?: PolygonRunFetch;
} = {},
): Promise<PolygonRunDetail> {
if (!SAFE_ID.test(runId)) {
throw new PolygonRunContractError("Идентификатор прогона имеет недопустимый формат.");
}
const admittedLimit = Number.isInteger(eventLimit) && eventLimit >= 1 && eventLimit <= 500
? eventLimit
: 200;
const body = await getJson(
`/api/v1/polygon/runs/${encodeURIComponent(runId)}?event_limit=${admittedLimit}`,
"Не удалось загрузить доказательства прогона.",
signal,
fetcher,
);
return decodePolygonRunDetail(body);
}

View File

@ -0,0 +1,58 @@
import { useEffect, useState } from "react";
import {
fetchPolygonWorkerStatus,
type PolygonWorkerStatus,
} from "./liveWorker";
export interface PolygonWorkerCapability {
checked: boolean;
available: boolean;
status: PolygonWorkerStatus | null;
}
const initialCapability: PolygonWorkerCapability = {
checked: false,
available: false,
status: null,
};
export function usePolygonWorkerCapability(): PolygonWorkerCapability {
const [capability, setCapability] = useState(initialCapability);
useEffect(() => {
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;
setCapability({
checked: true,
available: status.available,
status,
});
} catch {
if (controller.signal.aborted) return;
setCapability({
checked: true,
available: false,
status: null,
});
} finally {
if (!controller.signal.aborted) {
timer = window.setTimeout(() => void poll(), 3_000);
}
}
};
void poll();
return () => {
controller.abort();
if (timer !== null) window.clearTimeout(timer);
};
}, []);
return capability;
}

View File

@ -1,6 +1,13 @@
import type { IconName } from "@nodedc/ui-react";
export type RootId = "center" | "fleet" | "observation" | "missions" | "data" | "system";
export type RootId =
| "center"
| "fleet"
| "observation"
| "missions"
| "data"
| "polygon"
| "system";
export type WorkspaceKind =
| "overview"
@ -10,7 +17,8 @@ export type WorkspaceKind =
| "map"
| "timeline"
| "missions"
| "catalog";
| "catalog"
| "polygon-run";
export type CapabilityStatus = "active" | "ready" | "contract" | "later";
@ -35,6 +43,7 @@ export interface WorkspaceDefinition {
description: string;
icon: IconName;
kind: WorkspaceKind;
internalOnly?: boolean;
groups: CapabilityGroup[];
}
@ -118,6 +127,15 @@ export const roots: RootDefinition[] = [
statement: "Хранить живой контур и воспроизводимый эксперимент как одну модель данных.",
accent: "ПОТОКИ И ДОКАЗАТЕЛЬСТВА",
},
{
id: "polygon",
label: "Полигон",
title: "Полигон",
eyebrow: "СИМУЛЯЦИЯ И КВАЛИФИКАЦИЯ",
description: "Живые PX4/Gazebo прогоны, сценарии, траектории и доказательства.",
statement: "Проверять поведение машины в воспроизводимой виртуальной среде.",
accent: "СИМУЛЯЦИЯ И ДОКАЗАТЕЛЬСТВА",
},
{
id: "system",
label: "Система",
@ -130,6 +148,17 @@ export const roots: RootDefinition[] = [
];
export const workspaces: WorkspaceDefinition[] = [
{
id: "polygon-run",
root: "polygon",
label: "Полигон",
title: "Полигон",
eyebrow: "ПОЛИГОН / LIVE",
description: "Live Simulation Worker и доказательства прогонов PX4/Gazebo.",
icon: "activity",
kind: "polygon-run",
groups: [],
},
{
id: "command-overview",
root: "center",
@ -742,12 +771,22 @@ export function rootById(id: RootId | null): RootDefinition | null {
return id ? roots.find((root) => root.id === id) ?? null : null;
}
export function rootsForCapabilities({
polygonWorkerAvailable,
}: {
polygonWorkerAvailable: boolean;
}): RootDefinition[] {
return roots.filter((root) => root.id !== "polygon" || polygonWorkerAvailable);
}
export function workspaceById(id: string | null): WorkspaceDefinition | null {
return id ? workspaces.find((workspace) => workspace.id === id) ?? null : null;
}
export function workspacesForRoot(root: RootId | null): WorkspaceDefinition[] {
return root ? workspaces.filter((workspace) => workspace.root === root) : [];
return root
? workspaces.filter((workspace) => workspace.root === root && !workspace.internalOnly)
: [];
}
export const capabilityStatusLabel: Record<CapabilityStatus, string> = {

View File

@ -10,10 +10,17 @@
@media (max-width: 1280px) {
.overview-grid,
.mission-layout {
.mission-layout,
.polygon-live-layout,
.polygon-run-layout,
.polygon-run-evidence-grid {
grid-template-columns: 1fr;
}
.polygon-run-providers {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.pipeline-strip {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@ -67,12 +74,25 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.polygon-run-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.workspace-lead {
align-items: flex-start;
flex-direction: column;
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;
@ -131,10 +151,38 @@
.metrics-grid,
.capability-summary,
.camera-grid {
.camera-grid,
.polygon-run-metrics,
.polygon-run-providers {
grid-template-columns: 1fr;
}
.polygon-run-identity dl {
grid-template-columns: 1fr;
}
.polygon-live-heading > div:last-child {
align-items: stretch;
flex-direction: column;
}
.polygon-rover-scene {
min-height: 19rem;
}
.polygon-rover-scene__frames small,
.polygon-rover-scene__help {
display: none;
}
.polygon-run-event-list article {
grid-template-columns: 2.3rem minmax(0, 1fr);
}
.polygon-run-event-list article > span:last-child {
grid-column: 2;
}
.camera-grid {
grid-template-rows: repeat(4, minmax(14rem, 1fr));
}

View File

@ -90,6 +90,691 @@
line-height: 1.4;
}
.polygon-run-workspace {
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-rover-scene {
position: relative;
overflow: hidden;
min-height: 25rem;
border: 1px solid var(--station-hairline);
border-radius: 1rem;
background:
radial-gradient(circle at 62% 28%, rgb(var(--nodedc-accent-rgb) / 0.13), transparent 42%),
linear-gradient(155deg, #0d1722, #070c12 70%);
}
.polygon-rover-scene--loading {
display: grid;
place-items: center;
color: var(--nodedc-text-muted);
font-size: 0.62rem;
}
.polygon-rover-scene__viewport,
.polygon-rover-scene__viewport canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.polygon-rover-scene__viewport canvas {
cursor: grab;
touch-action: none;
}
.polygon-rover-scene__viewport canvas:active {
cursor: grabbing;
}
.polygon-rover-scene__toolbar,
.polygon-rover-scene__help,
.polygon-rover-scene__frames,
.polygon-rover-scene__idle {
position: absolute;
z-index: 2;
}
.polygon-rover-scene__toolbar {
top: 0.75rem;
left: 0.75rem;
display: flex;
gap: 0.4rem;
}
.polygon-rover-scene__toolbar button {
border: 1px solid rgb(255 255 255 / 0.14);
border-radius: 999px;
background: rgb(7 12 18 / 0.76);
color: var(--nodedc-text-secondary);
padding: 0.38rem 0.62rem;
font: inherit;
font-size: 0.54rem;
line-height: 1;
backdrop-filter: blur(16px);
cursor: pointer;
}
.polygon-rover-scene__toolbar button:hover {
border-color: rgb(var(--nodedc-accent-rgb) / 0.5);
color: var(--nodedc-text-primary);
}
.polygon-rover-scene__help {
bottom: 0.75rem;
left: 0.75rem;
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.polygon-rover-scene__help span,
.polygon-rover-scene__frames,
.polygon-rover-scene__frames small {
color: var(--nodedc-text-muted);
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.58rem;
line-height: 1.45;
}
.polygon-rover-scene__frames {
right: 0.75rem;
bottom: 0.7rem;
display: flex;
align-items: center;
gap: 0.45rem;
}
.polygon-rover-scene__frames span {
display: inline-flex;
align-items: center;
gap: 0.2rem;
}
.polygon-rover-scene__frames i {
width: 0.36rem;
height: 0.36rem;
border-radius: 50%;
background: #fff;
}
.polygon-rover-scene__frames i[data-axis="east"] {
background: #ff5f6d;
}
.polygon-rover-scene__frames i[data-axis="north"] {
background: #4ee19b;
}
.polygon-rover-scene__frames i[data-axis="up"] {
background: #5d9cff;
}
.polygon-rover-scene__frames small {
margin-left: 0.2rem;
}
.polygon-rover-scene__idle {
top: 4.1rem;
right: 0.8rem;
display: grid;
max-width: min(18rem, 52%);
gap: 0.2rem;
border: 1px solid rgb(255 255 255 / 0.09);
border-radius: 0.72rem;
background: rgb(7 12 18 / 0.58);
padding: 0.55rem 0.7rem;
text-align: right;
backdrop-filter: blur(14px);
pointer-events: none;
}
.polygon-rover-scene__idle strong {
color: var(--nodedc-text-primary);
font-size: 0.68rem;
}
.polygon-rover-scene__idle span,
.polygon-rover-scene__error {
color: var(--nodedc-text-muted);
font-size: 0.56rem;
line-height: 1.45;
}
.polygon-rover-scene__error {
position: absolute;
inset: 50% auto auto 50%;
margin: 0;
transform: translate(-50%, -50%);
}
.polygon-live-telemetry {
display: grid;
align-content: start;
gap: 0.42rem;
}
.polygon-live-telemetry > .polygon-rover-controls {
gap: 0.55rem;
border: 1px solid rgb(var(--nodedc-accent-rgb) / 0.24);
background:
radial-gradient(circle at 0 0, rgb(var(--nodedc-accent-rgb) / 0.1), transparent 58%),
rgb(255 255 255 / 0.03);
}
.polygon-rover-controls__heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.polygon-rover-controls__heading > span {
color: var(--nodedc-text-primary);
font-size: 0.62rem;
font-weight: 680;
}
.polygon-rover-controls__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.38rem;
}
.polygon-rover-controls__grid > button:last-child {
grid-column: 1 / -1;
}
.polygon-rover-controls small {
color: var(--nodedc-text-muted);
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.53rem;
line-height: 1.45;
}
.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;
place-items: start;
align-content: center;
gap: 0.8rem;
}
.polygon-run-message h2,
.polygon-run-message p,
.polygon-run-panel-heading h3,
.polygon-run-artifacts h3,
.polygon-run-limitations h3 {
margin: 0;
}
.polygon-run-message h2 {
color: var(--nodedc-text-primary);
font-size: 1.35rem;
letter-spacing: -0.035em;
}
.polygon-run-message p {
max-width: 38rem;
color: var(--nodedc-text-muted);
font-size: 0.72rem;
line-height: 1.55;
}
.polygon-run-lead-status {
display: grid;
justify-items: end;
gap: 0.45rem;
}
.polygon-run-lead-status > span {
color: var(--nodedc-text-muted);
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.58rem;
}
.polygon-run-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.65rem;
}
.polygon-run-metrics > div {
display: grid;
min-height: 5.5rem;
align-content: space-between;
gap: 0.32rem;
border-radius: 1rem;
background: var(--station-panel);
padding: 0.9rem 1rem;
}
.polygon-run-metrics span,
.polygon-run-metrics small,
.polygon-run-panel-heading > span {
color: var(--nodedc-text-muted);
font-size: 0.6rem;
}
.polygon-run-metrics strong {
overflow: hidden;
color: var(--nodedc-text-primary);
font-size: 1.05rem;
font-weight: 680;
text-overflow: ellipsis;
white-space: nowrap;
}
.polygon-run-metrics small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.polygon-run-layout,
.polygon-run-evidence-grid {
display: grid;
grid-template-columns: minmax(17rem, 0.72fr) minmax(0, 1.28fr);
gap: 0.75rem;
}
.polygon-run-panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.polygon-run-panel-heading h3,
.polygon-run-artifacts h3,
.polygon-run-limitations h3 {
margin-top: 0.4rem;
color: var(--nodedc-text-primary);
font-size: 0.94rem;
letter-spacing: -0.025em;
}
.polygon-run-list {
display: grid;
gap: 0.35rem;
}
.polygon-run-list button {
display: grid;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.65rem;
border: 0;
border-radius: 0.8rem;
background: rgb(255 255 255 / 0.025);
padding: 0.65rem 0.7rem;
text-align: left;
cursor: pointer;
}
.polygon-run-list button:hover,
.polygon-run-list button:focus-visible,
.polygon-run-list button[data-active="true"] {
outline: 0;
background: rgb(255 255 255 / 0.07);
}
.polygon-run-list button > i,
.polygon-run-providers i {
width: 0.48rem;
height: 0.48rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
}
.polygon-run-list button > i[data-state="completed"],
.polygon-run-providers i {
background: rgb(var(--nodedc-success-rgb));
}
.polygon-run-list button > i[data-state="failed"],
.polygon-run-list button > i[data-state="aborted"] {
background: rgb(var(--nodedc-danger-rgb));
}
.polygon-run-list button > i[data-state="running"] {
background: var(--nodedc-text-primary);
}
.polygon-run-list button > span {
display: grid;
min-width: 0;
gap: 0.18rem;
}
.polygon-run-list strong,
.polygon-run-list small,
.polygon-run-list em {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.polygon-run-list strong {
color: var(--nodedc-text-primary);
font-size: 0.66rem;
}
.polygon-run-list small,
.polygon-run-list em {
color: var(--nodedc-text-muted);
font-size: 0.56rem;
}
.polygon-run-list em {
font-style: normal;
}
.polygon-run-identity dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.3rem 1rem;
margin: 0.9rem 0 0;
}
.polygon-run-identity dl > div {
display: grid;
min-width: 0;
grid-template-columns: 7rem minmax(0, 1fr);
gap: 0.7rem;
border-bottom: 1px solid var(--station-hairline);
padding: 0.48rem 0;
}
.polygon-run-identity dt,
.polygon-run-identity dd {
overflow: hidden;
margin: 0;
font-size: 0.61rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.polygon-run-identity dt {
color: var(--nodedc-text-muted);
}
.polygon-run-identity dd {
color: var(--nodedc-text-primary);
}
.polygon-run-safety-boundary {
display: grid;
gap: 0.45rem;
margin-top: 1rem;
border-radius: 0.8rem;
background: rgb(255 255 255 / 0.035);
padding: 0.75rem;
}
.polygon-run-safety-boundary p {
margin: 0;
color: var(--nodedc-text-muted);
font-size: 0.59rem;
line-height: 1.5;
}
.polygon-run-providers {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.55rem;
}
.polygon-run-providers > div {
display: grid;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.22rem 0.55rem;
border-radius: 0.85rem;
background: var(--station-panel);
padding: 0.72rem;
}
.polygon-run-providers i {
grid-row: 1 / 3;
align-self: center;
}
.polygon-run-providers span {
display: grid;
min-width: 0;
gap: 0.15rem;
}
.polygon-run-providers strong,
.polygon-run-providers small,
.polygon-run-providers code {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.polygon-run-providers strong {
color: var(--nodedc-text-primary);
font-size: 0.64rem;
}
.polygon-run-providers small,
.polygon-run-providers code {
color: var(--nodedc-text-muted);
font-size: 0.54rem;
}
.polygon-run-providers code {
grid-column: 2;
}
.polygon-run-event-list {
display: grid;
}
.polygon-run-event-list article {
display: grid;
min-width: 0;
grid-template-columns: 2.3rem minmax(0, 1fr) auto;
align-items: start;
gap: 0.8rem;
border-top: 1px solid var(--station-hairline);
padding: 0.75rem 0;
}
.polygon-run-event-list article:first-child {
border-top: 0;
}
.polygon-run-event-list article > div {
display: grid;
min-width: 0;
gap: 0.2rem;
}
.polygon-run-event-list strong {
color: var(--nodedc-text-primary);
font-size: 0.66rem;
}
.polygon-run-event-list small,
.polygon-run-event-list article > span:last-child,
.polygon-run-event-sequence {
color: var(--nodedc-text-muted);
font-size: 0.55rem;
}
.polygon-run-event-list code {
overflow: hidden;
color: var(--nodedc-text-secondary);
font-size: 0.56rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.polygon-run-event-sequence {
border-radius: 0.4rem;
background: rgb(255 255 255 / 0.04);
padding: 0.2rem 0.3rem;
text-align: center;
}
.polygon-run-artifacts > div {
display: grid;
gap: 0.35rem;
margin-top: 0.85rem;
}
.polygon-run-artifacts article {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
border-radius: 0.75rem;
background: rgb(255 255 255 / 0.025);
padding: 0.65rem;
}
.polygon-run-artifacts article > span {
display: grid;
min-width: 0;
gap: 0.18rem;
}
.polygon-run-artifacts strong,
.polygon-run-artifacts small,
.polygon-run-artifacts code {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.polygon-run-artifacts strong {
color: var(--nodedc-text-primary);
font-size: 0.62rem;
}
.polygon-run-artifacts small,
.polygon-run-artifacts code,
.polygon-run-artifacts p,
.polygon-run-limitations li {
color: var(--nodedc-text-muted);
font-size: 0.56rem;
}
.polygon-run-artifacts p {
margin: 0.9rem 0 0;
}
.polygon-run-limitations ul {
display: grid;
gap: 0.55rem;
margin: 0.85rem 0 0;
padding-left: 1rem;
}
.polygon-run-limitations li {
line-height: 1.5;
}
.capability-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));

View File

@ -0,0 +1,296 @@
import { lazy, Suspense, useEffect, useRef, useState } from "react";
import {
Button,
GlassSurface,
StatusBadge,
} from "@nodedc/ui-react";
import {
fetchPolygonVehicleState,
fetchPolygonWorkerStatus,
sendPolygonRoverCommand,
startPolygonWorker,
stopPolygonWorker,
type PolygonCommandAcceptance,
type PolygonVehicleState,
type PolygonWorkerStatus,
} from "../core/polygon/liveWorker";
interface ControlIntent {
id: "forward" | "reverse" | "left" | "right";
label: string;
speedMps: number;
steeringNormalized: number;
}
const CONTROL_INTENTS: ControlIntent[] = [
{ id: "forward", label: "Вперёд", speedMps: 1, steeringNormalized: 0 },
{ id: "left", label: "Влево", speedMps: 0.8, steeringNormalized: -0.55 },
{ id: "right", label: "Вправо", speedMps: 0.8, steeringNormalized: 0.55 },
{ id: "reverse", label: "Назад", speedMps: -0.65, steeringNormalized: 0 },
];
const PolygonRoverScene = lazy(async () => {
const module = await import("./PolygonRoverScene");
return { default: module.PolygonRoverScene };
});
function message(error: unknown): string {
if (error instanceof Error && error.message.trim()) return error.message;
return "Simulation Worker не подтвердил операцию.";
}
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 [controlIntent, setControlIntent] = useState<ControlIntent | null>(null);
const [commandAcceptance, setCommandAcceptance] =
useState<PolygonCommandAcceptance | null>(null);
const [generation, setGeneration] = useState(0);
const commandInFlight = useRef(false);
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 runActive = Boolean(worker?.activeRunId);
useEffect(() => {
const runId = worker?.activeRunId;
if (!runId || worker.runState !== "running" || !controlIntent) return;
const controller = new AbortController();
let timer: number | null = null;
const publish = async () => {
if (commandInFlight.current || controller.signal.aborted) return;
commandInFlight.current = true;
try {
const accepted = await sendPolygonRoverCommand(runId, {
speedMps: controlIntent.speedMps,
steeringNormalized: controlIntent.steeringNormalized,
idempotencyKey: crypto.randomUUID(),
signal: controller.signal,
});
if (controller.signal.aborted) return;
setCommandAcceptance(accepted);
setError(null);
} catch (commandError) {
if (controller.signal.aborted) return;
setControlIntent(null);
setError(message(commandError));
} finally {
commandInFlight.current = false;
if (!controller.signal.aborted) {
timer = window.setTimeout(() => void publish(), 100);
}
}
};
void publish();
return () => {
controller.abort();
if (timer !== null) window.clearTimeout(timer);
};
}, [controlIntent, worker?.activeRunId, worker?.runState]);
useEffect(() => {
if (!worker?.activeRunId) {
setControlIntent(null);
setCommandAcceptance(null);
}
}, [worker?.activeRunId]);
const stopMotion = async () => {
const runId = worker?.activeRunId;
setControlIntent(null);
if (!runId || worker?.runState !== "running") return;
try {
const accepted = await sendPolygonRoverCommand(runId, {
speedMps: 0,
steeringNormalized: 0,
idempotencyKey: crypto.randomUUID(),
});
setCommandAcceptance(accepted);
setError(null);
} catch (commandError) {
setError(message(commandError));
}
};
const runAction = async () => {
if (!worker?.controlAvailable || actionBusy) return;
setActionBusy(true);
setError(null);
try {
const idempotencyKey = crypto.randomUUID();
if (worker.activeRunId) {
await stopMotion();
}
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">
<Suspense
fallback={
<div className="polygon-rover-scene polygon-rover-scene--loading">
Загружаем 3D-сцену
</div>
}
>
<PolygonRoverScene
live={live}
trajectory={trajectory}
workerAvailable={Boolean(worker?.available)}
/>
</Suspense>
<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" : "Ожидает прогона"}
</StatusBadge>
</div>
<div className="polygon-rover-controls__grid">
{CONTROL_INTENTS.map((intent) => (
<Button
key={intent.id}
size="compact"
variant={controlIntent?.id === intent.id ? "primary" : "secondary"}
disabled={!runActive || worker?.runState !== "running" || actionBusy}
onClick={() => setControlIntent(intent)}
>
{intent.label}
</Button>
))}
<Button
size="compact"
variant="secondary"
disabled={!runActive || worker?.runState !== "running" || actionBusy}
onClick={() => void stopMotion()}
>
Стоп
</Button>
</div>
<small>
{controlIntent
? `${controlIntent.label}: ${controlIntent.speedMps.toFixed(2)} m/s · steering ${controlIntent.steeringNormalized.toFixed(2)}`
: "Команда 250 мс; потеря браузера автоматически обнуляет скорость."}
</small>
</div>
<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>
<span>Последняя команда</span>
<strong>
{commandAcceptance
? `#${commandAcceptance.sequence} · ${commandAcceptance.speedMps.toFixed(2)} m/s · ${commandAcceptance.steeringNormalized.toFixed(2)}`
: "—"}
</strong>
</div>
<div className="polygon-live-boundary">
<StatusBadge tone="success">Virtual only</StatusBadge>
<p>
Команды проходят PX4 ROS 2 Offboard только в SITL. Физического actuator
authority нет; live-поза остаётся диагностической.
</p>
</div>
{error && <p className="polygon-live-error">{error}</p>}
</div>
</div>
</GlassSurface>
);
}

View File

@ -0,0 +1,387 @@
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import type { PolygonVehicleState } from "../core/polygon/liveWorker";
interface PolygonRoverSceneProps {
live: PolygonVehicleState | null;
trajectory: PolygonVehicleState[];
workerAvailable: boolean;
}
const CAMERA_POSITION = new THREE.Vector3(5.3, 3.9, 5.3);
const CAMERA_TARGET = new THREE.Vector3(0, 0.55, 0);
function threePosition(state: PolygonVehicleState): THREE.Vector3 {
return new THREE.Vector3(
state.position.x,
Math.max(0, state.position.z),
-state.position.y,
);
}
function yawRadians(state: PolygonVehicleState): number {
const { x, y, z, w } = state.orientation;
return Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z));
}
function createRover(): THREE.Group {
const rover = new THREE.Group();
rover.name = "ackermann-rover";
const bodyMaterial = new THREE.MeshStandardMaterial({
color: 0x2879ff,
metalness: 0.42,
roughness: 0.3,
});
const darkMaterial = new THREE.MeshStandardMaterial({
color: 0x111820,
metalness: 0.25,
roughness: 0.58,
});
const trimMaterial = new THREE.MeshStandardMaterial({
color: 0xb8c9dc,
metalness: 0.72,
roughness: 0.22,
});
const glassMaterial = new THREE.MeshPhysicalMaterial({
color: 0x72b7d8,
metalness: 0.05,
roughness: 0.08,
transmission: 0.3,
transparent: true,
opacity: 0.72,
});
const tireMaterial = new THREE.MeshStandardMaterial({
color: 0x080a0d,
metalness: 0.05,
roughness: 0.9,
});
const headlightMaterial = new THREE.MeshStandardMaterial({
color: 0xf2fbff,
emissive: 0x8ecbff,
emissiveIntensity: 2.4,
});
const tailMaterial = new THREE.MeshStandardMaterial({
color: 0xff3355,
emissive: 0xff1838,
emissiveIntensity: 1.7,
});
const addBox = (
name: string,
size: [number, number, number],
position: [number, number, number],
material: THREE.Material,
) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(...size), material);
mesh.name = name;
mesh.position.set(...position);
mesh.castShadow = true;
mesh.receiveShadow = true;
rover.add(mesh);
return mesh;
};
addBox("lower-chassis", [2.55, 0.34, 1.32], [0, 0.58, 0], darkMaterial);
addBox("body-shell", [2.34, 0.42, 1.18], [0.02, 0.86, 0], bodyMaterial);
addBox("front-hood", [0.92, 0.24, 1.06], [0.69, 1.16, 0], bodyMaterial);
addBox("equipment-cabin", [0.82, 0.58, 1.0], [-0.35, 1.22, 0], darkMaterial);
addBox("windshield", [0.04, 0.42, 0.88], [0.08, 1.31, 0], glassMaterial)
.rotation.z = -0.18;
addBox("front-bumper", [0.16, 0.22, 1.48], [1.34, 0.56, 0], trimMaterial);
addBox("rear-bumper", [0.15, 0.2, 1.42], [-1.33, 0.55, 0], trimMaterial);
for (const x of [-0.82, 0.82]) {
for (const z of [-0.76, 0.76]) {
const wheel = new THREE.Mesh(
new THREE.CylinderGeometry(0.42, 0.42, 0.28, 24),
tireMaterial,
);
wheel.name = x > 0 ? "front-wheel" : "rear-wheel";
wheel.position.set(x, 0.43, z);
wheel.rotation.x = Math.PI / 2;
wheel.castShadow = true;
rover.add(wheel);
const hub = new THREE.Mesh(
new THREE.CylinderGeometry(0.16, 0.16, 0.3, 20),
trimMaterial,
);
hub.position.copy(wheel.position);
hub.rotation.x = Math.PI / 2;
hub.castShadow = true;
rover.add(hub);
}
}
for (const z of [-0.4, 0.4]) {
addBox("headlight", [0.08, 0.16, 0.22], [1.22, 0.9, z], headlightMaterial);
addBox("tail-light", [0.06, 0.15, 0.2], [-1.19, 0.88, z], tailMaterial);
}
const mast = new THREE.Mesh(
new THREE.CylinderGeometry(0.045, 0.055, 0.7, 16),
trimMaterial,
);
mast.position.set(-0.45, 1.85, 0);
mast.castShadow = true;
rover.add(mast);
const lidar = new THREE.Mesh(
new THREE.CylinderGeometry(0.22, 0.22, 0.14, 32),
darkMaterial,
);
lidar.name = "lidar";
lidar.position.set(-0.45, 2.22, 0);
lidar.castShadow = true;
rover.add(lidar);
const lidarBand = new THREE.Mesh(
new THREE.CylinderGeometry(0.225, 0.225, 0.045, 32),
glassMaterial,
);
lidarBand.position.set(-0.45, 2.22, 0);
rover.add(lidarBand);
return rover;
}
function disposeScene(scene: THREE.Scene) {
scene.traverse((object) => {
if (!(object instanceof THREE.Mesh || object instanceof THREE.Line)) return;
object.geometry.dispose();
const materials = Array.isArray(object.material) ? object.material : [object.material];
materials.forEach((material) => material.dispose());
});
}
export function PolygonRoverScene({
live,
trajectory,
workerAvailable,
}: PolygonRoverSceneProps) {
const hostRef = useRef<HTMLDivElement | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const controlsRef = useRef<OrbitControls | null>(null);
const roverRef = useRef<THREE.Group | null>(null);
const trajectoryRef = useRef<THREE.Line<THREE.BufferGeometry, THREE.LineBasicMaterial> | null>(
null,
);
const desiredPositionRef = useRef(new THREE.Vector3());
const desiredYawRef = useRef(0);
const followRef = useRef(true);
const [followRover, setFollowRover] = useState(true);
const [renderError, setRenderError] = useState<string | null>(null);
useEffect(() => {
desiredPositionRef.current.copy(live ? threePosition(live) : new THREE.Vector3());
desiredYawRef.current = live ? -yawRadians(live) : 0;
}, [live]);
useEffect(() => {
followRef.current = followRover;
}, [followRover]);
useEffect(() => {
const line = trajectoryRef.current;
if (!line) return;
const points = trajectory.map((state) => {
const point = threePosition(state);
point.y += 0.06;
return point;
});
line.geometry.setFromPoints(points);
line.geometry.computeBoundingSphere();
}, [trajectory]);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: "high-performance",
});
} catch {
setRenderError("Браузер не смог создать WebGL-сцену.");
return;
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.06;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.domElement.setAttribute("aria-label", "Интерактивная 3D-сцена Ackermann Rover");
host.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x081019, 0.025);
const camera = new THREE.PerspectiveCamera(46, 1, 0.08, 220);
camera.position.copy(CAMERA_POSITION);
cameraRef.current = camera;
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.07;
controls.enablePan = true;
controls.enableZoom = true;
controls.minDistance = 2.2;
controls.maxDistance = 70;
controls.minPolarAngle = 0.04;
controls.maxPolarAngle = Math.PI / 2 - 0.02;
controls.target.copy(CAMERA_TARGET);
controls.update();
controlsRef.current = controls;
const hemisphere = new THREE.HemisphereLight(0xaed7ff, 0x10151b, 2.2);
scene.add(hemisphere);
const key = new THREE.DirectionalLight(0xffffff, 3.7);
key.position.set(5, 9, 4);
key.castShadow = true;
key.shadow.mapSize.set(1024, 1024);
key.shadow.camera.near = 1;
key.shadow.camera.far = 32;
key.shadow.camera.left = -10;
key.shadow.camera.right = 10;
key.shadow.camera.top = 10;
key.shadow.camera.bottom = -10;
scene.add(key);
const rim = new THREE.DirectionalLight(0x397dff, 2.1);
rim.position.set(-5, 3, -5);
scene.add(rim);
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(160, 160),
new THREE.MeshStandardMaterial({
color: 0x0b1118,
metalness: 0.12,
roughness: 0.9,
side: THREE.DoubleSide,
}),
);
ground.name = "ground";
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
const grid = new THREE.GridHelper(160, 160, 0x2c7cff, 0x263746);
grid.position.y = 0.012;
const gridMaterials = Array.isArray(grid.material) ? grid.material : [grid.material];
gridMaterials.forEach((material) => {
material.transparent = true;
material.opacity = 0.34;
});
scene.add(grid);
const rover = createRover();
roverRef.current = rover;
scene.add(rover);
const line = new THREE.Line(
new THREE.BufferGeometry(),
new THREE.LineBasicMaterial({
color: 0x44a6ff,
transparent: true,
opacity: 0.92,
}),
);
line.name = "ground-truth-trajectory";
trajectoryRef.current = line;
scene.add(line);
const resize = () => {
const width = Math.max(host.clientWidth, 1);
const height = Math.max(host.clientHeight, 1);
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
};
const observer = new ResizeObserver(resize);
observer.observe(host);
resize();
const clock = new THREE.Clock();
let animationFrame = 0;
const render = () => {
animationFrame = window.requestAnimationFrame(render);
const delta = Math.min(clock.getDelta(), 0.1);
const smoothing = 1 - Math.exp(-8 * delta);
rover.position.lerp(desiredPositionRef.current, smoothing);
rover.rotation.y = THREE.MathUtils.lerp(rover.rotation.y, desiredYawRef.current, smoothing);
if (followRef.current) {
const nextTarget = rover.position.clone();
nextTarget.y += 0.72;
controls.target.lerp(nextTarget, smoothing);
}
controls.update();
renderer.render(scene, camera);
};
render();
return () => {
window.cancelAnimationFrame(animationFrame);
observer.disconnect();
controls.dispose();
renderer.dispose();
renderer.domElement.remove();
disposeScene(scene);
cameraRef.current = null;
controlsRef.current = null;
roverRef.current = null;
trajectoryRef.current = null;
};
}, []);
const resetCamera = () => {
const camera = cameraRef.current;
const controls = controlsRef.current;
if (!camera || !controls) return;
camera.position.copy(CAMERA_POSITION);
const rover = roverRef.current;
controls.target.copy(rover ? rover.position.clone().add(new THREE.Vector3(0, 0.55, 0)) : CAMERA_TARGET);
controls.update();
};
return (
<div className="polygon-rover-scene" aria-label="Live 3D-полигон">
<div ref={hostRef} className="polygon-rover-scene__viewport">
{renderError ? <p className="polygon-rover-scene__error">{renderError}</p> : null}
</div>
<div className="polygon-rover-scene__toolbar">
<button type="button" onClick={() => setFollowRover((value) => !value)}>
{followRover ? "Следовать за ровером" : "Свободная камера"}
</button>
<button type="button" onClick={resetCamera}>Сбросить ракурс</button>
</div>
<div className="polygon-rover-scene__help">
<span>ЛКМ · вращение</span>
<span>Колесо · масштаб</span>
<span>ПКМ · панорама</span>
</div>
<div className="polygon-rover-scene__frames">
<span><i data-axis="east" />E</span>
<span><i data-axis="north" />N</span>
<span><i data-axis="up" />U</span>
<small>map_enu · base_link_flu · ground truth</small>
</div>
{!live ? (
<div className="polygon-rover-scene__idle">
<strong>{workerAvailable ? "Ackermann Rover готов" : "Нет связи с worker"}</strong>
<span>
{workerAvailable
? "Запустите PX4/Gazebo — модель останется доступна для осмотра."
: "Полигон появится в шапке после подтверждения Simulation Worker."}
</span>
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,327 @@
import { useEffect, useMemo, useState } from "react";
import {
Button,
GlassSurface,
StatusBadge,
} from "@nodedc/ui-react";
import {
fetchPolygonRunCatalog,
fetchPolygonRunDetail,
type PolygonRunCatalog,
type PolygonRunDetail,
type PolygonRunRoute,
type PolygonRunState,
} from "../core/polygon/runArchive";
import { PolygonLivePanel } from "./PolygonLivePanel";
interface PolygonRunWorkspaceProps {
route: PolygonRunRoute;
}
const stateLabels: Record<PolygonRunState, string> = {
admitted: "Допущен",
starting: "Запускается",
running: "Выполняется",
paused: "На паузе",
stopping: "Останавливается",
completed: "Завершён",
failed: "Ошибка",
aborted: "Прерван",
};
function stateTone(
state: PolygonRunState,
): "success" | "accent" | "warning" | "danger" | "neutral" {
if (state === "completed") return "success";
if (state === "running") return "accent";
if (state === "failed" || state === "aborted") return "danger";
if (state === "starting" || state === "stopping" || state === "paused") return "warning";
return "neutral";
}
function formatTimestamp(value: string | null): string {
if (!value) return "—";
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium",
timeStyle: "medium",
}).format(new Date(value));
}
function formatBytes(value: number): string {
if (value === 0) return "0 Б";
const units = ["Б", "КБ", "МБ", "ГБ"];
const exponent = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
return `${(value / 1024 ** exponent).toLocaleString("ru-RU", {
maximumFractionDigits: 1,
})} ${units[exponent]}`;
}
function errorMessage(error: unknown): string {
if (error instanceof Error && error.message.trim()) return error.message;
return "Не удалось прочитать доказательства прогона.";
}
export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
const [catalog, setCatalog] = useState<PolygonRunCatalog | null>(null);
const [detail, setDetail] = useState<PolygonRunDetail | null>(null);
const [selectedRunId, setSelectedRunId] = useState<string | null>(route.runId);
const [loading, setLoading] = useState(route.error === null);
const [error, setError] = useState<string | null>(route.error);
const [reloadGeneration, setReloadGeneration] = useState(0);
useEffect(() => {
if (route.error) {
setLoading(false);
setError(route.error);
return;
}
const controller = new AbortController();
setLoading(true);
setError(null);
void (async () => {
try {
const nextCatalog = await fetchPolygonRunCatalog({ signal: controller.signal });
if (controller.signal.aborted) return;
setCatalog(nextCatalog);
const targetRunId = selectedRunId ?? route.runId ?? nextCatalog.items[0]?.runId ?? null;
if (!targetRunId) {
setDetail(null);
return;
}
const nextDetail = await fetchPolygonRunDetail(targetRunId, {
signal: controller.signal,
});
if (controller.signal.aborted) return;
setSelectedRunId(targetRunId);
setDetail(nextDetail);
} catch (loadError) {
if (controller.signal.aborted) return;
setDetail(null);
setError(errorMessage(loadError));
} finally {
if (!controller.signal.aborted) setLoading(false);
}
})();
return () => controller.abort();
}, [reloadGeneration, route.error, route.runId, selectedRunId]);
const visibleEvents = useMemo(
() => detail ? [...detail.events].reverse() : [],
[detail],
);
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>
<p>Mission Core читает манифест, события и индекс артефактов без запуска провайдеров.</p>
</GlassSurface>
</div>
);
}
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>
<p>{error}</p>
<Button
size="compact"
variant="secondary"
onClick={() => setReloadGeneration((value) => value + 1)}
>
Повторить чтение
</Button>
</GlassSurface>
</div>
);
}
if (!detail) {
return (
<div className="standard-workspace polygon-run-workspace">
<PolygonLivePanel />
<GlassSurface className="polygon-run-message" padding="lg">
<StatusBadge tone="neutral">Журнал пуст</StatusBadge>
<h2>Квалификационных прогонов пока нет</h2>
<p>Экран появится автоматически после публикации первого журнала в read-only источник.</p>
</GlassSurface>
</div>
);
}
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 live-контура отделён от истории;
команд физическим актуаторам и реального управления здесь нет.
</p>
</div>
<div className="polygon-run-lead-status">
<StatusBadge tone={stateTone(run.state)}>{stateLabels[run.state]}</StatusBadge>
<span>read-only · {run.reproducibilityTier}</span>
</div>
</section>
<section className="polygon-run-metrics" aria-label="Сводка прогона">
<div>
<span>Состояние</span>
<strong>{stateLabels[run.state]}</strong>
<small>{run.terminalReason ?? "терминальная причина отсутствует"}</small>
</div>
<div>
<span>Провайдеры</span>
<strong>{run.providers.length}</strong>
<small>{run.providerIds.join(" · ")}</small>
</div>
<div>
<span>События</span>
<strong>{detail.eventsTotal}</strong>
<small>{detail.eventsTruncated ? "показан последний фрагмент" : "журнал целиком"}</small>
</div>
<div>
<span>Команды</span>
<strong>{detail.commandCount}</strong>
<small>содержимое не публикуется UI-0</small>
</div>
</section>
<div className="polygon-run-layout">
<GlassSurface className="polygon-run-catalog" padding="lg">
<header className="polygon-run-panel-heading">
<div>
<span className="section-eyebrow">ПОСЛЕДНИЕ ПРОГОНЫ</span>
<h3>{catalog?.total ?? 0} в источнике</h3>
</div>
<Button
size="compact"
variant="secondary"
onClick={() => setReloadGeneration((value) => value + 1)}
>
Обновить
</Button>
</header>
<div className="polygon-run-list">
{catalog?.items.map((item) => (
<button
type="button"
key={item.runId}
data-active={item.runId === run.runId ? "true" : undefined}
onClick={() => setSelectedRunId(item.runId)}
>
<i data-state={item.state} aria-hidden="true" />
<span>
<strong>{item.runId}</strong>
<small>{formatTimestamp(item.createdAtUtc)}</small>
</span>
<em>{stateLabels[item.state]}</em>
</button>
))}
</div>
</GlassSurface>
<GlassSurface className="polygon-run-identity" padding="lg">
<span className="section-eyebrow">ИДЕНТИЧНОСТЬ И ГРАНИЦА</span>
<dl>
<div><dt>Сценарий</dt><dd>{run.scenarioGeneration}</dd></div>
<div><dt>Профиль</dt><dd>{run.profileGeneration}</dd></div>
<div><dt>Host profile</dt><dd>{run.hostProfileId}</dd></div>
<div><dt>Mission Core</dt><dd><code>{run.missionCoreCommit.slice(0, 12)}</code></dd></div>
<div><dt>Clock</dt><dd><code>{run.clockDomain}</code></dd></div>
<div><dt>Seed</dt><dd>{run.seed}</dd></div>
<div><dt>Начало</dt><dd>{formatTimestamp(run.startedAtUtc)}</dd></div>
<div><dt>Завершение</dt><dd>{formatTimestamp(run.endedAtUtc)}</dd></div>
</dl>
<div className="polygon-run-safety-boundary">
<StatusBadge tone="success">Virtual only</StatusBadge>
<p>
Actuator authority: {run.authority.actuatorAuthority ? "да" : "нет"} ·
direct setpoints: {run.authority.directActuatorSetpointsAllowed ? "да" : "нет"} ·
navigation/safety accepted: {run.authority.navigationOrSafetyAccepted ? "да" : "нет"}
</p>
</div>
</GlassSurface>
</div>
<section className="polygon-run-providers" aria-label="Провайдеры прогона">
{run.providers.map((provider) => (
<div key={provider.identifier}>
<i aria-hidden="true" />
<span>
<strong>{provider.identifier}</strong>
<small>{provider.version}</small>
</span>
<code>{provider.revision.slice(0, 16)}</code>
</div>
))}
</section>
<GlassSurface className="polygon-run-events" padding="lg">
<header className="polygon-run-panel-heading">
<div>
<span className="section-eyebrow">ЖУРНАЛ СОБЫТИЙ</span>
<h3>Последние переходы и факты</h3>
</div>
<span>revision {run.revision}</span>
</header>
<div className="polygon-run-event-list">
{visibleEvents.map((event) => (
<article key={event.sequence}>
<span className="polygon-run-event-sequence">
{String(event.sequence).padStart(3, "0")}
</span>
<div>
<strong>{event.eventType}</strong>
<small>{formatTimestamp(event.observedAtUtc)}</small>
<code>{JSON.stringify(event.payload)}</code>
</div>
<span>{event.simTimeNs === null ? "host" : `${event.simTimeNs} ns`}</span>
</article>
))}
</div>
</GlassSurface>
<div className="polygon-run-evidence-grid">
<GlassSurface className="polygon-run-artifacts" padding="lg">
<span className="section-eyebrow">АРТЕФАКТЫ</span>
<h3>{detail.artifacts.length || run.artifactCount} ссылок в индексе</h3>
{detail.artifacts.length ? (
<div>
{detail.artifacts.map((artifact) => (
<article key={artifact.artifactId}>
<span>
<strong>{artifact.kind}</strong>
<small>{artifact.relativePath}</small>
</span>
<code>{artifact.sha256.slice(0, 12)} · {formatBytes(artifact.byteLength)}</code>
</article>
))}
</div>
) : (
<p>В журнале прогона нет зарегистрированных artifact-index записей.</p>
)}
</GlassSurface>
<GlassSurface className="polygon-run-limitations" padding="lg">
<span className="section-eyebrow">ЧЕСТНАЯ ГРАНИЦА UI-0</span>
<h3>Что этот результат ещё не доказывает</h3>
<ul>
{detail.limitations.map((limitation) => <li key={limitation}>{limitation}</li>)}
</ul>
</GlassSurface>
</div>
</div>
);
}

View File

@ -22,6 +22,7 @@ import {
import { ObservationTimeline } from "../components/ObservationTimeline";
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
import type { ObservationSessionReplayLaunch } from "../core/observation/sessionArchive";
import type { PolygonRunRoute } from "../core/polygon/runArchive";
import type { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission";
import type {
RecordedAdmissionPhase,
@ -55,6 +56,7 @@ import {
} from "../productModel";
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
import type { SceneSettings } from "../sceneSettings";
import { PolygonRunWorkspace } from "./PolygonRunWorkspace";
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
if (status === "active") return "success";
@ -136,6 +138,7 @@ export interface WorkspaceRendererProps {
cuboids3d: boolean;
}) => void;
observationLayout: ObservationLayoutController;
polygonRunRoute: PolygonRunRoute;
navigation: WorkspaceNavigation;
spatialControls: {
View: ComponentType<DevicePluginConnectionProps>;
@ -1303,6 +1306,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
return <MissionWorkspace {...props} />;
case "catalog":
return <CatalogWorkspace {...props} />;
case "polygon-run":
return <PolygonRunWorkspace route={props.polygonRunRoute} />;
case "device":
return null;
}

View File

@ -0,0 +1,428 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let decodePolygonRunCatalog;
let decodePolygonRunDetail;
let fetchPolygonRunCatalog;
let fetchPolygonRunDetail;
let resolvePolygonRunRoute;
let PolygonRunContractError;
let decodePolygonWorkerStatus;
let decodePolygonVehicleState;
let decodePolygonCommandAcceptance;
let fetchPolygonWorkerStatus;
let fetchPolygonVehicleState;
let sendPolygonRoverCommand;
let startPolygonWorker;
let stopPolygonWorker;
let PolygonWorkerContractError;
let workspaceById;
let workspacesForRoot;
let rootsForCapabilities;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
decodePolygonRunCatalog,
decodePolygonRunDetail,
fetchPolygonRunCatalog,
fetchPolygonRunDetail,
resolvePolygonRunRoute,
PolygonRunContractError,
} = await server.ssrLoadModule("/src/core/polygon/runArchive.ts"));
({
decodePolygonWorkerStatus,
decodePolygonVehicleState,
decodePolygonCommandAcceptance,
fetchPolygonWorkerStatus,
fetchPolygonVehicleState,
sendPolygonRoverCommand,
startPolygonWorker,
stopPolygonWorker,
PolygonWorkerContractError,
} = await server.ssrLoadModule("/src/core/polygon/liveWorker.ts"));
({
workspaceById,
workspacesForRoot,
rootsForCapabilities,
} = await server.ssrLoadModule("/src/productModel.ts"));
});
after(async () => {
await server?.close();
});
function summary(overrides = {}) {
return {
run_id: "s1b-6cb1495-20260724t1535z",
episode_id: "episode-s1b-6cb1495-20260724t1535z",
kind: "simulation_closed_loop",
state: "completed",
created_at_utc: "2026-07-24T15:35:00Z",
started_at_utc: "2026-07-24T15:35:02Z",
ended_at_utc: "2026-07-24T15:35:05Z",
terminal_reason: "operator-stop-clean",
scenario_generation: "px4-v1.17.0-stock-rover-ackermann",
profile_generation: "stock-rover-lifecycle-v1",
mission_core_commit: "6cb1495a1234567890abcdef1234567890abcdef",
host_profile_id: "mission-gpu-s0",
reproducibility_tier: "R1",
clock_domain: "gazebo:/clock",
revision: 5,
provider_ids: ["px4-autopilot", "gazebo"],
artifact_count: 1,
...overrides,
};
}
function catalog(overrides = {}) {
return {
schema_version: "missioncore.polygon-run-catalog/v1",
access: "read-only",
items: [summary()],
total: 1,
limitations: ["Qualification evidence only."],
...overrides,
};
}
function detail(overrides = {}) {
return {
schema_version: "missioncore.polygon-run-detail/v1",
access: "read-only",
run: {
...summary(),
scenario_sha256: "a".repeat(64),
profile_sha256: "b".repeat(64),
host_profile_sha256: "c".repeat(64),
seed: 42,
parent_run_id: null,
providers: [
{
identifier: "px4-autopilot",
version: "v1.17.0",
revision: "v1.17.0",
digest: null,
},
{
identifier: "gazebo",
version: "harmonic",
revision: "8.9.0",
digest: null,
},
],
authority: {
generation: 1,
command_ttl_max_ns: 250_000_000,
heartbeat_timeout_monotonic_ns: 500_000_000,
simulation_or_shadow_only: true,
actuator_authority: false,
navigation_or_safety_accepted: false,
direct_actuator_setpoints_allowed: false,
},
},
events: [
{
schema_version: "missioncore.qualification-event/v1",
run_id: "s1b-6cb1495-20260724t1535z",
sequence: 5,
event_type: "lifecycle.state-changed",
observed_at_utc: "2026-07-24T15:35:05Z",
host_monotonic_ns: 5,
sim_time_ns: 2_000_000,
payload: {
from: "stopping",
to: "completed",
reason: "operator-stop-clean",
},
},
],
events_total: 1,
events_truncated: false,
commands: {
count: 0,
content_exposed: false,
},
artifacts: [
{
artifact_id: "provider-log-index",
kind: "log-index",
relative_path: "provider-runtime/processes.json",
sha256: "a".repeat(64),
byte_length: 512,
source_of_record: true,
sequence: 1,
},
],
limitations: ["Qualification evidence only."],
...overrides,
};
}
function jsonResponse(payload, status = 200) {
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json" },
});
}
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,
};
}
function commandAcceptance(overrides = {}) {
return {
schema_version: "missioncore.command-acceptance/v1",
run_id: "s1c-6cb1495-20260724t180000z-aabbcc",
command_id: "cmd-aabbcc",
sequence: 1,
issued_at_sim_ns: 7_000_000_000,
valid_until_sim_ns: 7_250_000_000,
speed_mps: 1,
steering_normalized: -0.55,
authority_scope: "virtual-only",
delivery: {
provider: "px4-ros2-offboard",
mode: "speed-steering",
armed: true,
offboard: true,
ttl_expired_count: 0,
},
...overrides,
};
}
test("Polygon is a worker-gated product root and keeps a compatible direct route", () => {
assert.deepEqual(resolvePolygonRunRoute("?workspace=polygon-run"), {
active: true,
runId: null,
error: null,
});
assert.deepEqual(
resolvePolygonRunRoute("?workspace=polygon-run&run=s1b-6cb1495-20260724t1535z"),
{
active: true,
runId: "s1b-6cb1495-20260724t1535z",
error: null,
},
);
assert.equal(resolvePolygonRunRoute("?workspace=missions").active, false);
assert.match(
resolvePolygonRunRoute("?workspace=polygon-run&run=../../etc").error,
/некорректный/,
);
assert.equal(workspaceById("polygon-run").root, "polygon");
assert.equal(
workspacesForRoot("polygon").some(({ id }) => id === "polygon-run"),
true,
);
assert.equal(
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
false,
);
assert.equal(
rootsForCapabilities({ polygonWorkerAvailable: false })
.some(({ id }) => id === "polygon"),
false,
);
assert.equal(
rootsForCapabilities({ polygonWorkerAvailable: true })
.some(({ id }) => id === "polygon"),
true,
);
});
test("Polygon contracts decode path-free evidence and preserve the safety boundary", () => {
const decodedCatalog = decodePolygonRunCatalog(catalog());
const decodedDetail = decodePolygonRunDetail(detail());
assert.equal(decodedCatalog.items[0].state, "completed");
assert.equal(decodedDetail.run.authority.actuatorAuthority, false);
assert.equal(decodedDetail.run.authority.navigationOrSafetyAccepted, false);
assert.equal(decodedDetail.commandCount, 0);
assert.equal(decodedDetail.artifacts[0].relativePath, "provider-runtime/processes.json");
assert.equal("content" in decodedDetail, false);
});
test("Polygon contracts fail closed on command content, paths and unknown fields", () => {
assert.throws(
() => decodePolygonRunDetail(detail({
commands: { count: 1, content_exposed: true },
})),
PolygonRunContractError,
);
assert.throws(
() => decodePolygonRunDetail(detail({
artifacts: [{
...detail().artifacts[0],
relative_path: "/mnt/d/private/processes.json",
}],
})),
PolygonRunContractError,
);
assert.throws(
() => decodePolygonRunCatalog(catalog({ unexpected: true })),
PolygonRunContractError,
);
});
test("Polygon fetchers use bounded same-origin GET endpoints", async () => {
const calls = [];
const fetcher = async (url, init) => {
calls.push({ url, init });
return String(url).includes("event_limit") ? jsonResponse(detail()) : jsonResponse(catalog());
};
await fetchPolygonRunCatalog({ limit: 20, fetcher });
await fetchPolygonRunDetail("s1b-6cb1495-20260724t1535z", {
eventLimit: 200,
fetcher,
});
assert.deepEqual(calls.map(({ url }) => url), [
"/api/v1/polygon/runs?limit=20",
"/api/v1/polygon/runs/s1b-6cb1495-20260724t1535z?event_limit=200",
]);
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());
const command = decodePolygonCommandAcceptance(commandAcceptance());
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.equal(command.offboard, true);
assert.equal(command.steeringNormalized, -0.55);
assert.throws(
() => decodePolygonWorkerStatus(workerStatus({
authority: {
...workerStatus().authority,
actuator_authority: true,
},
})),
PolygonWorkerContractError,
);
assert.throws(
() => decodePolygonVehicleState(vehicleState({
source: {
...vehicleState().source,
quality: "accepted",
},
})),
PolygonWorkerContractError,
);
assert.throws(
() => decodePolygonCommandAcceptance(commandAcceptance({
authority_scope: "physical",
})),
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("/commands")) return jsonResponse(commandAcceptance());
if (String(url).endsWith("/stop")) return jsonResponse(stopped);
return jsonResponse(workerStatus());
};
await fetchPolygonWorkerStatus({ fetcher });
await fetchPolygonVehicleState({ fetcher });
await startPolygonWorker({ idempotencyKey: "start-001", fetcher });
await sendPolygonRoverCommand("s1c-6cb1495-20260724t180000z-aabbcc", {
speedMps: 1,
steeringNormalized: -0.55,
idempotencyKey: "command-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/commands",
"/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"], "command-001");
assert.equal(calls[4].init.headers["Idempotency-Key"], "stop-001");
assert.equal(calls[2].init.body, JSON.stringify({ scenario_id: "stock-rover-ackermann" }));
assert.equal(calls[3].init.body, JSON.stringify({
speed_mps: 1,
steering_normalized: -0.55,
}));
});

View File

@ -3,7 +3,7 @@
This plan supersedes the app-dependent experiment order in the reference Bible.
Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
## Current checkpoint — 2026-07-20
## Current checkpoint — 2026-07-24
| Stage | Result |
| --- | --- |
@ -24,6 +24,7 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
| Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process |
| K1 application control | GO (physical staged cycle) — after fixing the PCAP-proven `sint64` time field, one explicit UI launch completed all 14 canonical operations on one control session, reached live `SCANNING + project + init_ready`, displayed real points, then one explicit STOP returned K1 to unbound `READY`. No retry or fallback command was sent. Native-project reuse through LixelGO/USB remains an independent verification |
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
| Simulation Polygon | SIM S0 GO; S1A complete; S1B lifecycle PASS; S1C registered live worker PASS; UI-2 browser surface PASS; S1D Ackermann command/motion slice PASS — exact code generation `49b0f47` passed 40 focused tests on the immutable D-only generation. The capability-gated `Полигон` root owns one procedural 3D Ackermann Rover workspace with full azimuth/vertical orbit, zoom, pan, follow/reset and live diagnostic ENU state. Cold start completed in one request after 32.08 s. Run `s1c-49b0f47-20260724t195259z-62edd2` proved 3.74 m straight motion, a curved turn, reverse, 44.6 ms sampled command admission, 250 ms TTL expiry and clean zero-residue stop through PX4 rover-level Offboard throttle/steering setpoints. Differential Rover, calibrated closed-loop speed, canonical PX4/ROS 2 telemetry, pause/step/reset, heartbeat/offboard/link-loss cases, navigation/safety acceptance, shared-deployment auth/RBAC and real actuator authority remain absent |
USB project copying remains optional ground truth rather than a blocker for the
now-verified network path. Owner-operated LixelGO traffic verifies the MQTT
@ -121,6 +122,93 @@ control is plugin-owned. A plugin-commanded acquisition sends one canonical STOP
and seals locally after protocol-reported standby; an operator-manual acquisition
still stops and seals only local reception and reports scanner state as unknown.
## Parallel branch — Simulation Polygon
The Polygon branch follows
[`docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md`](12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md)
and
[`ADR 0015`](adr/0015-simulation-polygon-qualification-boundary.md). Component
placement and field/offline topology follow
[`ADR 0016`](adr/0016-distributed-product-edge-and-worker-topology.md).
It does not reorder or weaken the K1 physical-evidence gates in this document.
Its first gate, SIM S0, is accepted:
- exact Windows 11 / dedicated `MissionCore-Sim` WSL2 Ubuntu 24.04 worker
profile;
- all mutable runtime bytes physically on D;
- ROS 2 Jazzy, Gazebo Harmonic, PX4 v1.17.0, matching `px4_msgs`,
Micro XRCE-DDS and Nav2 compatibility;
- authoritative Gazebo `/clock`;
- loopback port and deterministic process ownership;
- target-worker rover/telemetry/pause/2 ms step/1×/2× resource evidence;
- target doctor `GO` for profile SHA-256
`aeecf5005301db230e1340b85215cbbd862b72974a22b8c43767c28be8453e2f`.
The repository doctor is side-effect free:
```text
uv run missioncore-sim s0 doctor --json
```
Without the exact admitted target-worker evidence its correct result remains
`INCOMPLETE`. S1 starts from the accepted S0 version/storage/time/process
boundary. Its S1A increment implements server-owned immutable run identity,
legal lifecycle transitions, canonical rover command envelopes, authority/TTL
admission, append-only artifacts and restart reconciliation. The S1B increment
adds idempotent application lifecycle, single active ownership, an
exact-profile/D-only worker guard, health-gated real provider specs and
deterministic POSIX PGID supervision. Exact generation `6cb1495` passed on
`MissionCore-Sim`: Micro XRCE-DDS Agent and PX4/Gazebo stock rover ran inside a
fresh loopback-only namespace, the run reached `completed`, and both provider
groups stopped without residue. S1C commit `b7ccca3` adds a persistent
unprivileged worker agent in the same isolation boundary, a bounded Unix-socket
gateway, an explicit `internal-virtual-only` backend gate and a browser-native
live panel. UI-2 commit `60e7916` promotes that panel into a worker-gated
top-level product root and replaces the diagnostic 2D marker with a
browser-native Three.js Ackermann Rover scene. The exact D-only generation
passed 35 target tests and one UI-driven provider run with diagnostic Gazebo
`VehicleState`, eight persisted lifecycle events, terminal `completed` and zero
process residue.
S1D exact code generation `49b0f47` adds the bounded
`missioncore.ackermann-command/v1` browser-to-worker path and the first physical
motion evidence. Mission Core keeps the external command in metres per second
plus normalized steering, journals it append-only, applies a 250 ms TTL and
maps it on the worker to stock-model PX4 rover Offboard throttle/steering
setpoints. PX4 still owns arming, Offboard state and control allocation; Mission
Core does not publish direct actuator topics. The exact D-only target passed 40
focused tests. Its first cold start took 32.08 seconds, and run
`s1c-49b0f47-20260724t195259z-62edd2` proved straight, turn, reverse, TTL stop
and zero-residue lifecycle shutdown. This closes only the primary Ackermann
command/motion slice. Differential Rover, calibrated speed tracking, canonical
PX4/ROS 2 telemetry, pause/step/reset, heartbeat/offboard/link-loss and
failsafe cases remain open before S1 can pass.
The archival surface consumes the same append-only run repository through
`GET /api/v1/polygon/runs` and
`GET /api/v1/polygon/runs/{run-id}`. `QualificationRunStore(read_only=True)`
does not create, chmod or mutate the configured repository and rejects every
write transition. When `/api/v1/polygon/worker` returns an admitted available
worker, the Control Station adds `Полигон` as a seventh header root. One header
click opens the direct `polygon-run` workspace without a System launcher panel.
The old `?workspace=polygon-run[&run=<id>]` URL remains a compatible deep-link
for review and exact-run selection, not the primary operator navigation.
Browser QA loaded the accepted archive and the S1C live worker, rotated and
zoomed the 3D rover, observed live run/provider status, Gazebo sim time and
ENU/FLU pose, then completed a clean stop. Start/stop stays behind the backend
gate and reaches the worker through a local Unix socket; SSH remains
qualification bootstrap, never the product data plane. A shared
operator-facing deployment still requires authentication/RBAC, service
supervision and an accepted routed worker transport.
Mission Core remains a distributed web product. React is served by a long-lived
Gateway/API and renders canonical state through browser-native components.
Gazebo/PX4/ROS 2/Nav2 run headless on a registered Linux simulation worker; the
native Gazebo GUI is optional worker-local diagnostics. A future field vehicle
runs a headless Linux Edge Agent with local-first evidence and store-and-forward
synchronization. The browser never talks directly to PX4, and field
execution/failsafe cannot depend on a browser or WAN connection.
## Stage 0 — repository and host baseline
Deliverables:

View File

@ -0,0 +1,860 @@
# Mission Core Polygon: product definition and system requirements
Status: canonical working SRS, 2026-07-24.
Ops source of truth:
- [MISSIONCOR-38](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-38) is the
product epic and original simulation-branch scope.
- [MISSIONCOR-39](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-39) is the
canonical, changeable architecture plan.
- [MISSIONCOR-40](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-40) is the
completed SIM S0 implementation and qualification gate.
- [MISSIONCOR-41](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-41) is the
active S1 lifecycle/authority implementation and qualification gate.
This document, ADR 0015 and ADR 0016 are the repository truth. A material change
to run kinds, authority, clocks, frames, source-of-record, provider boundaries,
component placement or phase gates must update the ADR/SRS and MISSIONCOR-39
together.
## 1. Product thesis
Polygon is Mission Core's autonomy qualification and assurance layer. It is not
a simulator embedded for visual effect and it is not a second observation
viewer.
Polygon makes one product promise:
> Given a versioned scenario and qualification profile, Mission Core can run or
> replay the autonomy stack, preserve exact provenance and evidence, explain
> each control/safety decision and compare outcomes without granting undeclared
> authority.
The product value appears in increments:
| Gate | Value |
| --- | --- |
| P0/S0 | Makes the stack installable, inspectable and reproducible rather than an ad hoc lab |
| S1 | Proves Mission Core can own a complete virtual rover run lifecycle |
| S2 | Produces the first useful navigation baseline and comparable safety/latency evidence |
| S3 | Connects Mission Core perception to a closed-loop consumer and exposes product differentiation |
| S4 | Makes recorded real data useful for regression and shadow-policy analysis |
| S5/S6 | Reduces risk before a real vehicle, without claiming real-world safety certification |
The first product-useful baseline is P0 through S2. The current estimate is
2746 engineer-days, excluding waits for hardware, external accounts or a
resolved worker-access blocker.
## 2. Current truth
The repository's proven vertical remains K1 observation, durable archive,
recorded perception and diagnostic motion evidence through LAB E26. It has not
accepted navigation behavior, safety behavior or real actuator control.
Polygon is now a parallel product branch. As of this document:
- P0 architecture and SRS are committed on the Polygon branch;
- SIM S0 has a strict accepted qualification profile and read-only doctor;
- the dedicated D-only worker distribution has been created and the accepted
PX4/Gazebo/ROS 2/Nav2 stack has been installed without using Docker;
- PX4 v1.17.0 and the stock Ackermann rover have completed repeated factual
smoke runs with ROS 2 vehicle status, Gazebo clock and clean shutdown;
- the exact accepted-profile generation has passed pause freeze, one 2 ms
single-step, 1×/2× speed and structured resource cases;
- all nine digest-bound S0 evidence claims validate and the target doctor
returns `GO` for profile SHA-256
`aeecf5005301db230e1340b85215cbbd862b72974a22b8c43767c28be8453e2f`;
- S1B exact generation `6cb1495` has passed a product-owned real-provider
lifecycle in a fresh loopback-only namespace: two provider PGIDs, explicit
world/startup/DDS-writer readiness, eight persisted events, zero commands,
terminal `completed` and zero process residue;
- the UI-0 archive contract remains available over GET-only Polygon API routes
and as a compatibility deep-link. Browser QA loaded the real accepted S1B
journal and displayed its four provider pins, eight events, zero commands and
unchanged virtual-only authority boundary;
- S1C exact code generation `b7ccca3` adds a persistent, unprivileged
Simulation Worker agent in a fresh loopback-only namespace, a bounded
Unix-socket Mission Core gateway and an explicit `internal-virtual-only`
lifecycle gate;
- UI-2 exact generation `60e7916` exposes `Полигон` as a dedicated header root
only while Mission Core confirms an available Simulation Worker. The header
opens one direct workspace without a secondary launcher panel;
- the browser-native Three.js scene renders a recognisable procedural Ackermann
Rover, ground grid and ENU trajectory. Orbit supports 360° azimuth, bounded
vertical inspection from overhead to horizon, zoom, pan, follow and camera
reset. The Gazebo dynamic-pose signal remains marked `diagnostic`;
- the UI-driven target run
`s1c-60e7916-20260724t182006z-2f164e` reached `completed` with eight
persisted events, 27.74 seconds of Gazebo time and zero provider residue;
it contained zero commands and therefore does not prove rover motion or PX4
command delivery;
- S1D exact code generation `49b0f47` adds the admitted Ackermann
speed/steering command route from React through Mission Core and the private
worker gateway. The worker journals before delivery and translates the
bounded intent to PX4 rover-level Offboard `RoverThrottleSetpoint` plus
`RoverSteeringSetpoint`; the stock-model conversion uses
`RO_MAX_THR_SPEED=3.1`, while direct actuator topics remain prohibited;
- the exact D-only generation passed 40 focused target tests. Its first cold
start completed in one request after 32.08 seconds. Run
`s1c-49b0f47-20260724t195259z-62edd2` proved 3.74 m straight motion, a curved
turn, reverse motion, 44.6 ms sampled command admission, 250 ms TTL expiry,
stable stopped pose and clean shutdown with no provider residue;
- this is not a calibrated closed-loop speed controller acceptance and does
not close Differential Rover, canonical PX4/ROS 2 telemetry,
heartbeat/offboard/link-loss, failsafe, navigation or safety checkers;
- `actuator_authority=false`;
- `navigation_or_safety_accepted=false`.
The doctor reports `GO` only on the matching target with the exact confined
worker evidence. A repository manifest alone remains insufficient.
## 3. Goals
### 3.1 Product goals
- Turn perception and planning work into measurable sense-plan-act experiments.
- Compare standard and Mission Core planners against the same versioned inputs.
- Preserve failures, limitations and uncertainty as first-class evidence.
- Reuse the same scenario/run/report concepts across simulation, replay, digital
twin, controller-in-loop, HIL and physical shadow.
- Keep physics, autopilot, middleware, planner and viewer replaceable.
- Create a controlled path toward a future trike without silently widening
authority.
### 3.2 Engineering goals
- Deterministic lifecycle ownership with no orphan simulator processes.
- Exact version, environment, input and artifact provenance.
- Explicit clock-domain and coordinate-frame conversions.
- Fail-closed setpoint validity, TTL, heartbeat, watchdog and loss behavior.
- Append-only run history and digest-bound source/derived artifacts.
- Side-effect-free infrastructure diagnosis before installation.
## 4. Non-goals
The first delivery does not:
- grant real actuator authority;
- expose PX4 directly to the browser;
- use direct actuator setpoints;
- certify Nav2, PX4 or Mission Core as a real-time safety system;
- treat replay as closed-loop evidence;
- import all 300 BARN worlds before a stock-world baseline;
- download complete CODa, JRDB or NCLT collections;
- build a custom physics engine;
- make `px4-ros2-interface-lib` mandatory for S1;
- overload the observation-session model for qualification runs;
- treat the capability-gated top-level UI as proof of the backend S1 command or
safety gate.
## 5. Product and system boundaries
### 5.1 Mission Core ownership
Mission Core owns:
- scenario definitions and profile selection;
- qualification-run identity and lifecycle;
- command authority and safety policy;
- canonical state, setpoint, decision and event contracts;
- orchestration of replaceable providers;
- evidence admission, provenance and immutable history;
- evaluation, comparison and qualification reports.
### 5.2 Provider ownership
Providers remain replaceable:
- Gazebo owns simulated world state, sensors, contacts and physics.
- PX4 SITL owns rover control loops, constraints, offboard lifecycle and
autopilot failsafe.
- ROS 2 transports typed telemetry and commands.
- Nav2 supplies the first mature planning/costmap/collision-checking baseline.
- Rerun or another viewer presents derived evidence and never becomes a queue,
orchestrator or source-of-record.
- Dataset adapters expose recorded streams but never pretend alternative
commands changed future recorded observations.
### 5.3 Neighboring Mission Core contexts
- Observation owns real and recorded sensor streams plus perception layers.
- Polygon owns scenarios, qualification runs, decisions, simulated state and
comparisons.
- Missions owns goals and routes that may later target a virtual or real
vehicle.
- Data owns source-of-record, derived artifacts, retention and report access.
Observation `SessionStatus` and its modality model are not extended to describe
simulation. Shared UI components require an explicit data-source contract.
## 6. Target architecture
```text
Mission Core UI
|
Simulation API
|
Simulation Orchestrator
+---- scenario/profile registry
+---- run state machine and authority gate
+---- clock, namespace, port and process ownership
+---- artifact/provenance store and evaluator
|
+---- Gazebo adapter ---- Gazebo world/physics/sensors
+---- PX4 adapter ------- PX4 SITL rover controller/failsafe
+---- ROS 2 adapter ----- Micro XRCE-DDS and canonical topics
+---- planner adapter --- Nav2 or Mission Core planner
+---- viewer adapter ---- canonical state/derived report
```
Gazebo and PX4 never run inside the web process. The browser never owns process
lifecycle or command authority. Losing the browser cannot terminate the only
copy of run state and cannot bypass the server-side authority gate.
### 6.1 Product form and component placement
Mission Core is a distributed web product, not a monolithic desktop
application:
| Component | Product placement | Packaging and responsibility |
| --- | --- | --- |
| Control Station | Any operator browser on macOS, Windows, Linux or tablet | React static assets; interactive planning, live state, history, comparison and reports |
| Gateway/API and Control Plane | Control-centre server, laboratory host or offline field kit | Long-lived service; identity, policy, mission/scenario/run state, orchestration and catalogues |
| Simulation Worker | Registered Linux GPU worker; currently dedicated WSL2 on the reviewed Windows host | Headless worker service plus Gazebo, PX4 SITL, ROS 2 and Nav2 |
| Native Gazebo GUI | Worker desktop only | Optional diagnostic attachment; never product state, authority or source-of-record |
| Vehicle Edge Agent | Future onboard or vehicle-adjacent Linux computer | Headless device/runtime adapters, local authority gate, bounded mission state, durable evidence and store-and-forward sync |
| PX4 controller | SITL process in simulation or separate controller hardware in the field | Vehicle control loops, constraints and failsafe behind an allowlisted adapter |
| Observation/perception worker | Onboard, nearby edge or control-centre worker according to latency/bandwidth profile | Canonical perception outputs; placement does not change contracts |
| Archive/evidence storage | Worker/vehicle local-first, then optional central synchronization | Immutable source evidence, derived artifacts and reports |
The primary deliverable remains the browser application. A PWA, kiosk package
or thin Tauri/Electron wrapper may distribute the same assets for an offline
field kit, but it is not a second product and never owns provider processes or
command authority.
The Polygon browser view consumes bounded canonical state and derived evidence:
rover pose, route, planned/factual trajectory, footprint, contacts, safety
state, metrics and optional media. The accepted live rover scene uses
browser-native Three.js; Rerun remains available for recorded spatial evidence.
Both presentation paths consume server-owned contracts rather than worker
processes. Full Gazebo pixels are optional diagnostics through the worker
desktop or a separately gated remote-display capability; they are not required
for the product view.
### 6.2 Deployment topologies
Laboratory simulation:
```text
Operator browser
-> Mission Core Gateway/API
-> Simulation Orchestrator
-> registered Linux/WSL2 worker
-> Gazebo + PX4 SITL + ROS 2 + Nav2
-> canonical live stream + local source/derived artifacts
```
Connected field vehicle:
```text
Operator browser/control centre
-> Mission Core Gateway/API
<-> Vehicle Edge Agent on onboard Linux
-> PX4 hardware boundary
-> sensors and actuators
```
Disconnected field vehicle and later review:
```text
Locally admitted mission + Edge Agent + PX4
-> accepted local watchdog/failsafe behavior
-> append-only onboard evidence
-> later idempotent digest-bound synchronization
-> browser history/replay/report
```
Field execution and safety never depend on a browser tab or continuous WAN.
The Edge Agent writes source evidence locally before upload, retains
high-volume data according to storage policy and sends bounded telemetry,
events, decisions and previews when connected. Reconnection synchronizes the
same immutable run identity; a partial upload is never presented as complete.
The authority path remains:
```text
Browser intent
-> Mission Core API/policy
-> Simulation Orchestrator or Vehicle Edge Agent
-> allowlisted provider/device adapter
-> PX4 boundary
```
The placement decision is specified by
[ADR 0016](adr/0016-distributed-product-edge-and-worker-topology.md).
## 7. Canonical domain model
### 7.1 Versioned inputs
`ScenarioDefinition` contains identifiers and references for:
- world and deterministic actor placement;
- vehicle profile;
- sensor profile;
- route/goal;
- planner profile;
- fault profile;
- metric profile;
- seed and reproducibility tier.
`VehicleProfile`, `SensorProfile`, `PlannerProfile`, `FaultProfile` and
`MetricProfile` are independently versioned. A run stores the exact resolved
generation and hashes, not only mutable names.
### 7.2 QualificationRun
`QualificationRun` is the umbrella aggregate. `SimulationRun` is not used as
the common type because replay, HIL and physical shadow are not simulations.
Required identity:
- immutable run ID and episode ID;
- run kind;
- scenario/profile generations and hashes;
- Mission Core commit;
- provider versions, commits or image digests;
- host and resource profile;
- seed and reproducibility tier;
- authority profile;
- start/end UTC provenance;
- authoritative clock domain.
Reset creates a new episode/run identity. It never reuses a completed identity
or overwrites artifacts.
### 7.3 Run kinds
| Kind | Commands affect future inputs | Actuator authority |
| --- | --- | --- |
| `simulation_closed_loop` | Yes, through virtual physics | Virtual only |
| `replay_shadow` | No | None |
| `digital_twin_closed_loop` | Yes, through an attested twin | Virtual only |
| `controller_in_loop` | Yes, through a bounded controller interface | Lab-specific |
| `hil` | Yes, with hardware in the loop | Separate gate |
| `physical_shadow` | No | None |
No result may be promoted from one kind to another by changing report wording.
### 7.4 Canonical streams
- `VehicleState`: pose, twist, acceleration, steering/wheel state, estimator
state and source frames.
- `PerceptionWorldState`: occupancy, objects, motion, confidence, unknown and
uncertainty.
- `ControlSetpoint`: profile, sequence, issued time, validity deadline,
speed/steering or speed/yaw-rate and authority scope.
- `SafetyDecision`: allow/slow/stop, reason, active constraint, TTC/clearance
evidence and input generation.
- `QualificationEvent`: lifecycle, collision/contact, reset, deadline miss,
data loss, watchdog, failsafe and evaluator events.
- `QualificationReport`: verdict, metrics, violations, limitations and evidence
links.
## 8. Lifecycle requirements
The minimum lifecycle is:
```text
admitted -> starting -> running <-> paused -> stopping -> completed
| |
+--------------------+-> failed
```
Cancellation is a requested transition, not evidence that processes stopped.
A run reaches a terminal state only after:
- child-process ownership is reconciled;
- stop/failsafe outcome is captured;
- source artifacts are sealed;
- derived indexes are generated or explicitly marked absent;
- the report records the final limitations.
Process shutdown order is explicit and deterministic. Orchestrator shutdown is
last. An orphan process is an S0/S1 failure.
## 9. Clock contract
For `simulation_closed_loop`:
- Gazebo `/clock` is authoritative.
- ROS 2 consumers use `use_sim_time=true`.
- PX4 uXRCE-DDS time synchronization is disabled when Gazebo time is used.
- Canonical timestamps are integer nanoseconds and include their clock domain.
- UTC is provenance only.
- TTL, deadline and watchdog calculations declare whether they use simulation
or monotonic host time.
- Pause, resume, single-step and real-time-factor changes are qualification
cases, not UI-only behavior.
Wall clock must not appear implicitly in planner or safety calculations.
## 10. Coordinate-frame contract
Mission Core navigation uses:
- `map` and `odom`: ENU;
- `base_link`: FLU.
PX4 uses:
- world/local: NED;
- body: FRD.
All PX4 conversion happens at one adapter boundary. Pose, vector, yaw, yaw-rate
and covariance transformations require golden tests. Every canonical message
identifies source and target frame. A frame mismatch fails closed and cannot be
reclassified as planner noise.
## 11. Authority and safety requirements
Until a new physical-control card and formal safety gate:
- `simulation_or_shadow_only=true`;
- `actuator_authority=false`;
- `navigation_or_safety_accepted=false`;
- direct actuator setpoints are rejected;
- supported S1 command profiles are `rover-speed-steering/v1` and
`rover-speed-yaw-rate/v1`;
- each command carries a monotonic sequence and explicit expiry;
- heartbeat loss, TTL expiry, invalid state, planner timeout or link loss causes
a stop/failsafe transition;
- unknown occupancy never becomes free space by omission;
- ground truth is unavailable to the planner and perception runtime;
- ground truth is separately available to the evaluator.
Nav2 Collision Monitor is an additional software safety layer. It is not a
hard-real-time certified safety controller.
## 12. Evidence and data requirements
### 12.1 Source-of-record
The minimum source set is:
- exact scenario and qualification profiles;
- stack/version lock;
- run manifest and authority profile;
- PX4 ULog;
- Gazebo log or equivalent world-state/contact evidence;
- rosbag2/MCAP for admitted canonical streams;
- factual host/resource and process-lifecycle evidence.
### 12.2 Derived artifacts
Derived data includes:
- normalized metrics;
- evaluator outputs;
- comparison tables;
- Rerun recording;
- qualification report.
Presentation state is not source evidence. Derived artifacts are rebuildable and
must retain input hashes. Source and derived generations are append-only.
### 12.3 Required metrics
Each applicable run reports:
- completion and terminal reason;
- collision/contact count and minimum clearance;
- path length, elapsed simulation time and efficiency;
- route/pose error;
- speed/steering tracking;
- stop time and distance;
- TTC and constraint activation;
- replans and oscillation;
- dropped/late messages and deadline misses;
- stage latency p50/p95/max and queue depth;
- real-time factor;
- CPU, GPU, RAM, VRAM and disk usage.
Inapplicable metrics are explicit, not silently zero.
## 13. Reproducibility
- R0: exact inputs and versions are retained; outcome may vary.
- R1: the same inputs/seed/version lock reproduce the same verdict within
declared tolerances.
- R2: defined metrics remain within accepted tolerance bands.
Bitwise-identical physics is not promised. Comparison is rejected when scenario
schema, metric profile, provider generation or reproducibility tier are
incompatible.
## 14. Upstream baseline
The S0 accepted line is:
- Windows 11 AI worker;
- dedicated `MissionCore-Sim` WSL2 Ubuntu 24.04 distribution physically on D;
- no Docker dependency in S0, so active shared Docker co-tenants remain untouched;
- ROS 2 Jazzy;
- Gazebo Harmonic;
- PX4 Autopilot v1.17.0;
- matching `px4_msgs` v1.17.0 generation;
- Micro XRCE-DDS Agent v2.4.3;
- Nav2 Jazzy;
- PX4 Gazebo stock rover models.
The exact source commits and package versions are resolved in the profile.
Target installation, PX4 build, DDS telemetry, Nav2/ROS package discovery and
repeated 1×/2× stock-rover evidence have moved every component pin to
`accepted`. A future pin change creates a new profile generation and invalidates
evidence tied to the prior SHA-256.
S1 uses direct exact `px4_msgs` integration. The experimental
`px4-ros2-interface-lib` is evaluated later and is not on the S1 critical path.
## 15. Storage and runtime placement
All mutable worker bytes must be physically placed under
`D:\NDC_MISSIONCORE`, including:
- the dedicated `MissionCore-Sim` WSL distribution/VHD;
- Docker data and images if a later profile elects to use containers; the S0
profile explicitly does not;
- sources and build products;
- package/model caches;
- runtime and logs;
- bags, ULog and Gazebo evidence;
- datasets and reports.
The WSL view is `/mnt/d/NDC_MISSIONCORE`. C-drive placement is a blocking
failure. S0 requires at least 300 GiB free before installation and a stop-below
threshold of 200 GiB. Full external datasets are out of S0 scope.
S0 runtime processes execute in a new loopback-only Linux network namespace.
Micro XRCE-DDS Agent v2.4.3 has no address-selection flag and therefore binds
UDP wildcard inside that namespace; the namespace contains only `lo`, so the
effective exposure remains loopback-only. ROS domain 0 is isolated by the same
boundary. This distinction is part of the port evidence and must not be
misreported as a host-wide `127.0.0.1` bind.
The stock rover world declares a 2 ms physics step. S0 pause acceptance permits
zero simulation-time advance; single-step requires exactly +2,000,000 ns and
+1 iteration. Speed factors 1× and 2× are measured over four seconds with a
±20% RTF tolerance. Resource sampling runs every 500 ms with an owned-process
budget of 2 GiB RSS and 800% CPU, at least 8 GiB available WSL memory and the
existing 200 GiB D stop floor. GPU values are recorded as global co-tenant
context and are not misattributed to headless S0.
## 16. Delivery plan
| Phase | Estimate | Exit condition |
| --- | ---: | --- |
| P0 | 24 days | Product thesis, SRS, ADR and canonical contracts accepted |
| S0 | 35 days | Target compatibility, D-only runtime, exact pins, clock/process/resource evidence |
| S1 | 712 days | Stock Ackermann and Differential lifecycle/control/failsafe evidence |
| S2 | 1525 days | LiDAR/odometry/Nav2 baseline, evaluator and obstacle scenarios |
| S2B | 58 days | Representative 30-world BARN pilot |
| S3 | 2035 days | K1-like virtual sensors and Mission Core perception closed loop |
| S4 | 1020 days | Canonical replay/shadow adapters and reports |
| S5 | 2040 days | Attested RAVNOVES00 digital twin |
| S6 | 2040+ days | Trike model, controller-in-loop/HIL and physical-shadow gate |
Implementation cards follow this order:
1. Product SRS and ADR.
2. D-only inventory and stack lock.
3. Clock, frame and authority contracts.
4. Orchestrator lifecycle and artifact store.
5. PX4 stock rover control and failsafe.
6. Nav2 adapter and truth baseline.
7. Evaluator, report and BARN pilot.
8. Virtual K1 and perception.
9. Replay/shadow datasets.
10. RAVNOVES00 twin.
11. Trike, HIL and safety gate.
## 17. SIM S0 acceptance
SIM S0 requires all of the following factual evidence:
- worker inventory;
- physical D-only placement of the dedicated WSL VHD, sources, builds, caches
and artifacts;
- confirmation that S0 uses the dedicated D-only WSL and does not use the
existing C-backed Docker Desktop runtime;
- exact component/package pins and licenses;
- one stock PX4 rover launch;
- ROS 2 telemetry;
- authoritative simulation clock;
- pause, resume, single-step and speed-factor behavior;
- clean stop with no orphan processes;
- 1× resource baseline;
- 2× resource baseline;
- explicit `GO`, `INCOMPLETE` or `BLOCKED` report.
The repository profile and doctor implement preflight and evidence admission.
They do not install packages, start processes, open ports or write a target
report.
Run the local read-only preflight:
```bash
uv run missioncore-sim s0 doctor --json
```
An optional evidence manifest uses
`missioncore.simulation-s0-evidence/v1`. Each required claim points to a
confined regular artifact and supplies its SHA-256. The profile SHA-256 must
match the exact candidate generation.
## 18. S1 and S2 acceptance outline
### S1
- Ackermann primary and Differential reference launch from the orchestrator.
- Start, pause, resume, step, reset and stop are server-owned.
- Straight, turn, reverse, deceleration and emergency-stop cases pass.
- TTL, heartbeat, offboard loss and link loss produce a captured safe outcome.
- Pose, speed, steering/controller state and failsafe telemetry are retained.
- Reset creates a new run/episode.
- A repeated seed produces the declared R1 verdict tolerance.
K1, Nav2 and top-level UI do not block S1 backend acceptance.
#### S1A implementation status
The first S1 increment is implemented in `k1link.simulation.contracts` and
`k1link.simulation.run_store`:
- the immutable manifest owns run/episode identity, exact scenario/profile
digests, Mission Core revision, provider pins, host profile, seed,
reproducibility tier, clock domain and fail-closed authority;
- lifecycle and domain events are stored as exclusive, individually atomic,
contiguous records; state is reconstructed by replay and the immutable
manifest is never rewritten;
- only legal lifecycle edges are admitted; terminal history and its artifact
index are sealed;
- Ackermann speed/steering and Differential speed/yaw-rate commands require a
positive monotonic sequence, simulation-time expiry and the current authority
generation; non-finite values, over-limit TTL, stale generations, direct
actuator authority and commands outside `running` fail closed;
- replay/shadow cannot causally accept commands, while HIL and
controller-in-loop admission remain blocked pending a separate safety gate;
- restart reconciliation marks an active run `failed` with
`orchestrator-recovery-interrupted`; it never hides a resume;
- reset is accepted only after the prior run is terminal and creates a new
linked run and episode identity;
- registered artifacts use confined relative POSIX paths, SHA-256 and
append-only sequence records.
The repository root is caller-supplied so unit tests and development remain
portable. The target Simulation Orchestrator must bind that root under the
accepted `/mnt/d/NDC_MISSIONCORE` storage boundary; S1 provider orchestration
and target evidence are not implied by this increment.
#### S1B ownership foundation status
The next local increment implements the provider-neutral ownership boundary:
- `SimulationApplicationService` is the only caller allowed to drive persisted
start, pause, resume, single-step, stop, reset and restart reconciliation;
- start and stop requests are journal-bound to idempotency keys, and a
different request cannot rebind the same run operation;
- a second `starting|running|paused|stopping` run cannot acquire the same worker
authority;
- the worker port exposes lifecycle operations without exposing a browser-to-PX4
channel or choosing a remote transport;
- `S0WorkerGuard` requires the run's exact accepted S0 profile ID/SHA-256, the
canonical `/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/runs/<run-id>`
artifact layout and the actual
`/mnt/d/NDC_MISSIONCORE/simulation/runtime/s1/runs/<run-id>` supervisor
working layout before provider start;
- `PosixProcessSupervisor` launches argv without a shell, creates one dedicated
session/PGID per provider, writes run-scoped stdout/stderr and a process
registry, rolls back partial starts, and stops in ascending S0 shutdown order;
- failure, profile drift or process residue becomes a persisted terminal
failure rather than a successful stop.
The first target adapter now supplies concrete Micro XRCE-DDS Agent and combined
PX4/Gazebo stock-rover specs. Readiness is an executable contract: the Agent
must be listening, while PX4 must report the exact rover world/model, completed
startup and a `vehicle_status_v1` DDS writer. PX4 stdin remains open without
sending a command, so its shell cannot enter an EOF prompt loop. Provider logs
are read incrementally, all argv are shell-free, and every provider owns a
dedicated PGID.
The exact commit `6cb1495` passed `25` target tests and the real
`s1b-6cb1495-20260724t1535z` lifecycle inside `MissionCore-Sim`. Its immutable
Git archive SHA-256 is
`37da47744e855bb1031734893a6f2dd82bb4eb92b7e15edd70794c29b2dfb451`.
Mission Core persisted the admitted/starting/running/stopping/completed history,
started `micro-xrce-dds-agent` plus `px4-gazebo-stock-rover`, observed an XRCE
session and PX4 DDS writer, sent zero control commands, and stopped with no
registry or `/proc` residue. Evidence is D-only and digest indexed under
`/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/lifecycle/`.
This accepts the S1B target start/health/stop boundary. UI-0 now consumes the
accepted persisted history without widening authority. It does not accept
restart-safe PID/start-token reconciliation, live
pause/resume/step/reset, canonical VehicleState, frame conversion, rover command
mapping, watchdog/failsafe cases, repeatability or S1 as a whole.
S1C commit `b7ccca3` accepts the next placement/presentation slice only. A
persistent worker agent runs unprivileged in a new loopback-only namespace and
is registered to Mission Core through a private Unix socket. The browser uses
same-origin APIs for worker status, explicit virtual-only start/stop and a
bounded `missioncore.vehicle-state/v1` view. Exact target source archive
SHA-256 is
`fc3195dbffef270f9669c194cf90e3fe4b62e113a5a7b74af89caa123b601ccd`;
the xattr-free Control Station build archive SHA-256 is
`76b687d12a7675275dfae8ebe3d806d3725240182136108c9bd01f0e8e364df6`.
The first UI-driven run completed cleanly with 52.836 seconds of Gazebo time
and no provider residue. Its `VehicleState` is Gazebo ground truth marked
`diagnostic`; PX4/ROS 2 canonical telemetry and command-induced rover motion
remain open.
UI-2 commit `60e7916` accepts the next browser-placement slice. Its exact
source archive SHA-256 is
`d0f343154981c6dc78deb8ac57de4d980388a0d3f80ff7405ff53e52166d2bd3`;
the xattr-free Control Station build archive SHA-256 is
`250898e82ae3c97c1328ea90b5a1fc0fed9aeedcd2c06002b9b5d1e5bfa50f36`.
The exact D-only generation passed 35 focused target tests. Browser QA entered
through the capability-gated header, found no secondary launcher, exercised
orbit and zoom, observed advancing Gazebo time and stopped
`s1c-60e7916-20260724t182006z-2f164e` cleanly at 27.74 seconds of simulation
time with eight events, zero commands and zero provider residue.
S1D code generation `49b0f47fea0ae29c977bbd60808696b85a9380cd`
accepts the first Ackermann command/motion slice. Its exact source archive
SHA-256 is
`30aad33664a536f2a6cb87bc172a5dae111ae479d5899723832a1abc17c4517b`;
the xattr-free Control Station build archive SHA-256 is
`014724896c8c2169a89b0ed6971b285b6e80da918d892509e9e93568efb99f25`.
The target imported product code from the immutable source generation before
the D-only dependency site and passed 40 focused tests.
The browser contract remains speed in metres per second plus normalized
steering. On this stock PX4 v1.17.0 model, the worker's S1 adapter converts speed
to `RoverThrottleSetpoint` using `RO_MAX_THR_SPEED=3.1` and publishes
`RoverSteeringSetpoint` under Offboard `thrust_and_torque`, which retains PX4
arming and control allocation. It never publishes `ActuatorMotors` or
`ActuatorServos`. This adapter is sufficient for causal motion qualification
but is not evidence of calibrated closed-loop speed tracking.
The first cold start completed in one request after 32.08 seconds. Run
`s1c-49b0f47-20260724t195259z-62edd2` moved from `(0.00, 0.00)` to
`(3.74, -0.00)` in the straight case, then to `(5.11, 2.13)` through a turn and
to `(5.58, 0.67)` in reverse. One sampled command completed in 44.6 ms. A
single non-zero command expired at the 250 ms boundary and incremented
`ttl_expired_count` from 4 to 5; two following one-second-separated live
samples had identical X/Y. Explicit stop disarmed the virtual rover, completed
the run and left no PX4, Gazebo or Micro XRCE-DDS process residue.
This evidence checks the primary Ackermann command item only. S1 stays open for
Differential Rover, deceleration/emergency-stop profiles, accepted PX4/ROS 2
telemetry, reset/repeatability, heartbeat/offboard/link-loss, full failsafe and
navigation/safety cases.
### S2
- Gazebo LiDAR/odometry feed the ROS 2/Nav2 baseline.
- Ackermann uses MPPI plus Smac Hybrid/Lattice or an explicitly justified
equivalent.
- Static wall, corridor, bypass, narrow passage, no-data, planner timeout and
control-loss scenarios produce comparable reports.
- PX4 waypoint following without obstacle avoidance and Nav2 are measured on
the same profiles.
- A 30-world BARN pilot follows stock-world acceptance; only geometry, maps,
paths and difficulty are imported from the legacy runtime.
## 19. Replay and dataset policy
Recorded datasets support detector, segmentation, tracking, occupancy and
shadow-policy regression. They do not react to alternative commands and cannot
prove a closed-loop avoidance maneuver.
- CODa and JRDB non-commercial/share-alike restrictions prohibit treating them
as unrestricted commercial runtime dependencies.
- NCLT is large and must be admitted by a size/license manifest.
- SCAND supplies social-navigation demonstrations but remains replay evidence.
- RAVNOVES00 first enters as canonical replay/shadow; only S5 may turn an
attested reconstruction into a digital-twin world.
Unknown or unobserved space remains unknown/occupied according to the selected
safety policy.
## 20. UI gate
Polygon is implemented as a capability-gated seventh top-level bounded context:
```text
Center | Fleet | Observation | Missions | Data | Polygon* | System
* visible only while Mission Core confirms an admitted available Simulation Worker
```
Two UI gates are intentionally distinct.
### UI-0 — early read-only run view
Target S1B proves persisted start/health/stop history. UI-0 is implemented as a
direct internal run view and exposes:
- run identity, lifecycle and terminal reason;
- authoritative clock and current pause/running state;
- provider health/process ownership;
- qualification events and admitted artifact links;
- explicit limitations and gate status.
The backend reads the configured repository from
`MISSIONCORE_POLYGON_RUNS_ROOT` using
`QualificationRunStore(read_only=True)`. It exposes only:
```text
GET /api/v1/polygon/runs
GET /api/v1/polygon/runs/{run-id}
```
Missing/malformed configuration returns `503`; invalid or corrupt evidence
fails closed. Responses never contain the configured root, artifact bytes or
command payloads. `?workspace=polygon-run[&run=<run-id>]` remains a compatible
deep-link into the same product workspace.
UI-0 itself has no PX4 transport or lifecycle/command operations. It remains a
read-only evidence contract backed by the server-owned run repository and is
composed into UI-2. A shared review deployment requires a reviewed read-only D
mount or a co-located backend; SSH is not a product data plane.
### UI-2 — worker-gated top-level Polygon
The full UI-2 control/scenario gate begins only after:
1. this SRS and the clock/frame/authority contracts are accepted;
2. S1 target backend lifecycle and command authority are proven;
3. run history is persisted independently of a browser session.
Commit `60e7916` accepts the presentation/lifecycle slice before all three
conditions are complete: conditional header root, direct workspace,
virtual-only start/stop, 3D rover inspection, diagnostic ENU pose and read-only
history. It does not claim command authority. Scenarios, pause/step/reset,
canonical rover commands, Compare and Report remain planned. UI-2 cannot own
provider processes, bypass authority checks or write directly to PX4.
## 21. Change control
A material change requires:
1. an ADR/SRS diff;
2. an update to MISSIONCOR-39;
3. migration and compatibility notes;
4. new or updated tests;
5. fresh evidence before a previously accepted gate remains accepted.
No checkbox is closed from intent alone. A commit plus factual validation or
admitted evidence is required.

View File

@ -0,0 +1,186 @@
# ADR 0015: Simulation Polygon as a qualification boundary
## Status
Accepted for architecture; SIM S0 qualification returned `GO`, the S1B
real-provider start/health/stop boundary passed on 2026-07-24, and the direct
read-only UI-0 view is implemented and visually verified against that accepted
journal. Navigation behavior, safety behavior and real actuator authority are
not accepted.
## Context
Mission Core has proven a K1 observation/archive/perception vertical, but the
perception outputs do not yet have a closed-loop consumer. Adding Gazebo, PX4,
ROS 2 and Nav2 directly to the web process or modeling every experiment as an
observation session would blur product ownership, lifecycle, evidence and
authority.
Replay, physics simulation, a digital twin, HIL and real shadow also have
different causal meaning. A recorded stream cannot react to a hypothetical
command, while a closed-loop world can. Calling both `SimulationRun` would make
reports easy to misuse.
## Decision
1. Polygon is a first-class Mission Core bounded context for autonomy
qualification and assurance.
2. Mission Core owns scenarios, qualification-run lifecycle, authority policy,
canonical contracts, provenance, evidence admission, evaluation and reports.
3. Gazebo, PX4 SITL, ROS 2, Nav2, Rerun and datasets are replaceable providers.
4. `QualificationRun` is the umbrella aggregate. Its kinds explicitly separate
`simulation_closed_loop`, `replay_shadow`,
`digital_twin_closed_loop`, `controller_in_loop`, `hil` and
`physical_shadow`.
5. Simulation Orchestrator is the sole process-lifecycle owner. Gazebo and PX4
do not run inside the Mission Core web process, and the browser has no direct
PX4 channel.
6. Gazebo `/clock` is authoritative in closed-loop simulation. ROS 2 uses
simulated time and PX4 uXRCE-DDS time synchronization is disabled for this
profile.
7. Mission Core uses ENU/FLU. PX4 uses NED/FRD. Exactly one adapter boundary
converts frames and requires golden tests.
8. The first command boundary permits only rover speed+steering and
speed+yaw-rate profiles with sequence, TTL, heartbeat, watchdog and
stop-on-loss behavior. Direct actuator setpoints are rejected.
9. Source-of-record, derived artifacts and presentation state remain separate.
Reset creates a new immutable run/episode.
10. Ground truth is evaluator-only and is not visible to perception or planning.
11. SIM S0 targets the reviewed Windows 11 worker through a dedicated
`MissionCore-Sim` WSL2 Ubuntu 24.04 distribution physically under
`D:\NDC_MISSIONCORE`.
12. SIM S0 does not depend on the existing Docker Desktop runtime. Its WSL
backend is C-backed and currently serves unrelated co-tenants.
13. S1 uses exact `px4_msgs` directly. The experimental
`px4-ros2-interface-lib` is not a mandatory S1 dependency.
14. The read-only direct UI-0 run view consumes proven target S1B persisted
history through GET-only server routes. It is hidden from navigation and
cannot mutate a run. Top-level Polygon UI and control remain gated on
accepted S1 target command/lifecycle behavior.
15. Real actuator authority requires a new decision and physical safety gate.
16. S0 processes run inside an ephemeral loopback-only Linux network namespace.
Micro XRCE-DDS Agent's UDP wildcard bind is acceptable only inside that
boundary; no S0 listener may be reachable from the worker LAN or Windows
host network.
17. Product packaging, simulation-worker placement, the future onboard Edge
Agent, local-first field evidence and offline synchronization follow
[ADR 0016](0016-distributed-product-edge-and-worker-topology.md). Native
Gazebo GUI is worker-local diagnostics; the operator product remains the
browser Control Station over canonical state.
## Consequences
- Perception can be measured by downstream decisions without granting it hidden
behavior authority.
- Standard planners and future Mission Core planners can be compared on the
same scenario and metric profiles.
- Replay cannot be presented as closed-loop safety evidence.
- Provider upgrades require explicit compatibility and evidence refresh.
- More up-front domain and artifact work is required before a visual demo.
- The AI worker must satisfy D-only, resource and coexistence constraints before
installation is accepted.
- Simulation and HIL reduce risk but do not certify the real trike.
## First implementation
The qualification doctor is deliberately read-only:
- `simulation/s0/qualification-profile.yaml` is the accepted S0 stack,
storage, clock, authority, port and process contract.
- `k1link.simulation.s0` strictly validates that contract and optional
digest-bound worker evidence.
- `missioncore-sim s0 doctor` inspects the current host without installing,
starting or modifying anything.
- MISSIONCOR-40 tracks factual worker qualification.
The first target bootstrap also includes `simulation/s0/worker-smoke.sh`. It
creates the reviewed loopback-only namespace, starts only the pinned stock-rover
providers, observes ROS 2 vehicle status and Gazebo clock, requests PX4 shutdown,
checks for owned-process residue and writes D-only factual artifacts. It never
arms the vehicle or sends an actuator command.
`simulation/s0/gazebo_time_control.py` owns the S0 pause/single-step/1×/2× RTF
and resource acceptance probe. `simulation/s0/build_evidence_manifest.py`
admits only exact-profile, D-confined, passing 1×/2× runs into the digest-bound
evidence generation. These scripts are qualification tooling; the S1
Simulation Orchestrator remains the future product lifecycle owner.
The doctor reports `INCOMPLETE` until it is running on the reviewed target with
resolved/accepted pins and all required evidence. On the accepted profile
generation and nine-claim confined evidence set it returned `GO`; it cannot
infer that result from repository state alone.
## S1B target lifecycle
Exact generation `6cb1495` is the first product-owned real-provider acceptance.
`SimulationApplicationService` persisted the lifecycle while the worker adapter
created a fresh loopback-only namespace and `PosixProcessSupervisor` owned two
dedicated PGIDs: Micro XRCE-DDS Agent and the combined PX4/Gazebo stock
Ackermann launch. The run became `running` only after the exact world/model,
PX4 startup and DDS-writer markers existed. No arm, setpoint or other PX4 shell
command was sent. Stop produced `completed`, eight events, zero commands and no
process residue.
UI-0 now implements that decision. `QualificationRunStore(read_only=True)`
opens an existing repository without creating or chmodding it and rejects every
mutating method. The web process exposes only run catalog/detail GET routes,
and the hidden Control Station workspace opens only from
`?workspace=polygon-run[&run=<id>]`. Browser QA loaded the accepted
`s1b-6cb1495-20260724t1535z` journal. This does not widen command authority or
accept pause/step/reset, canonical telemetry, frame conversion,
watchdog/failsafe behavior, navigation or physical control.
## S1A implementation
`k1link.simulation.contracts` now defines the first product-owned
`QualificationRun`, provider/authority/artifact provenance, canonical
Ackermann and Differential rover setpoints, and qualification events.
`k1link.simulation.run_store` persists them as an immutable manifest plus
one-file-per-sequence append-only journals. Exclusive creation and file/directory
fsync prevent partially acknowledged records; loading rejects gaps, identity
drift, non-regular records and illegal lifecycle history.
The aggregate derives its current state by replay. Terminal runs cannot accept
new events, commands or artifacts. A restart never silently resumes an active
run: reconciliation terminates it as `failed` with
`orchestrator-recovery-interrupted`. Reset requires a terminal parent and
creates a new linked run/episode. Commands are admitted only for a running
closed-loop virtual run with the current authority generation and bounded
simulation-time TTL. Replay/shadow commands, direct actuator authority and
ungated HIL/controller-in-loop admission fail closed.
This increment proves the local lifecycle/repository contract. It does not yet
own provider processes, transmit a PX4 setpoint, execute heartbeat/watchdog
failsafe behavior, or qualify navigation/safety behavior. The target
orchestrator must place the caller-supplied repository root under the accepted
D-only worker boundary.
## S1B ownership foundation
`k1link.simulation.orchestrator` now owns the application lifecycle through a
transport-neutral worker port. Start and stop requests are persisted with
idempotency identity, one active run owns a worker at a time, and
pause/resume/step/reset cannot bypass run-state validation. Provider start
failure, S0 profile drift, stop residue and restart interruption all become
explicit run events and terminal failures.
`k1link.simulation.process_supervisor` provides the worker-local POSIX ownership
primitive: shell-free argv, dedicated provider PGIDs, run-scoped logs, atomic
process registry, partial-start rollback and ascending shutdown order matching
the accepted S0 dependency graph. `k1link.simulation.worker` binds that
primitive to the exact accepted S0 profile digest and canonical D-only S1 run
artifact and process-runtime layouts.
The later exact generation `6cb1495` supplied the target provider
specifications, loopback namespace and live readiness probes and passed the
real-provider start/health/stop boundary described above. Restart-safe
PID/start-token reconciliation after orchestrator loss and Gazebo
world-control/PX4 command adapters remain open. The worker port deliberately
fixes ownership semantics without making SSH or the browser a provider
transport.
Commit `630d1ae` subsequently passed a D-only `MissionCore-Sim` bootstrap:
the exact Git archive SHA was verified, both S1 test modules returned
`20 passed`, and the test process-residue record was empty. This accepts target
executability of the foundation only; the later `6cb1495` evidence, not that
bootstrap, closes the real-provider lifecycle boundary.

View File

@ -0,0 +1,181 @@
# ADR 0016: Distributed web product, edge runtime and worker topology
## Status
Accepted for architecture on 2026-07-24. It defines placement and packaging,
not field actuator acceptance. The current Windows/WSL2 simulation worker is a
laboratory deployment of this topology; the future onboard Edge Agent and its
physical command authority remain separate gates.
## Context
Mission Core has a React Control Station, a Python control plane, native
simulation providers and future onboard components. Treating all of them as one
application creates two misleading alternatives:
1. embed Gazebo, PX4, ROS 2 and Nav2 inside the browser; or
2. make Mission Core a Windows/macOS/Linux desktop executable.
Neither matches the system.
Gazebo Harmonic has a native Qt GUI, PX4 SITL and ROS 2 are Linux processes, and
Nav2 is a runtime graph. A browser cannot own those processes or preserve their
state when its tab closes. Conversely, a field vehicle must keep bounded
autonomy, safety behavior and durable local evidence when the operator browser,
WAN or control centre is unavailable.
The operator still needs one coherent product: plan a mission, inspect a live
or historical vehicle, run a simulation, compare outcomes and review evidence
from a browser on macOS, Windows, Linux or a tablet.
## Decision
### Product form
1. The primary Mission Core operator product remains the React Control Station
served as static web assets by a Mission Core Gateway/API. It is not a
platform-specific desktop executable.
2. An optional PWA, kiosk shell or thin Tauri/Electron wrapper may package the
same web assets for offline distribution. Such a wrapper is not a second
product, does not own authority and is not required for the core topology.
3. Native provider GUIs are diagnostic tools. Gazebo GUI, QGroundControl and
similar tools do not become product navigation or a source-of-record.
### Runtime roles
4. Mission Core Control Plane owns users, missions, scenarios, policy,
qualification-run identity, authority admission, orchestration, catalogues,
comparison and reports.
5. Simulation Worker is a separately registered Linux execution target. It runs
Simulation Orchestrator worker components plus Gazebo, PX4 SITL, ROS 2 and
Nav2. Normal product operation is headless. A native Gazebo GUI may attach
locally for laboratory diagnosis without becoming the control plane.
6. Vehicle Edge Agent is a future headless Linux service on the onboard
computer or a vehicle-adjacent edge computer. It owns device adapters,
bounded local mission state, health, canonical telemetry, local evidence,
store-and-forward synchronization and the server-side physical authority
boundary accepted for that vehicle.
7. The flight/rover controller remains a separate PX4 hardware boundary. A
browser never talks to PX4 directly.
8. Observation/perception workers may run onboard, on a nearby edge computer or
in a control-centre worker according to latency and bandwidth budgets. Their
placement does not change canonical contracts or authority ownership.
### Browser presentation
9. The browser receives bounded canonical state, events, metrics, maps,
trajectories, perception layers and optional media previews. It renders
interactive product views through browser-native components such as Rerun
Web Viewer, WebGL/canvas and ordinary React controls.
10. A Polygon run view shows the canonical rover pose, route, planned/factual
trajectory, footprint, safety state and derived scene. It does not require
remote pixels from Gazebo GUI.
11. Full native Gazebo rendering may be exposed only as an explicitly
diagnostic remote-desktop/WebRTC capability. It is optional, isolated from
command authority and never the only representation of run state.
### Connectivity and evidence
12. Field safety and bounded mission execution cannot depend on a browser tab,
a WAN connection or continuous connectivity to a central Mission Core
instance.
13. The Edge Agent persists raw/source evidence and the canonical event/state
journal locally before optional upload. High-volume sensor data remains on
vehicle storage until retention and connectivity policy permits transfer.
14. Connected mode sends bounded live telemetry, health, decisions and previews
outward. Disconnected mode continues the locally admitted policy, applies
the accepted loss/failsafe behavior and queues evidence for later sync.
15. Reconnection performs idempotent, digest-bound store-and-forward
synchronization. The central archive never rewrites the vehicle's original
run identity or presents a partial upload as complete.
16. A field kit may host the Gateway/API and React assets locally on an operator
laptop or edge hub. The same deployment can later synchronize with a
control-centre instance; no Internet connection is required to render the
UI.
### Authority path
17. The authority path is always:
```text
Browser intent
-> Mission Core API/policy
-> Orchestrator or Vehicle Edge Agent
-> allowlisted provider/device adapter
-> PX4 boundary
```
18. Losing the browser affects presentation only. Losing upstream connectivity
invokes the locally accepted watchdog/failsafe policy; it does not grant a
new command source.
## Supported deployment topologies
### Laboratory simulation
```text
Operator browser
-> Mission Core Gateway/API
-> Simulation Orchestrator
-> registered Linux/WSL2 GPU worker
-> Gazebo + PX4 SITL + ROS 2 + Nav2
-> local source evidence + canonical/derived streams
```
The current `MissionCore-Sim` WSL2 distribution on the D drive is this
topology's first qualified worker. Its native Gazebo window is local diagnostics;
the product view remains browser-native.
The first implemented registration slice is S1C commit `b7ccca3`. Mission Core
and the worker share only a private mode-`0600` Unix socket in the WSL mount
namespace. PX4, Gazebo and Micro XRCE-DDS stay inside the worker's
loopback-only network namespace and run as the unprivileged `missioncore`
identity. Run artifacts and provider logs remain under the admitted D-only
roots. The React client calls same-origin Mission Core endpoints and never
receives the socket path or a provider endpoint. Remote/routed worker
registration is intentionally not inferred from this local laboratory
transport; it requires an authenticated contract of its own.
### Connected field vehicle
```text
Operator browser/control centre
-> Mission Core Gateway/API
<-> Vehicle Edge Agent on onboard Linux
-> PX4 hardware boundary
-> sensors and actuators
```
Bounded telemetry and previews can be live. Raw/high-volume evidence is written
locally first and uploaded according to policy.
### Disconnected field vehicle and later review
```text
Approved local mission + Edge Agent + PX4
-> local execution/failsafe
-> local append-only evidence
-> later digest-bound synchronization
-> browser history/replay/report
```
The historical/replay product is therefore not a fallback or a separate
application. It is the review side of the same distributed system.
## Consequences
- Operators use one browser product across simulation, field monitoring,
planning, archive and comparison.
- Native Linux dependencies do not force a Windows-only or macOS-only Mission
Core executable.
- Gazebo is not installed on field vehicles unless a specific lab/HIL profile
requires it.
- The product needs explicit Gateway, worker-registration, Edge Agent,
store-and-forward and capability-discovery contracts.
- Live 3D is a canonical-state presentation problem, not a remote-Gazebo-window
dependency.
- A thin desktop wrapper remains possible for distribution or kiosk use, but it
cannot widen authority or hide missing offline server components.
- Field hardware, storage budgets, network-loss policy, physical e-stop,
operator takeover and real actuator authority still require separate
acceptance.

View File

@ -0,0 +1,126 @@
# Lab 006: SIM S0 worker bootstrap and stock-rover smoke
Date: 2026-07-24 (Europe/Moscow).
## Scope
The reviewed Windows 11 AI worker received a dedicated Docker-free
`MissionCore-Sim` WSL2 Ubuntu 24.04 distribution physically stored at
`D:\NDC_MISSIONCORE\simulation\wsl\MissionCore-Sim`. Source, build, cache,
runtime, artifacts and datasets are all under the same D-only root. Existing
Docker Desktop co-tenants were inventoried but not stopped, reconfigured or
used.
This lab grants no real or simulated actuator authority. Every smoke run kept
`actuator_authority=false`; it observed the stock rover while disarmed.
## Redacted worker inventory
- Windows 11 Pro, build 26200;
- WSL2 kernel `6.18.33.1-microsoft-standard-WSL2`;
- Ubuntu 24.04.4 LTS, x86_64;
- 13th Gen Intel Core i9-13900KF, 32 WSL-visible logical CPUs;
- 50,427,760,640 WSL-visible RAM bytes and 12,884,901,888 swap bytes;
- NVIDIA GeForce RTX 4090, driver 610.47, 24,564 MiB VRAM;
- D capacity 2,000,381,014,016 bytes;
- D free after bootstrap and builds: 392,758,685,696 bytes.
The remaining D capacity is above the 300 GiB S0 start threshold and 200 GiB
stop threshold. This does not waive continuous disk monitoring.
## Installed candidate generation
- ROS 2 Jazzy `ros-base=0.11.0-1noble.20260616.084325`;
- `ros-gz=1.0.22-1noble.20260616.074726`;
- Gazebo Harmonic `1.0.0-1~noble`, Gazebo Sim `8.14.0-1~noble`,
SDFormat `14.9.0-1~noble`;
- PX4 Autopilot v1.17.0 commit
`d6f12ad1c4f70ad3230afd7d86e971421e02fef4`;
- `px4_msgs` v1.17.0 commit
`86d8239e962f6939e05c3737784f60c02fa884db`;
- PX4 Gazebo models commit
`b6127f4ec20de867e215fb5f78ae88b80f371909`;
- Micro XRCE-DDS Agent v2.4.3 commit
`73622810d984349b80bbac0ef55fc0b694d62222`;
- Nav2 `navigation2=1.3.12-1noble.20260615.181551` and
`nav2-bringup=1.3.12-1noble.20260616.082701`.
PX4 SITL and `px4_msgs` were built successfully from the pinned D-only source.
The Micro XRCE-DDS Agent was built from source and installed under the D-only
runtime root. Nav2 is installed but has not yet passed an S2 navigation case.
## Observed smoke result
Two consecutive canonical runs and one preliminary resource run passed:
- `20260724T134400Z-stock-rover`;
- `20260724T134600Z-stock-rover-repeat`;
- `20260724T135000Z-stock-rover-baseline`.
The final smoke-script generation also passed as
`20260724T135800Z-stock-rover-final` and added a runtime listener inventory plus
the point resource snapshot.
Each run started PX4 v1.17.0 with Gazebo world `rover` and model
`rover_ackermann_0`, established a Micro XRCE-DDS session, observed
`/fmu/out/vehicle_status_v1`, observed Gazebo `/clock`, requested PX4 shutdown
and found no owned runtime residue. The captured vehicle state was disarmed,
`failsafe=false`, with pre-flight checks passing.
Each provider set ran inside a new Linux network namespace containing only
`127.0.0.1` and `::1`. The Agent's UDP `0.0.0.0:8888` bind was therefore
effective only inside that loopback boundary. No PX4, Gazebo or Agent service
was exposed to the LAN.
The two canonical artifact directories are 68 KiB each. The preliminary
resource run completed in 38.75 seconds. One point sample observed approximately
202 MiB RSS for headless Gazebo, 19 MiB for PX4 and 17 MiB for the Agent. The
worker GPU was already occupied by unrelated co-tenants; no S0 GPU allocation
was attributable in this headless run. These point observations are not the
accepted 1× or 2× resource baselines.
## Diagnostics retained
Earlier bootstrap attempts remain outside Git under the D-only artifact root:
- one check raced the asynchronous DDS connection even though the session
subsequently established;
- one used an unsupported ROS 2 Jazzy long option;
- one used ROS domain 42 while PX4 v1.17.0 published on domain 0.
The smoke contract now waits for the Agent session plus the PX4
`vehicle_status_v1` writer, uses Jazzy `topic list -t`, and relies on the
loopback-only namespace to isolate domain 0.
An initial malformed interactive log repeatedly captured PX4 terminal repaint
output and reached approximately 1.1 GiB. That generated diagnostic artifact
was removed, then the case was rerun non-interactively; the valid standalone
PX4 log was approximately 8 KiB.
## Target doctor
The exact candidate profile was executed through the read-only doctor inside
`MissionCore-Sim`. Profile SHA-256:
`70bf0df44c85dd57eb3cedb98a474a46ba498c6ffc60ce0d8a06829e28587df1`.
Target identity, authority, clock, port/process registries, all seven D-only
mutable roots, the disk threshold and all eight required executables passed.
Version acceptance and the nine-item digest-bound evidence set remain
incomplete, so the doctor correctly returned `INCOMPLETE`.
## Verdict
`INCOMPLETE`.
Worker compatibility, D-only placement, exact candidate pins, PX4 build,
stock-rover launch, DDS telemetry, authoritative clock observation and repeated
clean lifecycle are factually proven. S0 still requires:
- pause/resume and deterministic single-step evidence;
- 1× speed/real-time-factor acceptance;
- isolated 2× resource/coexistence baseline;
- a complete digest-bound evidence manifest;
- final `GO`, `INCOMPLETE` or `BLOCKED` decision.
This lab does not accept Nav2 behavior, navigation safety, real-time guarantees,
or real actuator control.

View File

@ -0,0 +1,126 @@
# Lab 007: SIM S0 acceptance
Date: 2026-07-24 (Europe/Moscow).
## Scope
This lab closes the infrastructure qualification gate for Mission Core
Polygon. It validates the exact D-only worker profile, stock PX4 Ackermann
runtime, ROS 2 telemetry, Gazebo time control, 1×/2× resource behavior,
deterministic shutdown and digest-bound evidence admission.
It does not accept S1 orchestration, rover command behavior, Nav2 navigation,
safety behavior, HIL, physical shadow or real actuator control.
`actuator_authority=false` throughout.
## Accepted profile
- profile ID: `ai-worker-px4-gazebo-v1`;
- profile SHA-256:
`aeecf5005301db230e1340b85215cbbd862b72974a22b8c43767c28be8453e2f`;
- evidence tooling commit:
`2548bb57403411ce9c8374151e3bfa34ac0866ee`;
- target: dedicated Docker-free `MissionCore-Sim` WSL2 Ubuntu 24.04 on D;
- all seven component pins: resolved and `accepted`;
- all seven mutable roots: present under `/mnt/d/NDC_MISSIONCORE`;
- disk at final doctor: 365.55 GiB free, above the 300 GiB start and
200 GiB stop thresholds.
Exact ROS 2, Gazebo, PX4, `px4_msgs`, PX4 Gazebo model, Micro XRCE-DDS Agent and
Nav2 versions remain recorded in the qualification profile and Lab 006.
## Accepted runs
The exact accepted-profile generation produced two immutable runs:
- `20260724T155000Z-accepted-1x`;
- `20260724T160000Z-accepted-2x`.
Both runs passed:
- stock world `rover`, model `rover_ackermann_0`;
- PX4 v1.17.0 and Micro XRCE-DDS session;
- ROS 2 `/fmu/out/vehicle_status_v1`;
- authoritative Gazebo `/clock`;
- loopback-only network namespace;
- pause, single-step and declared speed behavior;
- structured resource baseline;
- clean shutdown with no owned process residue.
The vehicle stayed disarmed. No actuator command was issued.
## Time acceptance
| Case | Pause advance | Single step | Measured RTF | Accepted range |
| --- | ---: | ---: | ---: | ---: |
| 1× | 0 ns | +2,000,000 ns / +1 iteration | 0.981913 | 0.81.2 |
| 2× | 0 ns | +2,000,000 ns / +1 iteration | 1.967258 | 1.62.4 |
Gazebo `/clock` is the closed-loop simulation authority. The zero pause
advance and exact 2 ms single-step are observed deltas, not inferred settings.
## Resource acceptance
| Metric | 1× | 2× | Contract |
| --- | ---: | ---: | ---: |
| Samples at 500 ms | 15 | 15 | at least 6 |
| Peak owned RSS | 407,629,824 B | 408,756,224 B | at most 2 GiB |
| Peak owned CPU | 101.8% | 169.2% | at most 800% |
| Minimum available WSL RAM | 28,437,897,216 B | 28,662,071,296 B | at least 8 GiB |
| Minimum D free | 392,613,560,320 B | 392,568,233,984 B | at least 200 GiB |
| Maximum swap used | 0 B | 0 B | observed |
Global GPU context peaked at 13,579 MiB used and 52% utilization in both cases.
Those values include unrelated co-tenants and are intentionally not attributed
to headless S0.
## Evidence admission
The private D-only evidence generation contains nine redacted JSON artifacts
and one manifest, approximately 100 KiB total:
1. worker inventory;
2. D-only physical placement;
3. PX4 stock-rover launch;
4. ROS 2 telemetry;
5. authoritative simulation clock;
6. pause/step/speed;
7. clean stop with no orphans;
8. 1× resource baseline;
9. 2× resource baseline.
Every manifest entry is `pass`, names a confined regular file and binds its
SHA-256. The manifest binds the accepted profile SHA-256. Failed diagnostic
generations were not reused.
## Target doctor
The read-only doctor returned:
```text
verdict=go
target_host_match=true
profile-contract=pass
authority-contract=pass
clock-contract=pass
port-registry=pass
process-registry=pass
target-host=pass
d-only-storage=pass
disk-budget=pass
version-lock=pass
required-tools=pass
factual-evidence=pass
```
The persisted redacted `doctor.json` SHA-256 is
`6a2571bd7af0e85b1345201068ccd4bffa215f84a8a9f62a4357d5dbfa236c6c`.
## Verdict
`GO` for SIM S0 infrastructure qualification.
The next permitted product phase is SIM S1: server-owned start/pause/resume/
step/reset/stop, immutable `QualificationRun`, canonical Ackermann and
Differential rover commands, TTL/heartbeat/watchdog, stop-on-loss and captured
PX4 failsafe behavior. No real-control permission is created by this verdict.

View File

@ -0,0 +1,72 @@
# SIM S1 target bootstrap — 2026-07-24
## Result
`PASS` for the exact S1 source-generation/bootstrap boundary. This proves that
the committed S1A/S1B contracts, repository, application service, worker guard
and process-supervisor tests execute inside the accepted `MissionCore-Sim`
target. It does not accept the stock-rover provider lifecycle, PX4 commands,
telemetry, frames, watchdog behavior or S1 as a whole.
## Exact source generation
- Mission Core commit: `630d1ae`.
- Branch: `feat/simulation-polygon-s1`.
- Archive: `NODEDC_MISSION_CORE-s1-630d1ae.tgz`.
- Archive SHA-256:
`0ca5d3ffb008add6e793428e96b88920fdc65fa8d9982e5018ce7a4e83958dae`.
- Source:
`/mnt/d/NDC_MISSIONCORE/simulation/source/NODEDC_MISSION_CORE-630d1ae`.
- Cache:
`/mnt/d/NDC_MISSIONCORE/simulation/cache/mission-core-s1/`.
The archive was produced with `git archive` from the exact commit and an
explicit path set. The pre-existing S0 source and evidence generations were not
modified.
## Target and storage
- WSL distribution: `MissionCore-Sim`.
- Python: `3.12.3`.
- Kernel: WSL2 Linux `6.18.33.1-microsoft-standard-WSL2`, x86-64.
- Mutable source, cache and evidence: D-only.
- D free bytes at bootstrap: `392483127296`.
- Evidence:
`/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/bootstrap/20260724T150000Z-630d1ae`.
## Validation
```text
PYTHONPATH=src python3 -m pytest -q \
tests/test_simulation_s1_run_store.py \
tests/test_simulation_s1_orchestrator.py
```
Result:
```text
20 passed in 14.73s
```
The evidence generation retains environment and source identity, target pytest
output, source-file SHA-256 values, an empty process-residue record, an evidence
digest index and `verdict=pass`.
## Accepted and open boundaries
Accepted:
- exact committed S1 source executes on the target worker;
- the D-only source/cache/artifact split is viable;
- local process-group tests stop without retained test processes;
- S1 requires neither a global Python package install nor Docker.
Still open:
- concrete Gazebo/PX4/XRCE/ROS provider specifications;
- product-owned loopback target namespace;
- real-provider health and deterministic shutdown;
- restart-safe PID/start-token reconciliation;
- live pause/resume/step/reset;
- Ackermann/Differential command mapping;
- canonical `VehicleState`, frame conversion and failsafe evidence.

View File

@ -0,0 +1,125 @@
# SIM S1B real-provider acceptance — 2026-07-24
## Result
`PASS` for the product-owned stock-rover start/health/stop boundary on the
accepted `MissionCore-Sim` worker.
This is not S1 acceptance. It does not prove live world control, rover command
mapping, canonical telemetry/frames, watchdog/failsafe behavior, repeatability,
navigation behavior or real actuator safety.
## Accepted source generation
- Branch: `feat/simulation-polygon-s1`.
- Mission Core commit:
`6cb14954d4696270e12d4adb800bc337251dc631`.
- Archive: `NODEDC_MISSION_CORE-s1-6cb1495.tgz`.
- Archive SHA-256:
`37da47744e855bb1031734893a6f2dd82bb4eb92b7e15edd70794c29b2dfb451`.
- Source:
`/mnt/d/NDC_MISSIONCORE/simulation/source/NODEDC_MISSION_CORE-6cb1495`.
- Target test evidence:
`/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/bootstrap/20260724T153453Z-6cb1495`.
- Target test result: `25 passed`.
The source was produced with `git archive` from the exact commit and an
explicit path set. Existing S0 and earlier S1 source/evidence generations were
not modified.
## Accepted run
- Run: `s1b-6cb1495-20260724t1535z`.
- Evidence:
`/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/lifecycle/s1b-6cb1495-20260724t1535z`.
- Host profile:
`ai-worker-px4-gazebo-v1`.
- Host profile SHA-256:
`aeecf5005301db230e1340b85215cbbd862b72974a22b8c43767c28be8453e2f`.
- Lifecycle profile SHA-256:
`06fadcd1b55b70c9a53556dadc65d15a35d8d73f7664ebf766b9986cbe19a883`.
- Scenario SHA-256:
`49e8f4009bc96c40964a5657fef5c2e035f22fa567a2247b0e40571cb5e4ee5e`.
- Network interfaces inside the run: `lo` only.
- Provider owners:
`micro-xrce-dds-agent`, `px4-gazebo-stock-rover`.
- Terminal state: `completed`.
- Terminal reason: `operator-stop-clean`.
- Persisted events: `8`.
- Control commands: `0`.
- Registry residue: none.
- Independent exact-process check after the run: no `px4`,
`MicroXRCEAgent` or `gz`.
- D free bytes after the run: `392023871488`, above the accepted 200 GiB stop
floor.
Provider readiness was not inferred from PIDs. The admitted log evidence
contains:
```text
world: rover, model: rover_ackermann_0
Startup script returned successfully
successfully created rt/fmu/out/vehicle_status_v1 data writer
session established
```
PX4 stdin remained open to prevent an EOF prompt loop, but Mission Core wrote no
PX4 shell command. The worker received no arm or actuator/setpoint request.
## Failed generations retained as negative evidence
`01ec122` stopped before starting providers because the first namespace check
read WSL sysfs rather than the current namespace through netlink. The transient
namespace itself contained only loopback. Source was corrected in a new
generation; the failed generation was not retried.
`b4e3b3b` reached the real stock rover and XRCE session but ended
`orchestrator-start-failed`. Its declared profile incorrectly required
`Running, connected`, a line emitted by the S0 diagnostic CLI command rather
than spontaneous provider health, while PX4 stdin was closed. The full failed
run, provider logs, terminal process registry and digest index remain under:
```text
/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/lifecycle/s1b-b4e3b3b-20260724t1531z
```
That generation was not retried. `6cb1495` replaced repeated full-file polling
with incremental log readers, kept PX4 stdin open without writing commands and
used the factual DDS-writer marker.
## Accepted and open boundaries
Accepted:
- exact S0 generation and D-only admission;
- product-owned loopback namespace;
- shell-free real provider specifications;
- readiness-gated Mission Core start;
- persisted run lifecycle and provider registry;
- deterministic real-provider stop with no residue;
- zero command/actuator authority.
Open:
- restart-safe PID/start-token reconciliation after orchestrator process loss;
- live pause/resume/single-step/reset;
- PX4/ROS canonical VehicleState and ENU/NED/FLU/FRD golden conversion;
- Ackermann and Differential command mapping;
- TTL/heartbeat/watchdog and provider-loss cases;
- repeatability verdict and complete S1 QualificationReport;
- S1C/S1D and S2.
## UI-0 follow-on
The direct internal read-only UI-0 surface is implemented after this acceptance
without changing the accepted S1B evidence. It reads the same
`artifacts/s1/runs` journal through GET-only backend routes and a
non-navigable Control Station workspace. Visual QA loaded the copied accepted
run and showed terminal `completed`, `operator-stop-clean`, four provider pins,
eight events, zero commands and the virtual-only authority boundary. The
browser received neither the configured D root nor artifact bytes or command
payloads.
This follow-on is interface/read-model acceptance only. It does not promote the
S1B run to S1 acceptance and does not close any remaining control, telemetry,
frame, failsafe, repeatability, navigation or physical-safety gate.

View File

@ -0,0 +1,162 @@
# SIM S0 AI worker runbook
This runbook is for MISSIONCOR-40. It does not authorize system installation,
real vehicle control or a `GO` verdict by itself.
## Safety and placement
- Target: reviewed Windows 11 worker with the dedicated `MissionCore-Sim` WSL2
Ubuntu 24.04 distribution physically installed on D.
- All mutable bytes must be under `D:\NDC_MISSIONCORE`.
- Do not place a WSL distribution, source, build, cache, log, bag, ULog,
Gazebo artifact or dataset on C.
- Do not use or reconfigure the existing Docker Desktop runtime in S0. Its
backend is C-backed and serves unrelated co-tenants.
- Do not expose S0 services beyond loopback.
- Run providers inside the ephemeral loopback-only network namespace created
by `simulation/s0/worker-smoke.sh`. Micro XRCE-DDS Agent binds UDP wildcard
inside that namespace because v2.4.3 has no address-selection option.
- Do not download full external datasets.
- Keep `actuator_authority=false`.
## Repository preflight
From the repository-local Python 3.12 environment:
```bash
uv sync --frozen --group dev
uv run missioncore-sim s0 doctor --json
```
Before target evidence exists, the expected verdict is `INCOMPLETE`.
## Stock-rover smoke
Copy the reviewed script to the D-only worker source root, then invoke it as
root inside `MissionCore-Sim` with a unique immutable run ID:
```bash
simulation/s0/worker-smoke.sh <UTC-run-id>-stock-rover <1|2>
```
The script:
- creates a new network namespace containing only loopback;
- starts Micro XRCE-DDS Agent, PX4 SITL and the stock Gazebo Ackermann world;
- verifies the DDS session, `/fmu/out/vehicle_status_v1` and Gazebo `/clock`;
- proves pause freeze and exactly one 2 ms physics step;
- measures the declared 1× or 2× real-time factor over four seconds;
- samples owned CPU/RSS, available RAM, swap, D free space and global co-tenant
GPU load at 500 ms intervals;
- records Gazebo service/topic inventory and runtime listeners;
- sends the PX4 `shutdown` command and rejects owned-process residue.
Run both factors as separate immutable cases. Do not reuse a run ID. A failed
run remains diagnostic evidence; rerun with a new ID after correction.
## Required target inventory
Record a redacted artifact for each of:
1. Windows release, WSL version, Ubuntu version and architecture.
2. GPU/driver, CPU, RAM and VRAM.
3. Physical location of the dedicated WSL distribution and confirmation that
the Docker-free S0 profile is active.
4. D total/free bytes before installation.
5. Existing Docker images, WSL distributions and simulation-related processes.
6. Existing listeners and the candidate port registry.
7. Existing Frigate/Ollama/Triton resource use and whether S0 is coexistence or
exclusive-profile testing.
Do not record credentials, tokens or private network secrets.
## Candidate stack
Use `simulation/s0/qualification-profile.yaml` as the input. Resolve and record:
- exact ROS 2 Jazzy package versions;
- exact Gazebo Harmonic package versions;
- PX4 v1.17.0 commit;
- matching `px4_msgs` commit;
- Micro XRCE-DDS Agent commit/version;
- Nav2 Jazzy commit/package versions;
- PX4 Gazebo model commit;
- licenses and image digests where containers are used.
Change every component to `accepted` only after target build/runtime evidence
exists. Updating a pin changes the profile SHA-256 and invalidates evidence for
the prior generation.
## Runtime cases
The S0 factual cases are:
1. Launch one stock rover headless.
2. Observe PX4 health and ROS 2 telemetry.
3. Prove Gazebo `/clock` is authoritative.
4. Prove pause freezes simulation time.
5. Prove one single-step advances the declared amount.
6. Prove 1× and 2× speed behavior and measure real-time factor.
7. Stop through the intended lifecycle.
8. Prove no owned process remains.
9. Repeat from a clean start.
QGroundControl is diagnostic only and cannot become the Mission Core command
owner.
## Evidence manifest
Create a private/redacted evidence directory outside normal Git. Its manifest:
```yaml
schema_version: missioncore.simulation-s0-evidence/v1
profile_sha256: <sha256 of qualification-profile.yaml>
checks:
- id: worker-inventory
status: pass
artifact: worker-inventory.redacted.json
sha256: <artifact sha256>
note: Redacted host and resource inventory.
```
The manifest must contain exactly all IDs from `required_evidence`. Artifact
paths are relative, confined regular files. The doctor rejects missing,
additional, changed or cross-generation evidence.
Build the redacted manifest only from two passing runs made with the exact
accepted profile generation:
```bash
python3 simulation/s0/build_evidence_manifest.py \
--profile /mnt/d/NDC_MISSIONCORE/simulation/source/qualification-profile.yaml \
--run-1x /mnt/d/NDC_MISSIONCORE/simulation/artifacts/s0/<accepted-1x-run> \
--run-2x /mnt/d/NDC_MISSIONCORE/simulation/artifacts/s0/<accepted-2x-run> \
--output-dir /mnt/d/NDC_MISSIONCORE/simulation/artifacts/s0/evidence/<generation> \
--d-root /mnt/d/NDC_MISSIONCORE \
--source-root /mnt/d/NDC_MISSIONCORE/simulation/source \
--repository-commit <full-commit-sha> \
--wsl-windows-path 'D:\NDC_MISSIONCORE\simulation\wsl\MissionCore-Sim'
```
The generator refuses an existing output directory, non-passing runs,
candidate pins, commit drift, incomplete Nav2/ROS discovery or any input/output
outside the reviewed D root.
Validate without writing:
```bash
uv run missioncore-sim s0 doctor \
--profile simulation/s0/qualification-profile.yaml \
--evidence /path/to/redacted/evidence.yaml \
--json
```
## Verdict rules
- `GO`: target host matches, every pin is accepted, D-only and disk thresholds
pass, required tools exist and all digest-bound evidence validates.
- `INCOMPLETE`: work remains or the doctor is running on a development host.
- `BLOCKED`: contract, storage, tools or evidence fail validation.
Publish the factual result to MISSIONCOR-40 with exact repository commit,
profile SHA-256, files touched and validation performed.

View File

@ -0,0 +1,188 @@
# SIM S1C live-worker runbook
## Scope
This runbook starts and commands the first browser-visible stock Ackermann rover
through the Mission Core control plane. It accepts only a virtual PX4/Gazebo
run. It does not admit a physical vehicle, direct actuator setpoints, navigation
or safety.
Runtime placement:
```text
React Control Station
-> same-origin Mission Core Polygon API
-> mode-0600 Unix socket
-> unprivileged Simulation Worker agent
-> loopback-only network namespace
-> Micro XRCE-DDS Agent + PX4 SITL + Gazebo Harmonic
```
The Unix socket is ephemeral runtime coordination. Source, run journals,
provider logs and qualification artifacts remain under
`/mnt/d/NDC_MISSIONCORE/simulation`.
## Preconditions
- use the dedicated `MissionCore-Sim` WSL distribution;
- stage an immutable Git archive below the reviewed D root;
- verify its SHA-256 against the source artifact;
- use an exact 40-character Mission Core commit;
- confirm no PX4, Gazebo or Micro XRCE-DDS provider residue exists;
- keep `actuator_authority=false`;
- do not add a routed NIC to the worker namespace.
## Start worker agent
From the exact staged source generation:
```bash
sudo simulation/s1/worker-agent.sh <exact-40-character-commit>
```
The wrapper creates `/run/missioncore-sim` as a private runtime directory,
enters a fresh network namespace, raises only loopback and drops to the
`missioncore` user before importing product code.
Expected status through the backend gateway:
```json
{
"schema_version": "missioncore.simulation-worker-status/v1",
"worker_id": "mission-gpu-s1",
"transport": "unix",
"mode": "simulation",
"available": true,
"control_available": true,
"active_run_id": null,
"run_state": null
}
```
## Start Mission Core
```bash
export MISSIONCORE_POLYGON_RUNS_ROOT=/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/runs
export MISSIONCORE_POLYGON_WORKER_SOCKET=/run/missioncore-sim/worker.sock
export MISSIONCORE_POLYGON_WORKER_CONTROL=internal-virtual-only
export MISSIONCORE_COMMIT=<exact-40-character-commit>
uv run uvicorn k1link.web.app:app --host 127.0.0.1 --port 8765
```
Open the Control Station:
```text
http://127.0.0.1:8765/
```
After Mission Core confirms the worker, `Полигон` must appear in the header and
open the live workspace directly. The
`?workspace=polygon-run[&run=<qualification-run-id>]` URL remains a compatible
deep-link. The live panel must show `Worker готов`. Start/stop calls require an
`Idempotency-Key`; the React client generates one per operator intent. Removing
the `internal-virtual-only` environment gate must leave status visible while
disabling lifecycle actions.
Cold PX4/Gazebo/ROS 2 startup is expected to take roughly 3060 seconds on the
reviewed worker. The lifecycle request remains bounded at the gateway and the
controller fails closed if ROS publication or armed Offboard admission is not
confirmed.
## Live-state contract
The first S1C live sample comes from:
```text
/world/rover/dynamic_pose/info
```
It is published to the browser as `missioncore.vehicle-state/v1` with:
- `frame_id=map_enu`;
- `child_frame_id=base_link_flu`;
- integer Gazebo simulation time;
- bounded position and quaternion;
- `signal=ground-truth`;
- `quality=diagnostic`;
- `scope=virtual-only`;
- no navigation or safety acceptance.
This signal proves live presentation and frame naming only. It is not the
accepted PX4/ROS 2 telemetry path.
## S1D command contract
While the run is `running`, the React controls submit:
```text
POST /api/v1/polygon/worker/runs/<run-id>/commands
Idempotency-Key: <unique operator intent>
{"speed_mps": 1.0, "steering_normalized": -0.6}
```
Mission Core validates the S1 envelope, assigns a monotonic sequence and
250 ms simulation-time TTL, and durably journals the command before worker
delivery. The worker keeps the external speed/steering contract but maps the
stock model to PX4 rover-level Offboard control:
- `RoverThrottleSetpoint.throttle_body_x = speed_mps / 3.1`;
- `RoverSteeringSetpoint.normalized_steering_setpoint` carries steering;
- `OffboardControlMode.thrust_and_torque=true`;
- `OffboardControlMode.direct_actuator=false`;
- no `ActuatorMotors` or `ActuatorServos` message is published by Mission Core.
The browser repeats a held directional intent fast enough to renew TTL. Release,
the explicit `Стоп` control, stale command TTL or run stop all drive the
setpoint to zero. The `3.1 m/s` factor is the stock PX4 model's
`RO_MAX_THR_SPEED`; S1D motion acceptance must not be described as calibrated
closed-loop speed tracking.
## Acceptance checklist
- [ ] exact source commit and source/build SHA-256 recorded;
- [ ] focused target tests pass from the same staged generation;
- [ ] worker status reports loopback-only, unprivileged and D-only boundaries;
- [ ] browser start creates one new immutable qualification run;
- [ ] status traverses `starting -> running`;
- [ ] both provider IDs appear only after readiness;
- [ ] live samples advance in Gazebo time;
- [ ] React shows run ID, sim time, ENU pose and provider list;
- [ ] archive remains path-free and command content is not exposed;
- [ ] browser stop reaches terminal `completed`;
- [ ] stop records `operator-stop-clean`;
- [ ] PX4, Gazebo and Micro XRCE-DDS leave no process residue;
- [ ] straight, turn and reverse commands produce causal pose changes;
- [ ] one isolated non-zero command expires at 250 ms and produces a stable
stopped pose;
- [ ] command acceptance reports armed Offboard and remains below TTL;
- [ ] command count and command journal sequences match submitted intents.
## Accepted S1D evidence
- code commit:
`49b0f47fea0ae29c977bbd60808696b85a9380cd`;
- source archive SHA-256:
`30aad33664a536f2a6cb87bc172a5dae111ae479d5899723832a1abc17c4517b`;
- Control Station archive SHA-256:
`014724896c8c2169a89b0ed6971b285b6e80da918d892509e9e93568efb99f25`;
- 40 focused tests passed from the exact source-first D-only generation;
- cold start: one request, 32.08 seconds;
- run: `s1c-49b0f47-20260724t195259z-62edd2`;
- straight endpoint: `(3.74, -0.00)` m from origin;
- turn endpoint: `(5.11, 2.13)` m;
- reverse endpoint: `(5.58, 0.67)` m;
- sampled command admission: 44.6 ms;
- TTL evidence: counter 4 -> 5 and identical following X/Y samples;
- explicit stop: no PX4, Gazebo or Micro XRCE-DDS residue.
## Failure handling
- A missing socket returns unavailable status; it does not fall back to PX4.
- Start/stop is `403` without the explicit backend control gate.
- Commit drift is rejected before provider start.
- A malformed or oversized worker message fails the gateway contract.
- Provider readiness failure seals the run as failed and triggers supervised
cleanup.
- Agent termination aborts an active run and stops owned provider groups.
- Agent restart reconciles an interrupted journal as failed; restart-safe
cross-process PID ownership remains a separate open checker.

View File

@ -30,6 +30,7 @@ missioncore-plugin-sdk = { path = "packages/plugin-sdk", editable = true }
[project.scripts]
k1link = "k1link.device_plugins.xgrids_k1.cli:app"
missioncore-sim = "k1link.simulation.cli:app"
[dependency-groups]
dev = [

View File

@ -0,0 +1,493 @@
#!/usr/bin/env python3
"""Build a confined, digest-bound SIM S0 evidence pack from accepted worker runs."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import platform
import re
import shutil
import subprocess
from pathlib import Path
from typing import Any
import yaml
EVIDENCE_SCHEMA = "missioncore.simulation-s0-evidence/v1"
class EvidenceError(RuntimeError):
"""Evidence inputs are incomplete, inconsistent, or outside the reviewed boundary."""
def _run(command: list[str]) -> str:
result = subprocess.run(
command,
check=False,
capture_output=True,
text=True,
timeout=15,
)
if result.returncode != 0:
raise EvidenceError(
f"command failed ({result.returncode}): {' '.join(command)}; "
f"stderr={result.stderr.strip()!r}"
)
return result.stdout.strip()
def _load_json(path: Path) -> dict[str, Any]:
loaded = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(loaded, dict):
raise EvidenceError(f"expected a JSON object: {path}")
return loaded
def _load_yaml(path: Path) -> dict[str, Any]:
documents = [
document
for document in yaml.safe_load_all(path.read_text(encoding="utf-8"))
if document is not None
]
if len(documents) != 1 or not isinstance(documents[0], dict):
raise EvidenceError(f"expected a YAML mapping: {path}")
return documents[0]
def _result_env(path: Path) -> dict[str, str]:
result: dict[str, str] = {}
for line in path.read_text(encoding="utf-8").splitlines():
key, separator, value = line.partition("=")
if not separator or not key:
raise EvidenceError(f"invalid result line in {path}: {line!r}")
result[key] = value
required_passes = {
"px4_stock_rover",
"uxrce_dds",
"ros2_vehicle_status",
"gazebo_clock",
"pause_step_speed",
"resource_baseline",
"clean_lifecycle",
}
failed = sorted(key for key in required_passes if result.get(key) != "pass")
if failed:
raise EvidenceError(f"run did not pass required checks: {', '.join(failed)}")
if result.get("actuator_authority") != "false":
raise EvidenceError("run widened actuator authority")
return result
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _write_json(path: Path, payload: dict[str, Any]) -> None:
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def _package_version(package: str) -> str:
return _run(["dpkg-query", "-W", "-f=${Version}", package])
def _git_commit(path: Path) -> str:
commit = _run(
[
"git",
"-c",
f"safe.directory={path}",
"-C",
str(path),
"rev-parse",
"HEAD",
]
)
if re.fullmatch(r"[a-f0-9]{40}", commit) is None:
raise EvidenceError(f"invalid Git commit at {path}: {commit!r}")
return commit
def _memory_inventory() -> dict[str, int]:
values: dict[str, int] = {}
for line in Path("/proc/meminfo").read_text(encoding="utf-8").splitlines():
key, raw = line.split(":", maxsplit=1)
values[key] = int(raw.strip().split()[0]) * 1024
return {
"total_bytes": values["MemTotal"],
"available_bytes": values["MemAvailable"],
"swap_total_bytes": values["SwapTotal"],
"swap_free_bytes": values["SwapFree"],
}
def _gpu_inventory() -> dict[str, str | int] | None:
if shutil.which("nvidia-smi") is None:
return None
output = _run(
[
"nvidia-smi",
"--query-gpu=name,driver_version,memory.total",
"--format=csv,noheader,nounits",
]
)
name, driver, memory_mib = (part.strip() for part in output.splitlines()[0].split(","))
return {
"name": name,
"driver": driver,
"memory_total_mib": int(memory_mib),
}
def _cpu_model() -> str:
for line in Path("/proc/cpuinfo").read_text(encoding="utf-8").splitlines():
if line.startswith("model name"):
return line.split(":", maxsplit=1)[1].strip()
raise EvidenceError("CPU model was not available")
def _component_inventory(profile: dict[str, Any], source_root: Path) -> dict[str, Any]:
components = profile.get("components")
if not isinstance(components, list) or not components:
raise EvidenceError("profile components are missing")
if any(
not isinstance(component, dict) or component.get("qualification_state") != "accepted"
for component in components
):
raise EvidenceError("all profile components must be accepted before evidence admission")
packages = {
"ros-jazzy-ros-base": _package_version("ros-jazzy-ros-base"),
"ros-jazzy-ros-gz": _package_version("ros-jazzy-ros-gz"),
"gz-harmonic": _package_version("gz-harmonic"),
"gz-sim8-cli": _package_version("gz-sim8-cli"),
"libsdformat14": _package_version("libsdformat14"),
"ros-jazzy-navigation2": _package_version("ros-jazzy-navigation2"),
"ros-jazzy-nav2-bringup": _package_version("ros-jazzy-nav2-bringup"),
}
commits = {
"px4-autopilot": _git_commit(source_root / "PX4-Autopilot"),
"px4-msgs": _git_commit(source_root / "px4_msgs"),
"px4-gazebo-models": _git_commit(
source_root / "PX4-Autopilot" / "Tools" / "simulation" / "gz"
),
"micro-xrce-dds-agent": _git_commit(source_root / "Micro-XRCE-DDS-Agent"),
}
declared = {
str(component["id"]): {
"resolved_version": component.get("resolved_version"),
"resolved_commit": component.get("resolved_commit"),
"license": component.get("license"),
"qualification_state": component.get("qualification_state"),
}
for component in components
}
for identifier, commit in commits.items():
if declared[identifier]["resolved_commit"] != commit:
raise EvidenceError(f"worker commit drift for {identifier}")
nav2_prefix = _run(["ros2", "pkg", "prefix", "nav2_bringup"])
px4_msgs_prefix = _run(["ros2", "pkg", "prefix", "px4_msgs"])
nav2_executables = _run(["ros2", "pkg", "executables", "nav2_controller"]).splitlines()
if not nav2_executables:
raise EvidenceError("Nav2 controller executable is unavailable")
return {
"declared": declared,
"installed_packages": packages,
"observed_commits": commits,
"runtime_discovery": {
"nav2_bringup_prefix": nav2_prefix,
"nav2_controller_executables": nav2_executables,
"px4_msgs_prefix": px4_msgs_prefix,
},
}
def _world_model_from_log(path: Path) -> dict[str, str]:
text = path.read_text(encoding="utf-8", errors="replace")
world_model = re.search(r"world:\s*([^,\s]+),\s*model:\s*([^\s]+)", text)
commit = re.search(r"PX4 git-hash:\s*([a-f0-9]{40})", text)
version = re.search(r"PX4 version:\s*(.+)", text)
if world_model is None or commit is None or version is None:
raise EvidenceError(f"PX4 identity is incomplete in {path}")
return {
"world": world_model.group(1),
"model": world_model.group(2),
"px4_commit": commit.group(1),
"px4_version": version.group(1).strip(),
}
def _ensure_confined(path: Path, root: Path, *, label: str) -> Path:
resolved = path.expanduser().resolve(strict=True)
if not resolved.is_relative_to(root):
raise EvidenceError(f"{label} escaped reviewed root {root}: {resolved}")
return resolved
def _arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--profile", type=Path, required=True)
parser.add_argument("--run-1x", type=Path, required=True)
parser.add_argument("--run-2x", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--d-root", type=Path, required=True)
parser.add_argument("--source-root", type=Path, required=True)
parser.add_argument("--repository-commit", required=True)
parser.add_argument("--wsl-windows-path", required=True)
return parser.parse_args()
def main() -> int:
args = _arguments()
d_root = args.d_root.expanduser().resolve(strict=True)
profile_path = _ensure_confined(args.profile, d_root, label="profile")
run_1x = _ensure_confined(args.run_1x, d_root, label="1x run")
run_2x = _ensure_confined(args.run_2x, d_root, label="2x run")
source_root = _ensure_confined(args.source_root, d_root, label="source root")
output_dir = args.output_dir.expanduser().resolve(strict=False)
if not output_dir.is_relative_to(d_root):
raise EvidenceError("evidence output escaped the reviewed D-only root")
if output_dir.exists():
raise EvidenceError(f"evidence output already exists: {output_dir}")
if re.fullmatch(r"[a-f0-9]{40}", args.repository_commit) is None:
raise EvidenceError("repository commit must be a full Git SHA")
profile = _load_yaml(profile_path)
profile_sha256 = _sha256(profile_path)
result_1x = _result_env(run_1x / "result.env")
result_2x = _result_env(run_2x / "result.env")
if result_1x.get("speed_factor") != "1" or result_2x.get("speed_factor") != "2":
raise EvidenceError("run speed factors are not the declared 1x/2x pair")
time_1x = _load_json(run_1x / "time-control.json")
time_2x = _load_json(run_2x / "time-control.json")
resource_1x = _load_json(run_1x / "resource-baseline.json")
resource_2x = _load_json(run_2x / "resource-baseline.json")
if any(
report.get("verdict") != "pass" for report in (time_1x, time_2x, resource_1x, resource_2x)
):
raise EvidenceError("time or resource report is not a pass")
output_dir.mkdir(parents=True)
disk = shutil.disk_usage(d_root)
target_host = profile["target_host"]
worker_inventory = {
"redacted": True,
"repository_commit": args.repository_commit,
"profile_sha256": profile_sha256,
"target": {
"system": platform.system(),
"kernel_release": platform.release(),
"architecture": platform.machine(),
"wsl_distribution": os.environ.get("WSL_DISTRO_NAME"),
"linux_pretty_name": _run(
["bash", "-lc", ". /etc/os-release && printf '%s' \"$PRETTY_NAME\""]
),
},
"cpu": {
"model": _cpu_model(),
"logical_processors": os.cpu_count(),
},
"memory": _memory_inventory(),
"gpu": _gpu_inventory(),
"disk": {
"total_bytes": disk.total,
"free_bytes": disk.free,
},
"components": _component_inventory(profile, source_root),
}
if worker_inventory["target"]["wsl_distribution"] != target_host["wsl_distribution"]:
raise EvidenceError("worker distribution does not match the profile")
mutable_paths = profile["storage"]["mutable_paths"]
placement: list[dict[str, Any]] = []
for item in mutable_paths:
path = Path(str(item["wsl_path"])).resolve(strict=True)
if not path.is_relative_to(d_root):
raise EvidenceError(f"mutable path escaped D-only root: {path}")
placement.append(
{
"id": item["id"],
"windows_path": item["windows_path"],
"wsl_path": str(path),
"exists": path.is_dir(),
}
)
d_only = {
"redacted": True,
"profile_sha256": profile_sha256,
"wsl_distribution_windows_path": args.wsl_windows_path,
"wsl_mount_root": str(d_root),
"container_runtime": target_host["container_runtime"],
"existing_docker_desktop_used_or_modified": False,
"mutable_paths": placement,
"disk": {
"total_bytes": disk.total,
"free_bytes": disk.free,
"minimum_free_gib": profile["storage"]["minimum_free_gib"],
"stop_below_gib": profile["storage"]["stop_below_gib"],
},
}
px4_launch = {
"repository_commit": args.repository_commit,
"runs": [
{
"run_id": result_1x["run_id"],
"speed_factor": 1,
"identity": _world_model_from_log(run_1x / "px4-rover.log"),
"result": result_1x,
},
{
"run_id": result_2x["run_id"],
"speed_factor": 2,
"identity": _world_model_from_log(run_2x / "px4-rover.log"),
"result": result_2x,
},
],
}
telemetry = {
"runs": [
{
"run_id": result["run_id"],
"vehicle_status": _load_yaml(run / "vehicle-status.yaml"),
"vehicle_status_topic_present": (
"/fmu/out/vehicle_status_v1"
in (run / "ros-topics.txt").read_text(encoding="utf-8")
),
}
for result, run in ((result_1x, run_1x), (result_2x, run_2x))
]
}
authoritative_clock = {
"authority": profile["clock"],
"runs": [
{
"run_id": result["run_id"],
"clock_sample": _load_yaml(run / "clock.yaml"),
"pause_advance_ns": report["pause"]["observed_advance_ns"],
}
for result, run, report in (
(result_1x, run_1x, time_1x),
(result_2x, run_2x, time_2x),
)
],
}
pause_step_speed = {
"contract": profile["runtime_acceptance"],
"runs": [
{
"run_id": result_1x["run_id"],
"speed_factor": 1,
"pause": time_1x["pause"],
"single_step": time_1x["single_step"],
"speed": time_1x["speed"],
"verdict": time_1x["verdict"],
},
{
"run_id": result_2x["run_id"],
"speed_factor": 2,
"pause": time_2x["pause"],
"single_step": time_2x["single_step"],
"speed": time_2x["speed"],
"verdict": time_2x["verdict"],
},
],
}
clean_stop = {
"runs": [
{
"run_id": result_1x["run_id"],
"clean_lifecycle": result_1x["clean_lifecycle"],
},
{
"run_id": result_2x["run_id"],
"clean_lifecycle": result_2x["clean_lifecycle"],
},
],
"owned_process_residue": False,
}
artifacts: dict[str, tuple[str, dict[str, Any]]] = {
"worker-inventory": (
"worker-inventory.redacted.json",
worker_inventory,
),
"d-only-physical-placement": (
"d-only-physical-placement.redacted.json",
d_only,
),
"px4-stock-rover-launch": (
"px4-stock-rover-launch.redacted.json",
px4_launch,
),
"ros2-telemetry": (
"ros2-telemetry.redacted.json",
telemetry,
),
"authoritative-simulation-clock": (
"authoritative-simulation-clock.redacted.json",
authoritative_clock,
),
"pause-step-speed": (
"pause-step-speed.redacted.json",
pause_step_speed,
),
"clean-stop-no-orphans": (
"clean-stop-no-orphans.redacted.json",
clean_stop,
),
"resource-baseline-1x": (
"resource-baseline-1x.redacted.json",
resource_1x,
),
"resource-baseline-2x": (
"resource-baseline-2x.redacted.json",
resource_2x,
),
}
required = profile.get("required_evidence")
if not isinstance(required, list) or set(required) != set(artifacts):
raise EvidenceError("profile required_evidence does not match the generator")
manifest_checks: list[dict[str, str]] = []
for identifier in required:
filename, payload = artifacts[str(identifier)]
artifact_path = output_dir / filename
_write_json(artifact_path, payload)
manifest_checks.append(
{
"id": str(identifier),
"status": "pass",
"artifact": filename,
"sha256": _sha256(artifact_path),
"note": f"Redacted factual worker evidence for {identifier}.",
}
)
manifest = {
"schema_version": EVIDENCE_SCHEMA,
"profile_sha256": profile_sha256,
"checks": manifest_checks,
}
manifest_path = output_dir / "evidence.yaml"
manifest_path.write_text(
yaml.safe_dump(manifest, sort_keys=False),
encoding="utf-8",
)
print(
json.dumps(
{
"evidence_manifest": str(manifest_path),
"profile_sha256": profile_sha256,
"checks": len(manifest_checks),
},
sort_keys=True,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,487 @@
#!/usr/bin/env python3
"""Qualify Gazebo pause, single-step, speed and resource behavior on SIM S0."""
from __future__ import annotations
import argparse
import json
import re
import shutil
import subprocess
import threading
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
import yaml
TIME_SCHEMA = "missioncore.simulation-time-control/v1"
RESOURCE_SCHEMA = "missioncore.simulation-resource-baseline/v1"
MIB = 1024**2
GIB = 1024**3
class ProbeError(RuntimeError):
"""A runtime observation could not satisfy the reviewed S0 contract."""
@dataclass(frozen=True, slots=True)
class WorldStats:
sim_time_ns: int
real_time_ns: int
iterations: int
paused: bool
real_time_factor: float
step_size_ns: int
def _run(command: list[str], *, timeout: float) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
command,
check=False,
capture_output=True,
text=True,
timeout=timeout,
)
if result.returncode != 0:
raise ProbeError(
f"command failed ({result.returncode}): {' '.join(command)}; "
f"stderr={result.stderr.strip()!r}"
)
return result
def _time_field(message: str, field: str) -> int:
match = re.search(rf"\b{re.escape(field)}\s*\{{(?P<body>.*?)\}}", message, re.DOTALL)
if match is None:
raise ProbeError(f"Gazebo stats omitted {field}")
body = match.group("body")
seconds = re.search(r"\bsec:\s*(-?\d+)", body)
nanoseconds = re.search(r"\bnsec:\s*(-?\d+)", body)
sec_value = int(seconds.group(1)) if seconds is not None else 0
nsec_value = int(nanoseconds.group(1)) if nanoseconds is not None else 0
return sec_value * 1_000_000_000 + nsec_value
def _scalar_field(
message: str,
field: str,
converter: type[int] | type[float],
*,
default: int | float | None = None,
) -> int | float:
match = re.search(rf"\b{re.escape(field)}:\s*([^\s]+)", message)
if match is None:
if default is None:
raise ProbeError(f"Gazebo stats omitted {field}")
return default
return converter(match.group(1))
def _stats_snapshot() -> WorldStats:
result = _run(
["gz", "topic", "--echo", "--topic", "/stats", "-n", "1"],
timeout=8,
)
paused_match = re.search(r"\bpaused:\s*(true|false)", result.stdout)
paused = paused_match is not None and paused_match.group(1) == "true"
return WorldStats(
sim_time_ns=_time_field(result.stdout, "sim_time"),
real_time_ns=_time_field(result.stdout, "real_time"),
iterations=int(_scalar_field(result.stdout, "iterations", int)),
paused=paused,
real_time_factor=float(
_scalar_field(result.stdout, "real_time_factor", float, default=0.0)
),
step_size_ns=_time_field(result.stdout, "step_size"),
)
def _control(world: str, request: str) -> str:
result = _run(
[
"gz",
"service",
"-s",
f"/world/{world}/control",
"--reqtype",
"gz.msgs.WorldControl",
"--reptype",
"gz.msgs.Boolean",
"--timeout",
"5000",
"--req",
request,
],
timeout=8,
)
if "data: true" not in result.stdout:
raise ProbeError(
f"Gazebo rejected world control request {request!r}: {result.stdout.strip()!r}"
)
return result.stdout.strip()
def _wait_for_pause_state(expected: bool, *, timeout: float = 6) -> WorldStats:
deadline = time.monotonic() + timeout
observed: WorldStats | None = None
while time.monotonic() < deadline:
observed = _stats_snapshot()
if observed.paused is expected:
return observed
raise ProbeError(f"Gazebo pause state did not become {expected}; last={observed!r}")
def _wait_for_iteration(minimum: int, *, timeout: float = 6) -> WorldStats:
deadline = time.monotonic() + timeout
observed: WorldStats | None = None
while time.monotonic() < deadline:
observed = _stats_snapshot()
if observed.iterations >= minimum:
return observed
raise ProbeError(f"Gazebo iteration did not reach {minimum}; last={observed!r}")
def _meminfo() -> dict[str, int]:
values: dict[str, int] = {}
for line in Path("/proc/meminfo").read_text(encoding="utf-8").splitlines():
key, raw = line.split(":", maxsplit=1)
amount = int(raw.strip().split()[0])
values[key] = amount * 1024
return values
def _owned_processes(
*,
pids: set[int],
process_groups: set[int],
) -> list[dict[str, int | float | str]]:
result = _run(
[
"ps",
"-eo",
"pid=,ppid=,pgid=,rss=,pcpu=,comm=",
],
timeout=5,
)
processes: list[dict[str, int | float | str]] = []
for line in result.stdout.splitlines():
fields = line.split(maxsplit=5)
if len(fields) != 6:
continue
pid, ppid, pgid, rss_kib, cpu_percent, command = fields
pid_value = int(pid)
pgid_value = int(pgid)
if pid_value not in pids and pgid_value not in process_groups:
continue
processes.append(
{
"pid": pid_value,
"ppid": int(ppid),
"pgid": pgid_value,
"rss_bytes": int(rss_kib) * 1024,
"cpu_percent": float(cpu_percent),
"command": command,
}
)
return processes
def _gpu_sample() -> dict[str, int] | None:
if shutil.which("nvidia-smi") is None:
return None
result = _run(
[
"nvidia-smi",
"--query-gpu=memory.used,utilization.gpu",
"--format=csv,noheader,nounits",
],
timeout=5,
)
first_line = result.stdout.strip().splitlines()[0]
memory_mib, utilization_percent = (int(value.strip()) for value in first_line.split(","))
return {
"global_memory_used_mib": memory_mib,
"global_utilization_percent": utilization_percent,
}
class ResourceSampler:
def __init__(
self,
*,
owned_pids: set[int],
owned_process_groups: set[int],
storage_root: Path,
interval_seconds: float,
) -> None:
self._owned_pids = owned_pids
self._owned_process_groups = owned_process_groups
self._storage_root = storage_root
self._interval_seconds = interval_seconds
self._stop = threading.Event()
self._thread = threading.Thread(target=self._run, name="s0-resource-sampler")
self.samples: list[dict[str, Any]] = []
self.error: str | None = None
def start(self) -> None:
self._thread.start()
def stop(self) -> None:
self._stop.set()
self._thread.join(timeout=max(5.0, self._interval_seconds * 4))
if self._thread.is_alive():
raise ProbeError("resource sampler did not stop")
if self.error is not None:
raise ProbeError(f"resource sampler failed: {self.error}")
def _run(self) -> None:
try:
while True:
processes = _owned_processes(
pids=self._owned_pids,
process_groups=self._owned_process_groups,
)
memory = _meminfo()
disk = shutil.disk_usage(self._storage_root)
self.samples.append(
{
"monotonic_ns": time.monotonic_ns(),
"owned_rss_bytes": sum(int(process["rss_bytes"]) for process in processes),
"owned_cpu_percent": sum(
float(process["cpu_percent"]) for process in processes
),
"memory_available_bytes": memory["MemAvailable"],
"swap_used_bytes": memory["SwapTotal"] - memory["SwapFree"],
"disk_free_bytes": disk.free,
"gpu": _gpu_sample(),
"processes": processes,
}
)
if self._stop.wait(self._interval_seconds):
break
except Exception as error: # noqa: BLE001 - preserve factual sampler failure
self.error = f"{type(error).__name__}: {error}"
def _load_contract(profile_path: Path, speed_factor: int) -> dict[str, int | str]:
profile = yaml.safe_load(profile_path.read_text(encoding="utf-8"))
if not isinstance(profile, dict):
raise ProbeError("qualification profile must be a mapping")
runtime = profile.get("runtime_acceptance")
storage = profile.get("storage")
if not isinstance(runtime, dict) or not isinstance(storage, dict):
raise ProbeError("qualification profile omitted runtime_acceptance or storage")
factors = runtime.get("speed_factors")
if not isinstance(factors, list) or speed_factor not in factors:
raise ProbeError(f"speed factor {speed_factor} is not declared by the profile")
return {
"world": str(runtime["world"]),
"model": str(runtime["model"]),
"physics_step_ns": int(runtime["physics_step_ns"]),
"pause_max_advance_ns": int(runtime["pause_max_advance_ns"]),
"rtf_tolerance_percent": int(runtime["rtf_tolerance_percent"]),
"measurement_window_seconds": int(runtime["measurement_window_seconds"]),
"resource_sample_interval_milliseconds": int(
runtime["resource_sample_interval_milliseconds"]
),
"max_owned_rss_mib": int(runtime["max_owned_rss_mib"]),
"max_owned_cpu_percent": int(runtime["max_owned_cpu_percent"]),
"minimum_available_memory_gib": int(runtime["minimum_available_memory_gib"]),
"stop_below_gib": int(storage["stop_below_gib"]),
}
def _resource_report(
*,
samples: list[dict[str, Any]],
contract: dict[str, int | str],
speed_factor: int,
) -> dict[str, Any]:
if not samples:
raise ProbeError("resource sampler produced no samples")
peak_rss = max(int(sample["owned_rss_bytes"]) for sample in samples)
peak_cpu = max(float(sample["owned_cpu_percent"]) for sample in samples)
minimum_memory = min(int(sample["memory_available_bytes"]) for sample in samples)
minimum_disk = min(int(sample["disk_free_bytes"]) for sample in samples)
maximum_swap = max(int(sample["swap_used_bytes"]) for sample in samples)
gpu_samples = [sample["gpu"] for sample in samples if sample["gpu"] is not None]
checks = {
"sample_count": len(samples) >= 6,
"owned_rss": peak_rss <= int(contract["max_owned_rss_mib"]) * MIB,
"owned_cpu": peak_cpu <= int(contract["max_owned_cpu_percent"]),
"available_memory": (minimum_memory >= int(contract["minimum_available_memory_gib"]) * GIB),
"disk_floor": minimum_disk >= int(contract["stop_below_gib"]) * GIB,
}
summary: dict[str, Any] = {
"sample_count": len(samples),
"peak_owned_rss_bytes": peak_rss,
"peak_owned_cpu_percent": peak_cpu,
"minimum_memory_available_bytes": minimum_memory,
"minimum_disk_free_bytes": minimum_disk,
"maximum_swap_used_bytes": maximum_swap,
"gpu_metrics_are_global_and_include_co_tenants": True,
}
if gpu_samples:
summary["peak_global_gpu_memory_used_mib"] = max(
int(sample["global_memory_used_mib"]) for sample in gpu_samples
)
summary["peak_global_gpu_utilization_percent"] = max(
int(sample["global_utilization_percent"]) for sample in gpu_samples
)
return {
"schema_version": RESOURCE_SCHEMA,
"speed_factor": speed_factor,
"actuator_authority": False,
"checks": checks,
"summary": summary,
"samples": samples,
"verdict": "pass" if all(checks.values()) else "fail",
}
def _write_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def _arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--profile", type=Path, required=True)
parser.add_argument("--speed-factor", type=int, required=True)
parser.add_argument("--time-output", type=Path, required=True)
parser.add_argument("--resource-output", type=Path, required=True)
parser.add_argument("--storage-root", type=Path, required=True)
parser.add_argument("--owned-pid", action="append", type=int, default=[])
parser.add_argument("--owned-pgid", action="append", type=int, default=[])
return parser.parse_args()
def main() -> int:
args = _arguments()
contract = _load_contract(args.profile, args.speed_factor)
sampler = ResourceSampler(
owned_pids=set(args.owned_pid),
owned_process_groups=set(args.owned_pgid),
storage_root=args.storage_root,
interval_seconds=int(contract["resource_sample_interval_milliseconds"]) / 1000,
)
time_report: dict[str, Any] = {
"schema_version": TIME_SCHEMA,
"speed_factor": args.speed_factor,
"world": contract["world"],
"model": contract["model"],
"actuator_authority": False,
"contract": contract,
}
exit_code = 1
sampler.start()
try:
world = str(contract["world"])
_control(world, "pause: true")
paused_start = _wait_for_pause_state(True)
time.sleep(1.0)
paused_end = _stats_snapshot()
pause_delta = paused_end.sim_time_ns - paused_start.sim_time_ns
pause_pass = paused_end.paused and 0 <= pause_delta <= int(contract["pause_max_advance_ns"])
step_before = paused_end
_control(world, "pause: true, multi_step: 1")
step_after = _wait_for_iteration(step_before.iterations + 1)
step_delta = step_after.sim_time_ns - step_before.sim_time_ns
iteration_delta = step_after.iterations - step_before.iterations
step_pass = (
step_after.paused
and step_delta == int(contract["physics_step_ns"])
and iteration_delta == 1
)
_control(world, "pause: false")
_wait_for_pause_state(False)
time.sleep(0.75)
speed_start = _stats_snapshot()
time.sleep(int(contract["measurement_window_seconds"]))
speed_end = _stats_snapshot()
sim_delta = speed_end.sim_time_ns - speed_start.sim_time_ns
real_delta = speed_end.real_time_ns - speed_start.real_time_ns
if real_delta <= 0:
raise ProbeError("Gazebo real-time delta was not positive")
measured_rtf = sim_delta / real_delta
tolerance = int(contract["rtf_tolerance_percent"]) / 100
lower = args.speed_factor * (1 - tolerance)
upper = args.speed_factor * (1 + tolerance)
speed_pass = lower <= measured_rtf <= upper
time_report.update(
{
"pause": {
"before": asdict(paused_start),
"after": asdict(paused_end),
"observed_advance_ns": pause_delta,
"pass": pause_pass,
},
"single_step": {
"before": asdict(step_before),
"after": asdict(step_after),
"observed_advance_ns": step_delta,
"observed_iteration_delta": iteration_delta,
"pass": step_pass,
},
"speed": {
"before": asdict(speed_start),
"after": asdict(speed_end),
"simulation_advance_ns": sim_delta,
"real_advance_ns": real_delta,
"measured_rtf": measured_rtf,
"accepted_range": [lower, upper],
"pass": speed_pass,
},
}
)
time_report["verdict"] = "pass" if pause_pass and step_pass and speed_pass else "fail"
except Exception as error: # noqa: BLE001 - persist the factual probe failure
time_report["verdict"] = "fail"
time_report["error"] = f"{type(error).__name__}: {error}"
finally:
try:
_control(str(contract["world"]), "pause: false")
except Exception as resume_error: # noqa: BLE001 - report cleanup failure
time_report["resume_error"] = f"{type(resume_error).__name__}: {resume_error}"
time_report["verdict"] = "fail"
try:
sampler.stop()
resource_report = _resource_report(
samples=sampler.samples,
contract=contract,
speed_factor=args.speed_factor,
)
except Exception as resource_error: # noqa: BLE001
resource_report = {
"schema_version": RESOURCE_SCHEMA,
"speed_factor": args.speed_factor,
"actuator_authority": False,
"verdict": "fail",
"error": f"{type(resource_error).__name__}: {resource_error}",
"samples": sampler.samples,
}
_write_json(args.time_output, time_report)
_write_json(args.resource_output, resource_report)
if time_report["verdict"] == "pass" and resource_report["verdict"] == "pass":
exit_code = 0
print(
json.dumps(
{
"time_verdict": time_report["verdict"],
"resource_verdict": resource_report["verdict"],
"speed_factor": args.speed_factor,
},
sort_keys=True,
)
)
return exit_code
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,198 @@
schema_version: missioncore.simulation-s0-profile/v1
profile_id: ai-worker-px4-gazebo-v1
target_host:
windows_release: windows-11
virtualization: wsl2
wsl_distribution: MissionCore-Sim
linux_distribution: ubuntu-24.04
architecture: x86_64
container_runtime: none
storage:
windows_root: 'D:\NDC_MISSIONCORE'
wsl_root: /mnt/d/NDC_MISSIONCORE
minimum_free_gib: 300
stop_below_gib: 200
forbid_windows_system_drive: true
mutable_paths:
- id: wsl
windows_path: 'D:\NDC_MISSIONCORE\simulation\wsl'
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/wsl
- id: source
windows_path: 'D:\NDC_MISSIONCORE\simulation\source'
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/source
- id: build
windows_path: 'D:\NDC_MISSIONCORE\simulation\build'
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/build
- id: cache
windows_path: 'D:\NDC_MISSIONCORE\simulation\cache'
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/cache
- id: runtime
windows_path: 'D:\NDC_MISSIONCORE\simulation\runtime'
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/runtime
- id: artifacts
windows_path: 'D:\NDC_MISSIONCORE\simulation\artifacts'
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/artifacts
- id: datasets
windows_path: 'D:\NDC_MISSIONCORE\simulation\datasets'
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/datasets
clock:
authority: gazebo:/clock
ros_use_sim_time: true
px4_uxrce_dds_sync_enabled: false
canonical_unit: nanoseconds
utc_role: provenance-only
authority:
simulation_or_shadow_only: true
actuator_authority: false
navigation_or_safety_accepted: false
direct_actuator_setpoints_allowed: false
allowed_setpoint_profiles:
- rover-speed-steering/v1
- rover-speed-yaw-rate/v1
ros:
domain_id: 0
namespace: /missioncore/polygon/s0
runtime_acceptance:
world: rover
model: rover_ackermann_0
physics_step_ns: 2000000
pause_max_advance_ns: 0
speed_factors:
- 1
- 2
rtf_tolerance_percent: 20
measurement_window_seconds: 4
resource_sample_interval_milliseconds: 500
max_owned_rss_mib: 2048
max_owned_cpu_percent: 800
minimum_available_memory_gib: 8
components:
- id: ros2
source_url: https://docs.ros.org/en/jazzy/
requested_ref: jazzy
resolved_version: ros-base=0.11.0-1noble.20260616.084325;ros-gz=1.0.22-1noble.20260616.074726
resolved_commit:
license: mixed-apache-2.0-bsd
qualification_state: accepted
- id: gazebo
source_url: https://gazebosim.org/docs/harmonic/
requested_ref: harmonic
resolved_version: gz-harmonic=1.0.0-1~noble;gz-sim=8.14.0-1~noble;sdformat=14.9.0-1~noble
resolved_commit:
license: apache-2.0
qualification_state: accepted
- id: px4-autopilot
source_url: https://github.com/PX4/PX4-Autopilot
requested_ref: v1.17.0
resolved_version: v1.17.0
resolved_commit: d6f12ad1c4f70ad3230afd7d86e971421e02fef4
license: bsd-3-clause
qualification_state: accepted
- id: px4-msgs
source_url: https://github.com/PX4/px4_msgs
requested_ref: v1.17.0
resolved_version: v1.17.0
resolved_commit: 86d8239e962f6939e05c3737784f60c02fa884db
license: bsd-3-clause
qualification_state: accepted
- id: px4-gazebo-models
source_url: https://github.com/PX4/PX4-gazebo-models
requested_ref: PX4-Autopilot/v1.17.0-submodule
resolved_version: px4-v1.17.0-submodule
resolved_commit: b6127f4ec20de867e215fb5f78ae88b80f371909
license: bsd-3-clause
qualification_state: accepted
- id: micro-xrce-dds-agent
source_url: https://github.com/eProsima/Micro-XRCE-DDS-Agent
requested_ref: v2.4.3
resolved_version: v2.4.3
resolved_commit: 73622810d984349b80bbac0ef55fc0b694d62222
license: apache-2.0
qualification_state: accepted
- id: nav2
source_url: https://github.com/ros-navigation/navigation2
requested_ref: jazzy@2026-07-24
resolved_version: navigation2=1.3.12-1noble.20260615.181551;nav2-bringup=1.3.12-1noble.20260616.082701
resolved_commit: c92ea2fd9008f50c0ec8447610800214b2a0dafb
license: apache-2.0
qualification_state: accepted
ports:
- id: micro-xrce-dds
host_scope: loopback-only-netns
protocol: udp
bind: 0.0.0.0
port: 8888
owner: micro-xrce-dds-agent
- id: px4-mavlink-onboard
host_scope: loopback-only-netns
protocol: udp
bind: 0.0.0.0
port: 14580
owner: px4-sitl
- id: px4-mavlink-normal
host_scope: loopback-only-netns
protocol: udp
bind: 0.0.0.0
port: 18570
owner: px4-sitl
- id: simulation-orchestrator-api
host_scope: loopback-only-netns
protocol: tcp
bind: 127.0.0.1
port: 18080
owner: simulation-orchestrator
- id: simulation-telemetry
host_scope: loopback-only-netns
protocol: tcp
bind: 127.0.0.1
port: 18081
owner: simulation-orchestrator
processes:
- id: simulation-orchestrator
parent:
shutdown_order: 60
- id: gazebo-server
parent: simulation-orchestrator
shutdown_order: 50
- id: px4-sitl
parent: simulation-orchestrator
shutdown_order: 40
- id: micro-xrce-dds-agent
parent: simulation-orchestrator
shutdown_order: 30
- id: ros2-bridge
parent: simulation-orchestrator
shutdown_order: 20
- id: nav2
parent: simulation-orchestrator
shutdown_order: 10
required_tools:
- git
- gz
- make
- cmake
- ninja
- colcon
- ros2
- MicroXRCEAgent
required_evidence:
- worker-inventory
- d-only-physical-placement
- px4-stock-rover-launch
- ros2-telemetry
- authoritative-simulation-clock
- pause-step-speed
- clean-stop-no-orphans
- resource-baseline-1x
- resource-baseline-2x

296
simulation/s0/worker-smoke.sh Executable file
View File

@ -0,0 +1,296 @@
#!/usr/bin/env bash
#
# Run the SIM S0 stock-rover smoke case inside a loopback-only network
# namespace. The script writes factual runtime artifacts to D and never grants
# actuator authority.
set -Eeuo pipefail
readonly EXPECTED_DISTRO="MissionCore-Sim"
readonly D_ROOT="${MISSIONCORE_SIM_D_ROOT:-/mnt/d/NDC_MISSIONCORE/simulation}"
readonly PX4_ROOT="${MISSIONCORE_SIM_PX4_ROOT:-${D_ROOT}/source/PX4-Autopilot}"
readonly AGENT_PREFIX="${MISSIONCORE_SIM_AGENT_PREFIX:-${D_ROOT}/runtime/micro-xrce-dds-agent/2.4.3}"
readonly PX4_MSGS_SETUP="${MISSIONCORE_SIM_PX4_MSGS_SETUP:-${D_ROOT}/build/ros2/px4_msgs/install/setup.bash}"
readonly ARTIFACT_ROOT="${MISSIONCORE_SIM_ARTIFACT_ROOT:-${D_ROOT}/artifacts/s0}"
readonly PROFILE_PATH="${MISSIONCORE_SIM_PROFILE_PATH:-${D_ROOT}/source/qualification-profile.yaml}"
readonly TIME_PROBE="${MISSIONCORE_SIM_TIME_PROBE:-${D_ROOT}/source/gazebo_time_control.py}"
readonly RUN_ID="${1:-$(date -u +%Y%m%dT%H%M%SZ)}"
readonly SPEED_FACTOR="${2:-1}"
readonly RUN_DIR="${ARTIFACT_ROOT}/${RUN_ID}"
if [[ "${SPEED_FACTOR}" != "1" && "${SPEED_FACTOR}" != "2" ]]; then
echo "error: speed factor must be one of the reviewed values: 1 or 2" >&2
exit 2
fi
if [[ "${MISSIONCORE_S0_NETNS:-0}" != "1" ]]; then
if [[ "${EUID}" -ne 0 ]]; then
echo "error: initial worker-smoke invocation must run as root to create the network namespace" >&2
exit 2
fi
if [[ "${WSL_DISTRO_NAME:-}" != "${EXPECTED_DISTRO}" ]]; then
echo "error: expected WSL distro ${EXPECTED_DISTRO}, observed ${WSL_DISTRO_NAME:-unset}" >&2
exit 2
fi
exec unshare --net -- bash -c '
set -e
ip link set lo up
exec runuser -u missioncore -- env MISSIONCORE_S0_NETNS=1 "$@"
' bash "$0" "$@"
fi
if [[ "${WSL_DISTRO_NAME:-}" != "${EXPECTED_DISTRO}" ]]; then
echo "error: smoke case escaped the reviewed WSL distribution" >&2
exit 2
fi
for required_path in "${PX4_ROOT}" "${AGENT_PREFIX}" "${PX4_MSGS_SETUP}" "${ARTIFACT_ROOT}"; do
if [[ "${required_path}" != /mnt/d/NDC_MISSIONCORE/* ]]; then
echo "error: mutable or executable path escaped D-only root: ${required_path}" >&2
exit 2
fi
done
readonly AGENT_BIN="${AGENT_PREFIX}/bin/MicroXRCEAgent"
readonly AGENT_LOG="${RUN_DIR}/micro-xrce-dds-agent.log"
readonly PX4_LOG="${RUN_DIR}/px4-rover.log"
readonly BRIDGE_LOG="${RUN_DIR}/ros-gz-clock-bridge.log"
readonly TOPICS_LOG="${RUN_DIR}/ros-topics.txt"
readonly VEHICLE_STATUS_LOG="${RUN_DIR}/vehicle-status.yaml"
readonly CLOCK_LOG="${RUN_DIR}/clock.yaml"
readonly NETWORK_LOG="${RUN_DIR}/network.txt"
readonly RESOURCE_LOG="${RUN_DIR}/resource-snapshot.txt"
readonly GZ_SERVICES_LOG="${RUN_DIR}/gazebo-services.txt"
readonly GZ_TOPICS_LOG="${RUN_DIR}/gazebo-topics.txt"
readonly TIME_CONTROL_LOG="${RUN_DIR}/time-control.json"
readonly RESOURCE_BASELINE_LOG="${RUN_DIR}/resource-baseline.json"
readonly TIME_PROBE_STDOUT="${RUN_DIR}/time-probe.stdout"
readonly RESULT_LOG="${RUN_DIR}/result.env"
for required_file in \
"${PX4_ROOT}/build/px4_sitl_default/bin/px4" \
"${PX4_ROOT}/Tools/simulation/gz/worlds/rover.sdf" \
"${AGENT_BIN}" \
"/opt/ros/jazzy/setup.bash" \
"${PX4_MSGS_SETUP}" \
"${PROFILE_PATH}" \
"${TIME_PROBE}"; do
if [[ ! -e "${required_file}" ]]; then
echo "error: required S0 runtime input is missing: ${required_file}" >&2
exit 2
fi
done
mkdir -p "${RUN_DIR}"
agent_pid=""
px4_pgid=""
bridge_pgid=""
stop_process_group() {
local pgid="${1:-}"
if [[ -z "${pgid}" ]]; then
return
fi
if kill -0 -- "-${pgid}" 2>/dev/null; then
kill -INT -- "-${pgid}" 2>/dev/null || true
for _ in {1..20}; do
if ! kill -0 -- "-${pgid}" 2>/dev/null; then
return
fi
sleep 0.25
done
kill -TERM -- "-${pgid}" 2>/dev/null || true
sleep 1
kill -KILL -- "-${pgid}" 2>/dev/null || true
fi
}
cleanup() {
set +e
exec 3>&-
stop_process_group "${bridge_pgid}"
stop_process_group "${px4_pgid}"
if [[ -n "${agent_pid}" ]] && kill -0 "${agent_pid}" 2>/dev/null; then
kill -TERM "${agent_pid}" 2>/dev/null
wait "${agent_pid}" 2>/dev/null
fi
}
trap cleanup EXIT INT TERM
wait_for_pattern() {
local file="$1"
local pattern="$2"
local attempts="$3"
for ((attempt = 0; attempt < attempts; attempt += 1)); do
if grep -Fq "${pattern}" "${file}" 2>/dev/null; then
return 0
fi
sleep 0.5
done
return 1
}
env \
LD_LIBRARY_PATH="${AGENT_PREFIX}/lib" \
"${AGENT_BIN}" udp4 -p 8888 -v 4 >"${AGENT_LOG}" 2>&1 &
agent_pid="$!"
sleep 1
if ! kill -0 "${agent_pid}" 2>/dev/null; then
echo "error: Micro XRCE-DDS Agent exited during startup" >&2
exit 1
fi
{
echo "network_namespace=loopback-only"
ip -brief address
ss -lunp
} >"${NETWORK_LOG}"
coproc PX4_PROCESS {
exec setsid env HEADLESS=1 PX4_SIM_SPEED_FACTOR="${SPEED_FACTOR}" make -C "${PX4_ROOT}" \
px4_sitl gz_rover_ackermann >"${PX4_LOG}" 2>&1
}
px4_pgid="${PX4_PROCESS_PID}"
exec 3>&"${PX4_PROCESS[1]}"
if ! wait_for_pattern "${PX4_LOG}" "Startup script returned successfully" 120; then
echo "error: PX4 stock rover did not reach startup completion" >&2
exit 1
fi
sleep 3
printf '%s\n' \
"ver all" \
"commander status" >&3
if ! wait_for_pattern "${AGENT_LOG}" "session established" 60 \
|| ! wait_for_pattern \
"${PX4_LOG}" \
"successfully created rt/fmu/out/vehicle_status_v1 data writer" \
60; then
echo "error: PX4 uXRCE-DDS client did not connect to the loopback agent" >&2
exit 1
fi
printf '%s\n' "uxrce_dds_client status" >&3
if ! wait_for_pattern "${PX4_LOG}" "Running, connected" 20; then
echo "error: PX4 did not report the established uXRCE-DDS session" >&2
exit 1
fi
{
echo
echo "runtime_listeners=loopback-only-netns"
ss -lunp
} >>"${NETWORK_LOG}"
gz service -l >"${GZ_SERVICES_LOG}"
gz topic -l >"${GZ_TOPICS_LOG}"
set +u
# shellcheck source=/dev/null
source /opt/ros/jazzy/setup.bash
# shellcheck source=/dev/null
source "${PX4_MSGS_SETUP}"
set -u
export ROS_DOMAIN_ID=0
export ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST
unset ROS_LOCALHOST_ONLY
setsid ros2 run ros_gz_bridge parameter_bridge \
'/clock@rosgraph_msgs/msg/Clock[gz.msgs.Clock' >"${BRIDGE_LOG}" 2>&1 &
bridge_pgid="$!"
timeout 20s ros2 topic list -t >"${TOPICS_LOG}"
vehicle_status_topic="/fmu/out/vehicle_status"
if grep -Fq "/fmu/out/vehicle_status_v1" "${TOPICS_LOG}"; then
vehicle_status_topic="/fmu/out/vehicle_status_v1"
fi
timeout 20s ros2 topic echo --once \
"${vehicle_status_topic}" px4_msgs/msg/VehicleStatus >"${VEHICLE_STATUS_LOG}"
timeout 20s ros2 topic echo --once \
/clock rosgraph_msgs/msg/Clock >"${CLOCK_LOG}"
python3 "${TIME_PROBE}" \
--profile "${PROFILE_PATH}" \
--speed-factor "${SPEED_FACTOR}" \
--time-output "${TIME_CONTROL_LOG}" \
--resource-output "${RESOURCE_BASELINE_LOG}" \
--storage-root "${D_ROOT}" \
--owned-pid "${agent_pid}" \
--owned-pgid "${px4_pgid}" \
--owned-pgid "${bridge_pgid}" >"${TIME_PROBE_STDOUT}"
{
echo "sample=non-acceptance-point-snapshot"
free -b
ps -g "${px4_pgid}" -o pid,ppid,pgid,rss,vsz,pcpu,comm,args
ps -p "${agent_pid}" -o pid,ppid,pgid,rss,vsz,pcpu,comm,args
ps -g "${bridge_pgid}" -o pid,ppid,pgid,rss,vsz,pcpu,comm,args
if command -v nvidia-smi >/dev/null; then
nvidia-smi \
--query-gpu=memory.used,utilization.gpu \
--format=csv,noheader,nounits
fi
} >"${RESOURCE_LOG}"
printf '%s\n' "shutdown" >&3
if ! wait_for_pattern "${PX4_LOG}" "Exiting NOW." 20; then
echo "error: PX4 did not acknowledge the shutdown command" >&2
exit 1
fi
cleanup
trap - EXIT INT TERM
px4_status="fail"
dds_status="fail"
vehicle_status="fail"
clock_status="fail"
time_control_status="fail"
resource_baseline_status="fail"
lifecycle_status="fail"
grep -Fq "world: rover, model: rover_ackermann_0" "${PX4_LOG}" && px4_status="pass"
if grep -Fq "session established" "${AGENT_LOG}" \
&& grep -Fq \
"successfully created rt/fmu/out/vehicle_status_v1 data writer" \
"${PX4_LOG}"; then
dds_status="pass"
fi
grep -Fq "arming_state:" "${VEHICLE_STATUS_LOG}" && vehicle_status="pass"
grep -Fq "clock:" "${CLOCK_LOG}" && clock_status="pass"
grep -Fq '"verdict": "pass"' "${TIME_CONTROL_LOG}" && time_control_status="pass"
grep -Fq '"verdict": "pass"' "${RESOURCE_BASELINE_LOG}" \
&& resource_baseline_status="pass"
if ! pgrep -f "${PX4_ROOT}/build/px4_sitl_default/bin/px4" >/dev/null \
&& ! pgrep -f "gz sim.*rover.sdf" >/dev/null \
&& ! pgrep -f "${AGENT_BIN}" >/dev/null; then
lifecycle_status="pass"
fi
{
echo "run_id=${RUN_ID}"
echo "network_scope=loopback-only-netns"
echo "actuator_authority=false"
echo "speed_factor=${SPEED_FACTOR}"
echo "px4_stock_rover=${px4_status}"
echo "uxrce_dds=${dds_status}"
echo "ros2_vehicle_status=${vehicle_status}"
echo "gazebo_clock=${clock_status}"
echo "pause_step_speed=${time_control_status}"
echo "resource_baseline=${resource_baseline_status}"
echo "clean_lifecycle=${lifecycle_status}"
} >"${RESULT_LOG}"
if [[ "${px4_status}" != "pass" \
|| "${dds_status}" != "pass" \
|| "${vehicle_status}" != "pass" \
|| "${clock_status}" != "pass" \
|| "${time_control_status}" != "pass" \
|| "${resource_baseline_status}" != "pass" \
|| "${lifecycle_status}" != "pass" ]]; then
echo "error: one or more S0 smoke checks failed; inspect ${RUN_DIR}" >&2
exit 1
fi
echo "SIM S0 smoke PASS: ${RUN_DIR}"

View File

@ -0,0 +1,18 @@
schema_version: missioncore.stock-rover-lifecycle/v1
profile_id: s1b-stock-rover-lifecycle-v1
speed_factor: 1
dwell_seconds: 5
authority:
simulation_or_shadow_only: true
actuator_authority: false
navigation_or_safety_accepted: false
direct_actuator_setpoints_allowed: false
agent_ready_log_patterns:
- running...
px4_ready_log_patterns:
- 'world: rover, model: rover_ackermann_0'
- Startup script returned successfully
- successfully created rt/fmu/out/vehicle_status_v1 data writer

View File

@ -0,0 +1,368 @@
from __future__ import annotations
import argparse
import hashlib
import os
import re
import shutil
import socket
import sys
import time
from datetime import UTC, datetime
from pathlib import Path
from k1link.artifacts import write_json_atomic
from k1link.simulation import (
AuthorityProfile,
LocalProcessWorkerAdapter,
PosixProcessSupervisor,
ProviderPin,
QualificationRun,
QualificationRunStore,
ReproducibilityTier,
RunKind,
RunState,
S0WorkerGuard,
SimulationApplicationService,
StockRoverTargetPaths,
load_stock_rover_lifecycle_profile,
stock_rover_process_environment,
stock_rover_process_specs,
)
from k1link.simulation.s0 import ComponentPin, S0Profile
from k1link.simulation.worker import SimulationWorldControl
EXPECTED_DISTRO = "MissionCore-Sim"
COMMIT_PATTERN = re.compile(r"^[a-f0-9]{40}$")
PROVIDER_IDS = (
"px4-autopilot",
"gazebo",
"px4-gazebo-models",
"micro-xrce-dds-agent",
)
class _LifecycleOnlyWorldControl(SimulationWorldControl):
def pause(self, run_id: str) -> None:
raise RuntimeError(f"world pause is not admitted by S1B lifecycle run {run_id}")
def resume(self, run_id: str) -> None:
raise RuntimeError(f"world resume is not admitted by S1B lifecycle run {run_id}")
def step(self, run_id: str, step_count: int) -> int:
raise RuntimeError(f"world step is not admitted by S1B lifecycle run {run_id}:{step_count}")
def main() -> int:
parser = argparse.ArgumentParser(
description="Run the accepted PX4/Gazebo stock rover under Mission Core S1B ownership."
)
parser.add_argument("--run-id", required=True)
parser.add_argument("--mission-core-commit", required=True)
parser.add_argument("--lifecycle-profile", type=Path, required=True)
parser.add_argument(
"--s0-profile",
type=Path,
default=Path("/mnt/d/NDC_MISSIONCORE/simulation/source/qualification-profile.yaml"),
)
parser.add_argument(
"--d-root",
type=Path,
default=Path("/mnt/d/NDC_MISSIONCORE/simulation"),
)
args = parser.parse_args()
if not COMMIT_PATTERN.fullmatch(args.mission_core_commit):
parser.error("--mission-core-commit must be an exact 40-character Git revision")
if os.geteuid() == 0:
parser.error("the lifecycle harness must run as the unprivileged missioncore user")
if os.environ.get("WSL_DISTRO_NAME") != EXPECTED_DISTRO:
parser.error(f"the lifecycle harness requires WSL distribution {EXPECTED_DISTRO}")
paths = StockRoverTargetPaths.from_d_root(args.d_root)
evidence_root = paths.d_root / "artifacts/s1/lifecycle" / args.run_id
evidence_root.mkdir(mode=0o700, parents=True, exist_ok=False)
lifecycle_profile_path = args.lifecycle_profile.expanduser().resolve()
s0_profile_path = args.s0_profile.expanduser().resolve()
try:
lifecycle_profile = load_stock_rover_lifecycle_profile(lifecycle_profile_path)
guard = S0WorkerGuard(s0_profile_path)
_preflight(paths, guard.profile)
interface_names = _loopback_only_interfaces()
except Exception as exc:
write_json_atomic(
evidence_root / "result.json",
{
"schema_version": "missioncore.s1b-stock-rover-result/v1",
"verdict": "fail",
"phase": "preflight",
"detail": f"{type(exc).__name__}: {exc}",
"run_id": args.run_id,
"mission_core_commit": args.mission_core_commit,
"network_scope": "loopback-only-netns-required",
"actuator_authority": False,
"direct_actuator_setpoints_allowed": False,
"finished_at_utc": _utc_now(),
},
)
_write_digest_index(evidence_root)
print("verdict=fail")
print(f"evidence_root={evidence_root}")
return 1
artifact_runs_root = paths.d_root / "artifacts/s1/runs"
runtime_runs_root = paths.d_root / "runtime/s1/runs"
write_json_atomic(
evidence_root / "network.json",
{
"schema_version": "missioncore.s1b-network-evidence/v1",
"run_id": args.run_id,
"namespace": "product-owned-unshare-net",
"interfaces": list(interface_names),
"loopback_only": interface_names == ("lo",),
},
)
run = QualificationRun(
run_id=args.run_id,
episode_id=f"episode-{args.run_id}",
kind=RunKind.SIMULATION_CLOSED_LOOP,
state=RunState.ADMITTED,
scenario_generation="px4-v1.17.0-stock-rover-ackermann",
scenario_sha256=_sha256(paths.scenario_path),
profile_generation=lifecycle_profile.profile_id,
profile_sha256=_sha256(lifecycle_profile_path),
mission_core_commit=args.mission_core_commit,
providers=_provider_pins(guard.profile),
host_profile_id=guard.profile.profile_id,
host_profile_sha256=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(),
)
store = QualificationRunStore(artifact_runs_root)
store.create(run)
supervisor = PosixProcessSupervisor(
runtime_runs_root,
base_environment=stock_rover_process_environment(paths),
)
adapter = LocalProcessWorkerAdapter(
guard,
supervisor,
stock_rover_process_specs(paths, lifecycle_profile),
_LifecycleOnlyWorldControl(),
)
service = SimulationApplicationService(store, adapter)
verdict = "fail"
detail = "lifecycle did not start"
observed_provider_ids: tuple[str, ...] = ()
try:
running = service.start(
run.run_id,
idempotency_key=f"{run.run_id}:start",
observed_at_utc=_utc_now(),
host_monotonic_ns=time.monotonic_ns(),
)
if running.state is not RunState.RUNNING:
raise RuntimeError(
f"Mission Core did not admit provider readiness: {running.state.value}"
)
observed_provider_ids = tuple(record.process_id for record in supervisor.snapshot())
time.sleep(lifecycle_profile.dwell_seconds)
supervisor.snapshot()
terminal = service.stop(
run.run_id,
idempotency_key=f"{run.run_id}:stop",
observed_at_utc=_utc_now(),
host_monotonic_ns=time.monotonic_ns(),
sim_time_ns=None,
)
if terminal.state is not RunState.COMPLETED:
raise RuntimeError(
f"Mission Core provider shutdown was not clean: {terminal.state.value}"
)
residue = _provider_residue()
if supervisor.residue() or residue:
raise RuntimeError("provider process residue remained after Mission Core stop")
if store.list_commands(run.run_id):
raise RuntimeError("S1B lifecycle unexpectedly contains control commands")
verdict = "pass"
detail = "real stock-rover providers reached declared readiness and stopped cleanly"
except BaseException as exc:
detail = f"{type(exc).__name__}: {exc}"
_abort_active_run(service, store, run.run_id)
supervisor.stop()
finally:
terminal_run = store.load(run.run_id)
residue = _provider_residue()
_capture_directory(
artifact_runs_root / run.run_id,
evidence_root / "qualification-run",
)
runtime_run_root = runtime_runs_root / run.run_id
if runtime_run_root.is_dir():
_capture_directory(runtime_run_root, evidence_root / "provider-runtime")
write_json_atomic(
evidence_root / "result.json",
{
"schema_version": "missioncore.s1b-stock-rover-result/v1",
"verdict": verdict,
"detail": detail,
"run_id": run.run_id,
"run_state": terminal_run.state.value,
"terminal_reason": terminal_run.terminal_reason,
"mission_core_commit": run.mission_core_commit,
"host_profile_id": run.host_profile_id,
"host_profile_sha256": run.host_profile_sha256,
"lifecycle_profile_id": lifecycle_profile.profile_id,
"lifecycle_profile_sha256": run.profile_sha256,
"scenario_sha256": run.scenario_sha256,
"provider_ids": list(observed_provider_ids),
"event_count": len(store.list_events(run.run_id)),
"command_count": len(store.list_commands(run.run_id)),
"process_residue": list(residue),
"network_interfaces": list(interface_names),
"network_scope": "loopback-only-netns",
"actuator_authority": False,
"direct_actuator_setpoints_allowed": False,
"finished_at_utc": _utc_now(),
},
)
_write_digest_index(evidence_root)
print(f"verdict={verdict}")
print(f"evidence_root={evidence_root}")
return 0 if verdict == "pass" else 1
def _preflight(paths: StockRoverTargetPaths, profile: S0Profile) -> None:
required = (
paths.px4_root,
paths.agent_binary,
paths.scenario_path,
)
missing = tuple(str(path) for path in required if not path.exists())
if missing:
raise RuntimeError(f"S1B target input is missing: {', '.join(missing)}")
free_bytes = shutil.disk_usage(paths.d_root).free
stop_floor_bytes = profile.storage.stop_below_gib * 1024**3
if free_bytes < stop_floor_bytes:
raise RuntimeError("D free space is below the accepted S0 stop floor")
if not all(component.qualification_state == "accepted" for component in profile.components):
raise RuntimeError("an S0 provider generation is no longer accepted")
def _loopback_only_interfaces() -> tuple[str, ...]:
interfaces = tuple(sorted(name for _, name in socket.if_nameindex()))
if interfaces != ("lo",):
raise RuntimeError(f"S1B must run in a loopback-only namespace; observed {interfaces}")
return interfaces
def _provider_pins(profile: S0Profile) -> tuple[ProviderPin, ...]:
components = {component.identifier: component for component in profile.components}
missing = tuple(identifier for identifier in PROVIDER_IDS if identifier not in components)
if missing:
raise RuntimeError(f"S0 profile is missing provider pins: {', '.join(missing)}")
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
revision = component.resolved_commit or version
return ProviderPin(
identifier=component.identifier,
version=version,
revision=revision,
)
def _abort_active_run(
service: SimulationApplicationService,
store: QualificationRunStore,
run_id: str,
) -> None:
current = store.load(run_id)
if current.state not in {RunState.RUNNING, RunState.PAUSED, RunState.STOPPING}:
return
try:
service.stop(
run_id,
idempotency_key=f"{run_id}:harness-abort",
observed_at_utc=_utc_now(),
host_monotonic_ns=time.monotonic_ns(),
sim_time_ns=None,
terminal_state=RunState.ABORTED,
reason="target-harness-failure",
)
except Exception:
return
def _provider_residue() -> tuple[str, ...]:
tokens = (
"MicroXRCEAgent",
"/build/px4_sitl_default/bin/px4",
"gz sim",
"rover.sdf",
)
residue: list[str] = []
for process_dir in Path("/proc").iterdir():
if not process_dir.name.isdigit() or int(process_dir.name) == os.getpid():
continue
try:
command = (process_dir / "cmdline").read_bytes().replace(b"\x00", b" ").decode()
except (FileNotFoundError, PermissionError, UnicodeDecodeError):
continue
if command and any(token in command for token in tokens):
residue.append(f"{process_dir.name}:{command.strip()}")
return tuple(sorted(residue))
def _capture_directory(source: Path, destination: Path) -> None:
if destination.exists():
raise RuntimeError(f"evidence destination already exists: {destination}")
shutil.copytree(source, destination)
def _write_digest_index(evidence_root: Path) -> None:
records = []
for path in sorted(evidence_root.rglob("*")):
if path.is_file() and path.name != "sha256-index.json":
records.append(
{
"path": path.relative_to(evidence_root).as_posix(),
"sha256": _sha256(path),
"bytes": path.stat().st_size,
}
)
write_json_atomic(
evidence_root / "sha256-index.json",
{
"schema_version": "missioncore.evidence-digest-index/v1",
"files": records,
},
)
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")
if __name__ == "__main__":
sys.exit(main())

75
simulation/s1/worker-agent.sh Executable file
View File

@ -0,0 +1,75 @@
#!/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"
SOURCE_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
readonly SOURCE_ROOT
readonly MISSION_CORE_COMMIT="${1:-}"
readonly SOCKET_ROOT="/run/missioncore-sim"
readonly SOCKET_PATH="${SOCKET_ROOT}/worker.sock"
readonly ROS_SETUP="/opt/ros/jazzy/setup.bash"
readonly PX4_MSGS_SETUP="${D_ROOT}/build/ros2/px4_msgs/install/setup.bash"
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
if [[ ! -f "${ROS_SETUP}" || ! -f "${PX4_MSGS_SETUP}" ]]; then
echo "error: reviewed ROS 2 Jazzy/px4_msgs runtime is incomplete" >&2
exit 2
fi
set +u
# shellcheck disable=SC1090
source "${ROS_SETUP}"
# shellcheck disable=SC1090
source "${PX4_MSGS_SETUP}"
set -u
exec env \
ROS_DOMAIN_ID=0 \
PYTHONPATH="${SOURCE_ROOT}/src:${PYTHONPATH:-}" \
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"

View File

@ -0,0 +1,53 @@
#!/usr/bin/env bash
#
# Enter a fresh loopback-only namespace and execute the S1B stock-rover
# lifecycle as the unprivileged missioncore worker identity.
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 RUN_ID="${1:-}"
readonly MISSION_CORE_COMMIT="${2:-}"
if [[ -z "${RUN_ID}" || -z "${MISSION_CORE_COMMIT}" ]]; then
echo "usage: $0 <run-id> <exact-40-character-mission-core-commit>" >&2
exit 2
fi
if [[ "${SOURCE_ROOT}" != "${D_ROOT}/source/"* ]]; then
echo "error: S1B 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 S1B invocation must run as root to create the network namespace" >&2
exit 2
fi
if [[ "${WSL_DISTRO_NAME:-}" != "${EXPECTED_DISTRO}" ]]; then
echo "error: expected WSL distro ${EXPECTED_DISTRO}" >&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: S1B inner lifecycle escaped the reviewed worker identity" >&2
exit 2
fi
exec env \
PYTHONPATH="${SOURCE_ROOT}/src" \
python3 "${SOURCE_ROOT}/simulation/s1/stock_rover_lifecycle.py" \
--run-id "${RUN_ID}" \
--mission-core-commit "${MISSION_CORE_COMMIT}" \
--lifecycle-profile "${SOURCE_ROOT}/simulation/s1/stock-rover-lifecycle.yaml"

View File

@ -0,0 +1,121 @@
"""Mission Core qualification and simulation boundaries."""
from k1link.simulation.contracts import (
AckermannControlSetpoint,
AuthorityProfile,
CommandAuthorityScope,
ControlProfile,
ControlSetpoint,
DifferentialControlSetpoint,
ProviderPin,
QualificationArtifact,
QualificationEvent,
QualificationRun,
ReproducibilityTier,
RunKind,
RunState,
SimulationContractError,
)
from k1link.simulation.orchestrator import (
ActiveQualificationRunError,
SimulationApplicationService,
SimulationOrchestratorError,
SimulationWorkerPort,
WorkerStartResult,
WorkerStopResult,
)
from k1link.simulation.process_supervisor import (
OwnedProcess,
PosixProcessSupervisor,
ProcessSpec,
ProcessStopResult,
ProcessSupervisorError,
)
from k1link.simulation.run_store import (
QualificationRunConflictError,
QualificationRunIntegrityError,
QualificationRunNotFoundError,
QualificationRunStore,
QualificationRunStoreError,
QualificationRunTransitionError,
)
from k1link.simulation.s0 import (
CheckStatus,
DoctorVerdict,
RuntimeAcceptance,
S0DoctorReport,
S0Profile,
S0ProfileError,
load_s0_profile,
run_s0_doctor,
)
from k1link.simulation.stock_rover import (
LIFECYCLE_PROFILE_SCHEMA,
StockRoverLifecycleProfile,
StockRoverProfileError,
StockRoverTargetPaths,
load_stock_rover_lifecycle_profile,
stock_rover_process_environment,
stock_rover_process_specs,
)
from k1link.simulation.worker import (
LocalProcessWorkerAdapter,
S0WorkerGuard,
SimulationWorldControl,
WorkerAdmission,
WorkerAdmissionError,
)
__all__ = [
"AckermannControlSetpoint",
"ActiveQualificationRunError",
"AuthorityProfile",
"CheckStatus",
"CommandAuthorityScope",
"ControlProfile",
"ControlSetpoint",
"DifferentialControlSetpoint",
"DoctorVerdict",
"LocalProcessWorkerAdapter",
"LIFECYCLE_PROFILE_SCHEMA",
"OwnedProcess",
"PosixProcessSupervisor",
"ProcessSpec",
"ProcessStopResult",
"ProcessSupervisorError",
"ProviderPin",
"QualificationArtifact",
"QualificationEvent",
"QualificationRun",
"QualificationRunConflictError",
"QualificationRunIntegrityError",
"QualificationRunNotFoundError",
"QualificationRunStore",
"QualificationRunStoreError",
"QualificationRunTransitionError",
"ReproducibilityTier",
"RuntimeAcceptance",
"RunKind",
"RunState",
"S0WorkerGuard",
"S0DoctorReport",
"S0Profile",
"S0ProfileError",
"StockRoverLifecycleProfile",
"StockRoverProfileError",
"StockRoverTargetPaths",
"SimulationContractError",
"SimulationApplicationService",
"SimulationOrchestratorError",
"SimulationWorkerPort",
"SimulationWorldControl",
"WorkerAdmission",
"WorkerAdmissionError",
"WorkerStartResult",
"WorkerStopResult",
"load_s0_profile",
"load_stock_rover_lifecycle_profile",
"run_s0_doctor",
"stock_rover_process_environment",
"stock_rover_process_specs",
]

View File

@ -0,0 +1,98 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Annotated
import typer
from rich.console import Console
from rich.table import Table
from k1link.simulation import S0ProfileError, run_s0_doctor
app = typer.Typer(
name="missioncore-sim",
help="Fail-closed qualification tooling for the Mission Core Polygon.",
no_args_is_help=True,
)
s0_app = typer.Typer(
help="SIM S0 compatibility and infrastructure qualification.",
no_args_is_help=True,
)
app.add_typer(s0_app, name="s0")
console = Console()
def _default_profile() -> Path:
return Path(__file__).resolve().parents[3] / "simulation" / "s0" / "qualification-profile.yaml"
DEFAULT_PROFILE = _default_profile()
@s0_app.command("doctor")
def s0_doctor(
profile: Annotated[
Path,
typer.Option(
"--profile",
help="Strict SIM S0 qualification profile.",
exists=True,
dir_okay=False,
readable=True,
),
] = DEFAULT_PROFILE,
evidence: Annotated[
Path | None,
typer.Option(
"--evidence",
help="Optional digest-bound target-worker evidence manifest.",
exists=True,
dir_okay=False,
readable=True,
),
] = None,
json_output: Annotated[
bool,
typer.Option("--json", help="Emit the complete report as JSON."),
] = False,
) -> None:
"""Inspect contracts and local target readiness without changing the host."""
try:
report = run_s0_doctor(profile, evidence_path=evidence)
except (OSError, S0ProfileError) as exc:
if json_output:
console.print_json(
json.dumps(
{
"schema_version": "missioncore.simulation-s0-doctor/v1",
"verdict": "blocked",
"error": str(exc),
}
)
)
else:
console.print(f"[red]SIM S0 profile rejected:[/red] {exc}")
raise typer.Exit(code=2) from exc
payload = report.to_dict()
if json_output:
console.print_json(json.dumps(payload))
return
table = Table(title=f"SIM S0 doctor — {report.profile_id}")
table.add_column("Check")
table.add_column("Status")
table.add_column("Detail")
colors = {"pass": "green", "fail": "red", "unknown": "yellow"}
for check in report.checks:
status = check.status.value
styled_status = f"[{colors[status]}]{status}[/{colors[status]}]"
table.add_row(check.identifier, styled_status, check.detail)
console.print(table)
console.print(f"Verdict: [bold]{report.verdict.value.upper()}[/bold]")
if __name__ == "__main__":
app()

View File

@ -0,0 +1,685 @@
from __future__ import annotations
import math
import re
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from enum import StrEnum
from pathlib import PurePosixPath
from typing import Any, Final
IDENTIFIER_PATTERN: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
SHA256_PATTERN: Final = re.compile(r"^[a-f0-9]{64}$")
GIT_REVISION_PATTERN: Final = re.compile(r"^[a-f0-9]{7,64}$")
class SimulationContractError(ValueError):
"""A Polygon domain object violates the admitted S1 contract."""
class RunKind(StrEnum):
SIMULATION_CLOSED_LOOP = "simulation_closed_loop"
REPLAY_SHADOW = "replay_shadow"
DIGITAL_TWIN_CLOSED_LOOP = "digital_twin_closed_loop"
CONTROLLER_IN_LOOP = "controller_in_loop"
HIL = "hil"
PHYSICAL_SHADOW = "physical_shadow"
class RunState(StrEnum):
ADMITTED = "admitted"
STARTING = "starting"
RUNNING = "running"
PAUSED = "paused"
STOPPING = "stopping"
COMPLETED = "completed"
FAILED = "failed"
ABORTED = "aborted"
@property
def terminal(self) -> bool:
return self in {RunState.COMPLETED, RunState.FAILED, RunState.ABORTED}
class ReproducibilityTier(StrEnum):
R0 = "R0"
R1 = "R1"
R2 = "R2"
class ControlProfile(StrEnum):
ROVER_SPEED_STEERING_V1 = "rover-speed-steering/v1"
ROVER_SPEED_YAW_RATE_V1 = "rover-speed-yaw-rate/v1"
class CommandAuthorityScope(StrEnum):
VIRTUAL_ONLY = "virtual-only"
@dataclass(frozen=True, slots=True)
class ProviderPin:
identifier: str
version: str
revision: str
digest: str | None = None
def __post_init__(self) -> None:
_identifier(self.identifier, "provider identifier")
if not self.version.strip():
raise SimulationContractError("provider version must not be empty")
if not self.revision.strip():
raise SimulationContractError("provider revision must not be empty")
if self.digest is not None:
_sha256(self.digest, "provider digest")
def to_dict(self) -> dict[str, object]:
return {
"identifier": self.identifier,
"version": self.version,
"revision": self.revision,
"digest": self.digest,
}
@classmethod
def from_dict(cls, value: object) -> ProviderPin:
document = _object(value, "provider pin")
return cls(
identifier=_string(document, "identifier"),
version=_string(document, "version"),
revision=_string(document, "revision"),
digest=_optional_string(document, "digest"),
)
@dataclass(frozen=True, slots=True)
class AuthorityProfile:
"""The only command authority admitted before a physical safety gate."""
generation: int
command_ttl_max_ns: int
heartbeat_timeout_monotonic_ns: int
simulation_or_shadow_only: bool = True
actuator_authority: bool = False
navigation_or_safety_accepted: bool = False
direct_actuator_setpoints_allowed: bool = False
def __post_init__(self) -> None:
if self.generation < 1:
raise SimulationContractError("authority generation must be positive")
if self.command_ttl_max_ns < 1:
raise SimulationContractError("command TTL limit must be positive")
if self.heartbeat_timeout_monotonic_ns < 1:
raise SimulationContractError("heartbeat timeout must be positive")
if (
not self.simulation_or_shadow_only
or self.actuator_authority
or self.navigation_or_safety_accepted
or self.direct_actuator_setpoints_allowed
):
raise SimulationContractError(
"S1 authority must remain simulation/shadow-only without actuator authority"
)
def to_dict(self) -> dict[str, object]:
return {
"generation": self.generation,
"command_ttl_max_ns": self.command_ttl_max_ns,
"heartbeat_timeout_monotonic_ns": self.heartbeat_timeout_monotonic_ns,
"simulation_or_shadow_only": self.simulation_or_shadow_only,
"actuator_authority": self.actuator_authority,
"navigation_or_safety_accepted": self.navigation_or_safety_accepted,
"direct_actuator_setpoints_allowed": self.direct_actuator_setpoints_allowed,
}
@classmethod
def from_dict(cls, value: object) -> AuthorityProfile:
document = _object(value, "authority profile")
return cls(
generation=_integer(document, "generation"),
command_ttl_max_ns=_integer(document, "command_ttl_max_ns"),
heartbeat_timeout_monotonic_ns=_integer(
document,
"heartbeat_timeout_monotonic_ns",
),
simulation_or_shadow_only=_boolean(document, "simulation_or_shadow_only"),
actuator_authority=_boolean(document, "actuator_authority"),
navigation_or_safety_accepted=_boolean(
document,
"navigation_or_safety_accepted",
),
direct_actuator_setpoints_allowed=_boolean(
document,
"direct_actuator_setpoints_allowed",
),
)
@dataclass(frozen=True, slots=True)
class QualificationArtifact:
artifact_id: str
kind: str
relative_path: str
sha256: str
byte_length: int
source_of_record: bool
sequence: int = 0
def __post_init__(self) -> None:
_identifier(self.artifact_id, "artifact id")
_identifier(self.kind, "artifact kind")
_relative_path(self.relative_path)
_sha256(self.sha256, "artifact digest")
if self.byte_length < 0:
raise SimulationContractError("artifact byte length must not be negative")
if self.sequence < 0:
raise SimulationContractError("artifact sequence must not be negative")
def to_dict(self) -> dict[str, object]:
return {
"artifact_id": self.artifact_id,
"kind": self.kind,
"relative_path": self.relative_path,
"sha256": self.sha256,
"byte_length": self.byte_length,
"source_of_record": self.source_of_record,
"sequence": self.sequence,
}
@classmethod
def from_dict(cls, value: object) -> QualificationArtifact:
document = _object(value, "qualification artifact")
return cls(
artifact_id=_string(document, "artifact_id"),
kind=_string(document, "kind"),
relative_path=_string(document, "relative_path"),
sha256=_string(document, "sha256"),
byte_length=_integer(document, "byte_length"),
source_of_record=_boolean(document, "source_of_record"),
sequence=_integer(document, "sequence"),
)
@dataclass(frozen=True, slots=True)
class QualificationRun:
run_id: str
episode_id: str
kind: RunKind
state: RunState
scenario_generation: str
scenario_sha256: str
profile_generation: str
profile_sha256: str
mission_core_commit: str
providers: tuple[ProviderPin, ...]
host_profile_id: str
host_profile_sha256: str
seed: int
reproducibility_tier: ReproducibilityTier
authority: AuthorityProfile
clock_domain: str
created_at_utc: str
started_at_utc: str | None = None
ended_at_utc: str | None = None
terminal_reason: str | None = None
parent_run_id: str | None = None
revision: int = 0
artifacts: tuple[QualificationArtifact, ...] = ()
def __post_init__(self) -> None:
_identifier(self.run_id, "run id")
_identifier(self.episode_id, "episode id")
_identifier(self.scenario_generation, "scenario generation")
_sha256(self.scenario_sha256, "scenario digest")
_identifier(self.profile_generation, "profile generation")
_sha256(self.profile_sha256, "profile digest")
if not GIT_REVISION_PATTERN.fullmatch(self.mission_core_commit):
raise SimulationContractError("Mission Core commit must be a hexadecimal revision")
if not self.providers:
raise SimulationContractError("at least one provider pin is required")
provider_ids = [provider.identifier for provider in self.providers]
if len(provider_ids) != len(set(provider_ids)):
raise SimulationContractError("provider identifiers must be unique")
_identifier(self.host_profile_id, "host profile id")
_sha256(self.host_profile_sha256, "host profile digest")
if self.seed < 0:
raise SimulationContractError("run seed must not be negative")
if self.clock_domain != "gazebo:/clock" and self.kind in {
RunKind.SIMULATION_CLOSED_LOOP,
RunKind.DIGITAL_TWIN_CLOSED_LOOP,
}:
raise SimulationContractError("closed-loop simulation requires Gazebo /clock")
_utc_timestamp(self.created_at_utc, "created timestamp")
if self.started_at_utc is not None:
_utc_timestamp(self.started_at_utc, "started timestamp")
if self.ended_at_utc is not None:
_utc_timestamp(self.ended_at_utc, "ended timestamp")
if self.parent_run_id is not None:
_identifier(self.parent_run_id, "parent run id")
if self.parent_run_id == self.run_id:
raise SimulationContractError("a run cannot be its own parent")
if self.revision < 0:
raise SimulationContractError("run revision must not be negative")
if self.state.terminal != (self.ended_at_utc is not None):
raise SimulationContractError("terminal state and ended timestamp must agree")
if self.state.terminal != (self.terminal_reason is not None):
raise SimulationContractError("terminal state and terminal reason must agree")
if self.kind in {RunKind.CONTROLLER_IN_LOOP, RunKind.HIL}:
raise SimulationContractError(
f"{self.kind.value} requires a separate physical authority safety gate"
)
def with_runtime(
self,
*,
state: RunState,
revision: int,
started_at_utc: str | None,
ended_at_utc: str | None,
terminal_reason: str | None,
artifacts: tuple[QualificationArtifact, ...],
) -> QualificationRun:
return replace(
self,
state=state,
revision=revision,
started_at_utc=started_at_utc,
ended_at_utc=ended_at_utc,
terminal_reason=terminal_reason,
artifacts=artifacts,
)
def to_manifest_dict(self) -> dict[str, object]:
return {
"schema_version": "missioncore.qualification-run/v1",
"run_id": self.run_id,
"episode_id": self.episode_id,
"kind": self.kind.value,
"initial_state": RunState.ADMITTED.value,
"scenario": {
"generation": self.scenario_generation,
"sha256": self.scenario_sha256,
},
"profile": {
"generation": self.profile_generation,
"sha256": self.profile_sha256,
},
"mission_core_commit": self.mission_core_commit,
"providers": [provider.to_dict() for provider in self.providers],
"host_profile": {
"id": self.host_profile_id,
"sha256": self.host_profile_sha256,
},
"seed": self.seed,
"reproducibility_tier": self.reproducibility_tier.value,
"authority": self.authority.to_dict(),
"clock_domain": self.clock_domain,
"created_at_utc": self.created_at_utc,
"parent_run_id": self.parent_run_id,
}
@classmethod
def from_manifest_dict(cls, value: object) -> QualificationRun:
document = _object(value, "qualification run")
if document.get("schema_version") != "missioncore.qualification-run/v1":
raise SimulationContractError("unsupported qualification-run schema")
if document.get("initial_state") != RunState.ADMITTED.value:
raise SimulationContractError("qualification run must be admitted immutably")
scenario = _object(document.get("scenario"), "scenario")
profile = _object(document.get("profile"), "profile")
host_profile = _object(document.get("host_profile"), "host profile")
providers = document.get("providers")
if not isinstance(providers, list):
raise SimulationContractError("providers must be an array")
try:
kind = RunKind(_string(document, "kind"))
tier = ReproducibilityTier(_string(document, "reproducibility_tier"))
except ValueError as exc:
raise SimulationContractError("qualification run contains an unknown enum") from exc
return cls(
run_id=_string(document, "run_id"),
episode_id=_string(document, "episode_id"),
kind=kind,
state=RunState.ADMITTED,
scenario_generation=_string(scenario, "generation"),
scenario_sha256=_string(scenario, "sha256"),
profile_generation=_string(profile, "generation"),
profile_sha256=_string(profile, "sha256"),
mission_core_commit=_string(document, "mission_core_commit"),
providers=tuple(ProviderPin.from_dict(provider) for provider in providers),
host_profile_id=_string(host_profile, "id"),
host_profile_sha256=_string(host_profile, "sha256"),
seed=_integer(document, "seed"),
reproducibility_tier=tier,
authority=AuthorityProfile.from_dict(document.get("authority")),
clock_domain=_string(document, "clock_domain"),
created_at_utc=_string(document, "created_at_utc"),
parent_run_id=_optional_string(document, "parent_run_id"),
)
@dataclass(frozen=True, slots=True)
class QualificationEvent:
run_id: str
sequence: int
event_type: str
observed_at_utc: str
host_monotonic_ns: int
sim_time_ns: int | None
payload: dict[str, Any]
def __post_init__(self) -> None:
_identifier(self.run_id, "event run id")
_identifier(self.event_type, "event type")
if self.sequence < 1:
raise SimulationContractError("event sequence must be positive")
_utc_timestamp(self.observed_at_utc, "event timestamp")
if self.host_monotonic_ns < 0:
raise SimulationContractError("host monotonic timestamp must not be negative")
if self.sim_time_ns is not None and self.sim_time_ns < 0:
raise SimulationContractError("simulation timestamp must not be negative")
_json_object(self.payload, "event payload")
def to_dict(self) -> dict[str, object]:
return {
"schema_version": "missioncore.qualification-event/v1",
"run_id": self.run_id,
"sequence": self.sequence,
"event_type": self.event_type,
"observed_at_utc": self.observed_at_utc,
"host_monotonic_ns": self.host_monotonic_ns,
"sim_time_ns": self.sim_time_ns,
"payload": self.payload,
}
@classmethod
def from_dict(cls, value: object) -> QualificationEvent:
document = _object(value, "qualification event")
if document.get("schema_version") != "missioncore.qualification-event/v1":
raise SimulationContractError("unsupported qualification-event schema")
sim_time = document.get("sim_time_ns")
if sim_time is not None and (not isinstance(sim_time, int) or isinstance(sim_time, bool)):
raise SimulationContractError("sim_time_ns must be an integer or null")
payload = _object(document.get("payload"), "event payload")
return cls(
run_id=_string(document, "run_id"),
sequence=_integer(document, "sequence"),
event_type=_string(document, "event_type"),
observed_at_utc=_string(document, "observed_at_utc"),
host_monotonic_ns=_integer(document, "host_monotonic_ns"),
sim_time_ns=sim_time,
payload=payload,
)
@dataclass(frozen=True, slots=True)
class AckermannControlSetpoint:
run_id: str
command_id: str
sequence: int
issued_at_sim_ns: int
valid_until_sim_ns: int
authority_generation: int
speed_mps: float
steering_normalized: float
profile: ControlProfile = ControlProfile.ROVER_SPEED_STEERING_V1
authority_scope: CommandAuthorityScope = CommandAuthorityScope.VIRTUAL_ONLY
source: str = "simulation-orchestrator"
def __post_init__(self) -> None:
_command_header(
self.run_id,
self.command_id,
self.sequence,
self.issued_at_sim_ns,
self.valid_until_sim_ns,
self.authority_generation,
)
_finite(self.speed_mps, "speed")
_finite(self.steering_normalized, "steering")
if not -1.0 <= self.steering_normalized <= 1.0:
raise SimulationContractError("normalized steering must be within -1..1")
if self.profile is not ControlProfile.ROVER_SPEED_STEERING_V1:
raise SimulationContractError("Ackermann command has an incompatible profile")
_command_provenance(self.authority_scope, self.source)
def to_dict(self) -> dict[str, object]:
return {
"schema_version": "missioncore.control-setpoint/v1",
"run_id": self.run_id,
"command_id": self.command_id,
"sequence": self.sequence,
"profile": self.profile.value,
"authority_scope": self.authority_scope.value,
"source": self.source,
"issued_at_sim_ns": self.issued_at_sim_ns,
"valid_until_sim_ns": self.valid_until_sim_ns,
"authority_generation": self.authority_generation,
"speed_mps": self.speed_mps,
"steering_normalized": self.steering_normalized,
}
@dataclass(frozen=True, slots=True)
class DifferentialControlSetpoint:
run_id: str
command_id: str
sequence: int
issued_at_sim_ns: int
valid_until_sim_ns: int
authority_generation: int
speed_mps: float
yaw_rate_rps: float
profile: ControlProfile = ControlProfile.ROVER_SPEED_YAW_RATE_V1
authority_scope: CommandAuthorityScope = CommandAuthorityScope.VIRTUAL_ONLY
source: str = "simulation-orchestrator"
def __post_init__(self) -> None:
_command_header(
self.run_id,
self.command_id,
self.sequence,
self.issued_at_sim_ns,
self.valid_until_sim_ns,
self.authority_generation,
)
_finite(self.speed_mps, "speed")
_finite(self.yaw_rate_rps, "yaw rate")
if self.profile is not ControlProfile.ROVER_SPEED_YAW_RATE_V1:
raise SimulationContractError("differential command has an incompatible profile")
_command_provenance(self.authority_scope, self.source)
def to_dict(self) -> dict[str, object]:
return {
"schema_version": "missioncore.control-setpoint/v1",
"run_id": self.run_id,
"command_id": self.command_id,
"sequence": self.sequence,
"profile": self.profile.value,
"authority_scope": self.authority_scope.value,
"source": self.source,
"issued_at_sim_ns": self.issued_at_sim_ns,
"valid_until_sim_ns": self.valid_until_sim_ns,
"authority_generation": self.authority_generation,
"speed_mps": self.speed_mps,
"yaw_rate_rps": self.yaw_rate_rps,
}
ControlSetpoint = AckermannControlSetpoint | DifferentialControlSetpoint
def control_setpoint_from_dict(value: object) -> ControlSetpoint:
document = _object(value, "control setpoint")
if document.get("schema_version") != "missioncore.control-setpoint/v1":
raise SimulationContractError("unsupported control-setpoint schema")
try:
profile = ControlProfile(_string(document, "profile"))
authority_scope = CommandAuthorityScope(_string(document, "authority_scope"))
except ValueError as exc:
raise SimulationContractError("unknown control profile or authority scope") from exc
run_id = _string(document, "run_id")
command_id = _string(document, "command_id")
sequence = _integer(document, "sequence")
issued_at_sim_ns = _integer(document, "issued_at_sim_ns")
valid_until_sim_ns = _integer(document, "valid_until_sim_ns")
authority_generation = _integer(document, "authority_generation")
speed_mps = _number(document, "speed_mps")
if profile is ControlProfile.ROVER_SPEED_STEERING_V1:
return AckermannControlSetpoint(
run_id=run_id,
command_id=command_id,
sequence=sequence,
issued_at_sim_ns=issued_at_sim_ns,
valid_until_sim_ns=valid_until_sim_ns,
authority_generation=authority_generation,
speed_mps=speed_mps,
steering_normalized=_number(document, "steering_normalized"),
authority_scope=authority_scope,
source=_string(document, "source"),
)
return DifferentialControlSetpoint(
run_id=run_id,
command_id=command_id,
sequence=sequence,
issued_at_sim_ns=issued_at_sim_ns,
valid_until_sim_ns=valid_until_sim_ns,
authority_generation=authority_generation,
speed_mps=speed_mps,
yaw_rate_rps=_number(document, "yaw_rate_rps"),
authority_scope=authority_scope,
source=_string(document, "source"),
)
def _command_header(
run_id: str,
command_id: str,
sequence: int,
issued_at_sim_ns: int,
valid_until_sim_ns: int,
authority_generation: int,
) -> None:
_identifier(run_id, "command run id")
_identifier(command_id, "command id")
if sequence < 1:
raise SimulationContractError("command sequence must be positive")
if issued_at_sim_ns < 0:
raise SimulationContractError("command issue time must not be negative")
if valid_until_sim_ns <= issued_at_sim_ns:
raise SimulationContractError("command validity deadline must follow issue time")
if authority_generation < 1:
raise SimulationContractError("command authority generation must be positive")
def _command_provenance(authority_scope: CommandAuthorityScope, source: str) -> None:
if authority_scope is not CommandAuthorityScope.VIRTUAL_ONLY:
raise SimulationContractError("S1 commands require virtual-only authority scope")
_identifier(source, "command source")
def _identifier(value: str, label: str) -> str:
if not IDENTIFIER_PATTERN.fullmatch(value):
raise SimulationContractError(f"{label} is not a safe identifier")
return value
def _sha256(value: str, label: str) -> str:
if not SHA256_PATTERN.fullmatch(value):
raise SimulationContractError(f"{label} must be a lowercase SHA-256 digest")
return value
def _relative_path(value: str) -> str:
path = PurePosixPath(value)
if (
not value
or value.startswith("/")
or "\\" in value
or path.is_absolute()
or path.as_posix() != value
or any(part in {"", ".", ".."} for part in path.parts)
):
raise SimulationContractError("artifact path must be a confined relative POSIX path")
return value
def _utc_timestamp(value: str, label: str) -> str:
if not value.endswith("Z") or "T" not in value:
raise SimulationContractError(f"{label} must be an explicit UTC timestamp")
try:
parsed = datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
except ValueError as exc:
raise SimulationContractError(f"{label} must be a valid explicit UTC timestamp") from exc
if parsed.tzinfo != UTC:
raise SimulationContractError(f"{label} must be an explicit UTC timestamp")
return value
def _finite(value: float, label: str) -> float:
if not math.isfinite(value):
raise SimulationContractError(f"{label} must be finite")
return value
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 SimulationContractError(f"{label} must be an object")
return value
def _json_object(value: object, label: str) -> dict[str, Any]:
document = _object(value, label)
_json_value(document, label)
return document
def _json_value(value: object, label: str) -> None:
if value is None or isinstance(value, str | bool | int):
return
if isinstance(value, float):
_finite(value, label)
return
if isinstance(value, list):
for item in value:
_json_value(item, label)
return
if isinstance(value, dict) and all(isinstance(key, str) for key in value):
for item in value.values():
_json_value(item, label)
return
raise SimulationContractError(f"{label} must contain only finite JSON values")
def _string(document: dict[str, Any], key: str) -> str:
value = document.get(key)
if not isinstance(value, str):
raise SimulationContractError(f"{key} must be a string")
return value
def _optional_string(document: dict[str, Any], key: str) -> str | None:
value = document.get(key)
if value is not None and not isinstance(value, str):
raise SimulationContractError(f"{key} must be a string or null")
return value
def _integer(document: dict[str, Any], key: str) -> int:
value = document.get(key)
if not isinstance(value, int) or isinstance(value, bool):
raise SimulationContractError(f"{key} must be an integer")
return value
def _number(document: dict[str, Any], key: str) -> float:
value = document.get(key)
if not isinstance(value, int | float) or isinstance(value, bool):
raise SimulationContractError(f"{key} must be a number")
return float(value)
def _boolean(document: dict[str, Any], key: str) -> bool:
value = document.get(key)
if not isinstance(value, bool):
raise SimulationContractError(f"{key} must be a boolean")
return value

View File

@ -0,0 +1,549 @@
from __future__ import annotations
import threading
from contextlib import suppress
from dataclasses import dataclass
from typing import Protocol
from k1link.simulation.contracts import QualificationEvent, QualificationRun, RunState
from k1link.simulation.run_store import (
ACTIVE_RECOVERY_STATES,
QualificationRunConflictError,
QualificationRunStore,
QualificationRunTransitionError,
)
class SimulationOrchestratorError(RuntimeError):
"""The server-owned S1 lifecycle could not be completed safely."""
class ActiveQualificationRunError(SimulationOrchestratorError):
"""Another qualification run already owns the worker authority."""
@dataclass(frozen=True, slots=True)
class WorkerStartResult:
provider_ids: tuple[str, ...]
profile_sha256: str
@dataclass(frozen=True, slots=True)
class WorkerStopResult:
stopped_provider_ids: tuple[str, ...]
residue_provider_ids: tuple[str, ...]
@property
def clean(self) -> bool:
return not self.residue_provider_ids
class SimulationWorkerPort(Protocol):
"""Transport-neutral worker boundary; no browser/PX4 shortcut exists."""
def start(self, run: QualificationRun) -> WorkerStartResult: ...
def pause(self, run: QualificationRun) -> None: ...
def resume(self, run: QualificationRun) -> None: ...
def step(self, run: QualificationRun, step_count: int) -> int: ...
def stop(self, run: QualificationRun) -> WorkerStopResult: ...
def reconcile(self, run: QualificationRun) -> WorkerStopResult: ...
class SimulationApplicationService:
"""Sole owner of one worker's persisted qualification-run lifecycle."""
def __init__(self, store: QualificationRunStore, worker: SimulationWorkerPort) -> None:
self.store = store
self.worker = worker
self._lock = threading.RLock()
def start(
self,
run_id: str,
*,
idempotency_key: str,
observed_at_utc: str,
host_monotonic_ns: int,
) -> QualificationRun:
key = _idempotency_key(idempotency_key)
with self._lock:
run = self.store.load(run_id)
existing = self._start_request(run_id, key)
if existing:
if run.state is RunState.ADMITTED:
return self._continue_start(
run,
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
)
return run
if self._has_other_start_request(run_id):
raise QualificationRunConflictError(
"qualification run is already bound to another start request"
)
active = tuple(
candidate
for candidate in self.store.list_runs()
if candidate.run_id != run_id and candidate.state in ACTIVE_RECOVERY_STATES
)
if active:
raise ActiveQualificationRunError(
f"worker authority is owned by {active[0].run_id}"
)
if run.state is not RunState.ADMITTED:
raise QualificationRunTransitionError(
"only an admitted qualification run can start"
)
event = self.store.append_event(
run_id,
event_type="orchestrator.start-requested",
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
payload={"idempotency_key": key},
expected_revision=run.revision,
)
run = self.store.load(run_id)
if run.revision != event.sequence:
raise QualificationRunConflictError("start request journal changed")
return self._continue_start(
run,
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
)
def pause(
self,
run_id: str,
*,
observed_at_utc: str,
host_monotonic_ns: int,
sim_time_ns: int,
) -> QualificationRun:
with self._lock:
run = self.store.load(run_id)
if run.state is not RunState.RUNNING:
raise QualificationRunTransitionError("only a running run can pause")
try:
self.worker.pause(run)
except Exception as exc:
return self._fail_worker_operation(
run,
operation="pause",
exc=exc,
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=sim_time_ns,
)
return self.store.transition(
run_id,
RunState.PAUSED,
expected_revision=run.revision,
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=sim_time_ns,
)
def resume(
self,
run_id: str,
*,
observed_at_utc: str,
host_monotonic_ns: int,
sim_time_ns: int,
) -> QualificationRun:
with self._lock:
run = self.store.load(run_id)
if run.state is not RunState.PAUSED:
raise QualificationRunTransitionError("only a paused run can resume")
try:
self.worker.resume(run)
except Exception as exc:
return self._fail_worker_operation(
run,
operation="resume",
exc=exc,
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=sim_time_ns,
)
return self.store.transition(
run_id,
RunState.RUNNING,
expected_revision=run.revision,
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=sim_time_ns,
)
def step(
self,
run_id: str,
*,
step_count: int,
observed_at_utc: str,
host_monotonic_ns: int,
sim_time_ns: int,
) -> int:
if step_count < 1:
raise ValueError("step count must be positive")
with self._lock:
run = self.store.load(run_id)
if run.state is not RunState.PAUSED:
raise QualificationRunTransitionError("single-step requires a paused run")
try:
advanced_ns = self.worker.step(run, step_count)
except Exception as exc:
self._fail_worker_operation(
run,
operation="step",
exc=exc,
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=sim_time_ns,
)
raise SimulationOrchestratorError("worker step failed") from exc
if advanced_ns < 1:
self._fail_worker_operation(
run,
operation="step-invalid-advance",
exc=ValueError("worker returned a non-positive clock advance"),
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=sim_time_ns,
)
raise SimulationOrchestratorError(
"worker step returned a non-positive clock advance"
)
self.store.append_event(
run_id,
event_type="clock.single-step",
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=sim_time_ns + advanced_ns,
payload={"step_count": step_count, "advanced_ns": advanced_ns},
expected_revision=run.revision,
)
return advanced_ns
def stop(
self,
run_id: str,
*,
idempotency_key: str,
observed_at_utc: str,
host_monotonic_ns: int,
sim_time_ns: int | None,
terminal_state: RunState = RunState.COMPLETED,
reason: str = "operator-stop-clean",
) -> QualificationRun:
key = _idempotency_key(idempotency_key)
if terminal_state not in {RunState.COMPLETED, RunState.ABORTED}:
raise ValueError("operator stop terminal state must be completed or aborted")
with self._lock:
run = self.store.load(run_id)
existing = self._stop_request(run_id, key)
if existing is not None and (
existing.payload.get("terminal_state") != terminal_state.value
or existing.payload.get("reason") != reason
):
raise QualificationRunConflictError(
"idempotency key is bound to a different stop request"
)
if existing is not None and run.state.terminal:
return run
if existing is None:
if self._has_other_stop_request(run_id):
raise QualificationRunConflictError(
"qualification run is already bound to another stop request"
)
event = self.store.append_event(
run_id,
event_type="orchestrator.stop-requested",
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=sim_time_ns,
payload={
"idempotency_key": key,
"terminal_state": terminal_state.value,
"reason": reason,
},
expected_revision=run.revision,
)
run = self.store.load(run_id)
if run.revision != event.sequence:
raise QualificationRunConflictError("stop request journal changed")
if run.state not in {RunState.RUNNING, RunState.PAUSED, RunState.STOPPING}:
raise QualificationRunTransitionError(
"only a running, paused or stopping run can stop"
)
if run.state is not RunState.STOPPING:
run = self.store.transition(
run_id,
RunState.STOPPING,
expected_revision=run.revision,
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=sim_time_ns,
)
try:
outcome = self.worker.stop(run)
except Exception as exc:
return self._terminal_failure(
run,
operation="stop",
detail=type(exc).__name__,
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=sim_time_ns,
)
if not outcome.clean:
return self._terminal_failure(
run,
operation="stop-residue",
detail=",".join(outcome.residue_provider_ids),
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=sim_time_ns,
)
event = self.store.append_event(
run_id,
event_type="orchestrator.providers-stopped",
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=sim_time_ns,
payload={"provider_ids": list(outcome.stopped_provider_ids)},
expected_revision=run.revision,
)
return self.store.transition(
run_id,
terminal_state,
expected_revision=event.sequence,
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=sim_time_ns,
reason=reason,
)
def reset(
self,
run_id: str,
*,
idempotency_key: str,
new_run_id: str,
new_episode_id: str,
observed_at_utc: str,
host_monotonic_ns: int,
sim_time_ns: int | None,
) -> QualificationRun:
with self._lock:
key = _idempotency_key(idempotency_key)
if len(key) > 155:
raise ValueError("reset idempotency key must contain at most 155 characters")
terminal = self.stop(
run_id,
idempotency_key=f"{key}:stop",
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=sim_time_ns,
terminal_state=RunState.ABORTED,
reason="operator-reset",
)
return self.store.create_reset_episode(
terminal.run_id,
run_id=new_run_id,
episode_id=new_episode_id,
created_at_utc=observed_at_utc,
)
def reconcile(
self,
*,
observed_at_utc: str,
host_monotonic_ns: int,
) -> tuple[QualificationRun, ...]:
reconciled: list[QualificationRun] = []
with self._lock:
for run in self.store.list_runs():
if run.state not in ACTIVE_RECOVERY_STATES:
continue
try:
outcome = self.worker.reconcile(run)
residue = outcome.residue_provider_ids
except Exception as exc:
residue = (f"reconcile-error:{type(exc).__name__}",)
if residue:
event = self.store.append_event(
run.run_id,
event_type="orchestrator.recovery-residue",
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
payload={"provider_ids": list(residue)},
expected_revision=run.revision,
)
run = self.store.load(run.run_id)
if run.revision != event.sequence:
raise QualificationRunConflictError("recovery event journal changed")
reconciled.append(
self.store.reconcile_interrupted(
run.run_id,
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
)
)
return tuple(reconciled)
def _continue_start(
self,
run: QualificationRun,
*,
observed_at_utc: str,
host_monotonic_ns: int,
) -> QualificationRun:
starting = self.store.transition(
run.run_id,
RunState.STARTING,
expected_revision=run.revision,
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
)
try:
outcome = self.worker.start(starting)
except Exception as exc:
with suppress(Exception):
self.worker.stop(starting)
return self._terminal_failure(
starting,
operation="start",
detail=_exception_detail(exc),
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=0,
)
if outcome.profile_sha256 != starting.host_profile_sha256:
with suppress(Exception):
self.worker.stop(starting)
return self._terminal_failure(
starting,
operation="profile-drift",
detail=outcome.profile_sha256,
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=0,
)
event = self.store.append_event(
run.run_id,
event_type="orchestrator.providers-started",
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=0,
payload={
"provider_ids": list(outcome.provider_ids),
"profile_sha256": outcome.profile_sha256,
},
expected_revision=starting.revision,
)
return self.store.transition(
run.run_id,
RunState.RUNNING,
expected_revision=event.sequence,
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=0,
)
def _fail_worker_operation(
self,
run: QualificationRun,
*,
operation: str,
exc: Exception,
observed_at_utc: str,
host_monotonic_ns: int,
sim_time_ns: int | None,
) -> QualificationRun:
with suppress(Exception):
self.worker.stop(run)
return self._terminal_failure(
run,
operation=operation,
detail=_exception_detail(exc),
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=sim_time_ns,
)
def _terminal_failure(
self,
run: QualificationRun,
*,
operation: str,
detail: str,
observed_at_utc: str,
host_monotonic_ns: int,
sim_time_ns: int | None,
) -> QualificationRun:
event = self.store.append_event(
run.run_id,
event_type="orchestrator.operation-failed",
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=sim_time_ns,
payload={"operation": operation, "detail": detail[:256]},
expected_revision=run.revision,
)
return self.store.transition(
run.run_id,
RunState.FAILED,
expected_revision=event.sequence,
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=sim_time_ns,
reason=f"orchestrator-{operation}-failed",
)
def _start_request(self, run_id: str, key: str) -> bool:
return any(
event.event_type == "orchestrator.start-requested"
and event.payload.get("idempotency_key") == key
for event in self.store.list_events(run_id)
)
def _has_other_start_request(self, run_id: str) -> bool:
return any(
event.event_type == "orchestrator.start-requested"
for event in self.store.list_events(run_id)
)
def _stop_request(self, run_id: str, key: str) -> QualificationEvent | None:
return next(
(
event
for event in self.store.list_events(run_id)
if event.event_type == "orchestrator.stop-requested"
and event.payload.get("idempotency_key") == key
),
None,
)
def _has_other_stop_request(self, run_id: str) -> bool:
return any(
event.event_type == "orchestrator.stop-requested"
for event in self.store.list_events(run_id)
)
def _exception_detail(exc: Exception) -> str:
message = " ".join(str(exc).split())
return type(exc).__name__ if not message else f"{type(exc).__name__}: {message}"
def _idempotency_key(value: str) -> str:
normalized = value.strip()
if not 1 <= len(normalized) <= 160:
raise ValueError("idempotency key must contain 1..160 characters")
return normalized

View File

@ -0,0 +1,391 @@
from __future__ import annotations
import os
import re
import signal
import subprocess
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Final
from k1link.artifacts import write_json_atomic
PROCESS_ID_PATTERN: Final = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
class ProcessSupervisorError(RuntimeError):
"""A provider process graph could not be owned or stopped safely."""
@dataclass(frozen=True, slots=True)
class ProcessSpec:
process_id: str
argv: tuple[str, ...]
start_order: int
shutdown_order: int
environment: tuple[tuple[str, str], ...] = ()
startup_grace_seconds: float = 0.05
ready_log_patterns: tuple[str, ...] = ()
health_timeout_seconds: float = 0.0
health_poll_interval_seconds: float = 0.05
keep_stdin_open: bool = False
interrupt_timeout_seconds: float = 2.0
terminate_timeout_seconds: float = 1.0
def __post_init__(self) -> None:
if not PROCESS_ID_PATTERN.fullmatch(self.process_id):
raise ValueError("process id must be a safe lowercase identifier")
if not self.argv or any(not item or "\x00" in item for item in self.argv):
raise ValueError("process argv must contain nonempty NUL-free values")
if self.start_order < 0 or self.shutdown_order < 0:
raise ValueError("process order must not be negative")
if (
self.startup_grace_seconds < 0
or self.health_timeout_seconds < 0
or self.health_poll_interval_seconds <= 0
or self.interrupt_timeout_seconds < 0
or self.terminate_timeout_seconds < 0
):
raise ValueError("process timeouts must not be negative")
if any(not pattern or "\x00" in pattern for pattern in self.ready_log_patterns):
raise ValueError("ready log patterns must be nonempty and NUL-free")
if len(self.ready_log_patterns) != len(set(self.ready_log_patterns)):
raise ValueError("ready log patterns must be unique")
if self.ready_log_patterns and self.health_timeout_seconds <= 0:
raise ValueError("ready log patterns require a positive health timeout")
keys = [key for key, _ in self.environment]
if len(keys) != len(set(keys)) or any(
not key or "=" in key or "\x00" in key or "\x00" in value
for key, value in self.environment
):
raise ValueError("process environment must contain unique safe entries")
@dataclass(frozen=True, slots=True)
class OwnedProcess:
process_id: str
pid: int
pgid: int
start_order: int
shutdown_order: int
stdout_path: Path
stderr_path: Path
def to_dict(self) -> dict[str, object]:
return {
"process_id": self.process_id,
"pid": self.pid,
"pgid": self.pgid,
"start_order": self.start_order,
"shutdown_order": self.shutdown_order,
"stdout_path": self.stdout_path.name,
"stderr_path": self.stderr_path.name,
}
@dataclass(frozen=True, slots=True)
class ProcessStopResult:
stopped_process_ids: tuple[str, ...]
residue_process_ids: tuple[str, ...]
class PosixProcessSupervisor:
"""Own provider PGIDs and stop them in explicit reverse dependency order."""
def __init__(
self,
runtime_root: Path,
*,
base_environment: dict[str, str] | None = None,
) -> None:
if os.name != "posix":
raise ProcessSupervisorError("S1 process supervisor requires a POSIX worker")
self.runtime_root = runtime_root.expanduser().resolve()
self.runtime_root.mkdir(mode=0o700, parents=True, exist_ok=True)
self.base_environment = dict(base_environment or {})
self._lock = threading.RLock()
self._run_id: str | None = None
self._processes: dict[str, tuple[ProcessSpec, subprocess.Popen[bytes]]] = {}
def start(self, run_id: str, specs: tuple[ProcessSpec, ...]) -> tuple[OwnedProcess, ...]:
if not PROCESS_ID_PATTERN.fullmatch(run_id):
raise ProcessSupervisorError("run id must be a safe lowercase identifier")
if not specs:
raise ProcessSupervisorError("provider graph must not be empty")
identifiers = [spec.process_id for spec in specs]
if len(identifiers) != len(set(identifiers)):
raise ProcessSupervisorError("provider process ids must be unique")
start_orders = [spec.start_order for spec in specs]
shutdown_orders = [spec.shutdown_order for spec in specs]
if len(start_orders) != len(set(start_orders)):
raise ProcessSupervisorError("provider start orders must be unique")
if len(shutdown_orders) != len(set(shutdown_orders)):
raise ProcessSupervisorError("provider shutdown orders must be unique")
with self._lock:
if self._processes:
raise ProcessSupervisorError(
f"process authority is already owned by {self._run_id}"
)
run_root = self.runtime_root / run_id
if run_root.exists():
raise ProcessSupervisorError("run-scoped process directory already exists")
run_root.mkdir(mode=0o700)
self._run_id = run_id
try:
for spec in sorted(specs, key=lambda item: item.start_order):
self._start_one(run_root, spec)
records = self.snapshot()
self._persist_registry(run_root, records, terminal=False)
return records
except BaseException:
self.stop()
raise
def snapshot(self) -> tuple[OwnedProcess, ...]:
with self._lock:
records: list[OwnedProcess] = []
for process_id, (spec, process) in self._processes.items():
return_code = process.poll()
if return_code is not None:
raise ProcessSupervisorError(f"provider {process_id} exited with {return_code}")
records.append(
OwnedProcess(
process_id=process_id,
pid=process.pid,
pgid=os.getpgid(process.pid),
start_order=spec.start_order,
shutdown_order=spec.shutdown_order,
stdout_path=self.runtime_root
/ str(self._run_id)
/ f"{process_id}.stdout.log",
stderr_path=self.runtime_root
/ str(self._run_id)
/ f"{process_id}.stderr.log",
)
)
return tuple(sorted(records, key=lambda item: item.start_order))
def stop(self) -> ProcessStopResult:
with self._lock:
stopped: list[str] = []
residue: list[str] = []
remaining: dict[str, tuple[ProcessSpec, subprocess.Popen[bytes]]] = {}
run_id = self._run_id
for process_id, (spec, process) in sorted(
self._processes.items(),
key=lambda item: item[1][0].shutdown_order,
):
pgid = process.pid
if self._stop_group(process, pgid, spec):
stopped.append(process_id)
else:
residue.append(process_id)
remaining[process_id] = (spec, process)
if run_id is not None:
run_root = self.runtime_root / run_id
records = tuple(
OwnedProcess(
process_id=process_id,
pid=process.pid,
pgid=process.pid,
start_order=spec.start_order,
shutdown_order=spec.shutdown_order,
stdout_path=run_root / f"{process_id}.stdout.log",
stderr_path=run_root / f"{process_id}.stderr.log",
)
for process_id, (spec, process) in self._processes.items()
)
self._persist_registry(
run_root,
records,
terminal=True,
residue=tuple(residue),
)
self._processes = remaining
if not remaining:
self._run_id = None
return ProcessStopResult(tuple(stopped), tuple(residue))
def residue(self) -> tuple[str, ...]:
with self._lock:
return tuple(
process_id
for process_id, (_, process) in self._processes.items()
if _group_exists(process.pid)
)
def _start_one(self, run_root: Path, spec: ProcessSpec) -> None:
stdout_path = run_root / f"{spec.process_id}.stdout.log"
stderr_path = run_root / f"{spec.process_id}.stderr.log"
environment = dict(self.base_environment)
environment.update(spec.environment)
with stdout_path.open("xb") as stdout, stderr_path.open("xb") as stderr:
process = subprocess.Popen(
spec.argv,
cwd=run_root,
env=environment,
stdin=subprocess.PIPE if spec.keep_stdin_open else subprocess.DEVNULL,
stdout=stdout,
stderr=stderr,
start_new_session=True,
close_fds=True,
)
self._processes[spec.process_id] = (spec, process)
return_code = process.poll()
if return_code is not None:
phase = "before readiness" if spec.ready_log_patterns else "during startup"
raise ProcessSupervisorError(
f"provider {spec.process_id} exited {phase} with {return_code}"
)
try:
process_group_id = os.getpgid(process.pid)
except ProcessLookupError as exc:
return_code = process.poll()
phase = "before readiness" if spec.ready_log_patterns else "during startup"
raise ProcessSupervisorError(
f"provider {spec.process_id} exited {phase} with {return_code}"
) from exc
if process_group_id != process.pid:
raise ProcessSupervisorError(
f"provider {spec.process_id} did not acquire a dedicated process group"
)
if spec.ready_log_patterns:
self._wait_until_ready(
process,
spec,
stdout_path=stdout_path,
stderr_path=stderr_path,
)
elif spec.startup_grace_seconds:
time.sleep(spec.startup_grace_seconds)
return_code = process.poll()
if return_code is not None:
raise ProcessSupervisorError(
f"provider {spec.process_id} exited during startup with {return_code}"
)
def _wait_until_ready(
self,
process: subprocess.Popen[bytes],
spec: ProcessSpec,
*,
stdout_path: Path,
stderr_path: Path,
) -> None:
pending = {pattern: pattern.encode("utf-8") for pattern in spec.ready_log_patterns}
maximum_tail_bytes = max(len(pattern) for pattern in pending.values()) - 1
deadline = time.monotonic() + spec.health_timeout_seconds
with (
stdout_path.open("rb", buffering=0) as stdout,
stderr_path.open("rb", buffering=0) as stderr,
):
streams = (stdout, stderr)
tails = [b"", b""]
while pending:
return_code = process.poll()
if return_code is not None:
raise ProcessSupervisorError(
f"provider {spec.process_id} exited before readiness with {return_code}; "
f"missing markers: {', '.join(pending)}"
)
for index, stream in enumerate(streams):
content = tails[index] + stream.read()
if content:
pending = {
pattern: encoded
for pattern, encoded in pending.items()
if encoded not in content
}
tails[index] = content[-maximum_tail_bytes:] if maximum_tail_bytes else b""
if not pending:
return
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ProcessSupervisorError(
f"provider {spec.process_id} did not reach readiness within "
f"{spec.health_timeout_seconds:g} seconds; "
f"missing markers: {', '.join(pending)}"
)
time.sleep(min(spec.health_poll_interval_seconds, remaining))
def _stop_group(
self,
process: subprocess.Popen[bytes],
pgid: int,
spec: ProcessSpec,
) -> bool:
group_signal_supported = True
for sent_signal, timeout in (
(signal.SIGINT, spec.interrupt_timeout_seconds),
(signal.SIGTERM, spec.terminate_timeout_seconds),
(signal.SIGKILL, 1.0),
):
if process.poll() is not None and not _group_exists(pgid):
process.wait(timeout=0)
_close_stdin(process)
return True
try:
if group_signal_supported:
os.killpg(pgid, sent_signal)
else:
process.send_signal(sent_signal)
except ProcessLookupError:
_close_stdin(process)
return True
except PermissionError:
# Some macOS application sandboxes deny killpg for an owned
# child session. The reviewed WSL target retains group signals;
# local development falls back to the group leader and does
# not claim target residue acceptance from this path.
group_signal_supported = False
process.send_signal(sent_signal)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if process.poll() is not None:
process.wait(timeout=0)
if not group_signal_supported or not _group_exists(pgid):
_close_stdin(process)
return True
time.sleep(0.01)
stopped = process.poll() is not None and (
not group_signal_supported or not _group_exists(pgid)
)
if stopped:
_close_stdin(process)
return stopped
def _persist_registry(
self,
run_root: Path,
records: tuple[OwnedProcess, ...],
*,
terminal: bool,
residue: tuple[str, ...] = (),
) -> None:
write_json_atomic(
run_root / "process-registry.json",
{
"schema_version": "missioncore.simulation-process-registry/v1",
"run_id": run_root.name,
"terminal": terminal,
"processes": [record.to_dict() for record in records],
"residue_process_ids": list(residue),
},
)
def _group_exists(pgid: int) -> bool:
try:
os.killpg(pgid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def _close_stdin(process: subprocess.Popen[bytes]) -> None:
if process.stdin is not None and not process.stdin.closed:
process.stdin.close()

View File

@ -0,0 +1,347 @@
from __future__ import annotations
import importlib
import math
import threading
import time
from contextlib import suppress
from dataclasses import dataclass
from typing import Any, Final
from k1link.simulation.contracts import AckermannControlSetpoint
OFFBOARD_HEARTBEAT_SECONDS: Final = 0.1
OFFBOARD_WARMUP_HEARTBEATS: Final = 12
ROS_PUBLISHER_STARTUP_TIMEOUT_SECONDS: Final = 15.0
OFFBOARD_ADMISSION_TIMEOUT_SECONDS: Final = 60.0
STOCK_ROVER_MAX_THROTTLE_SPEED_MPS: Final = 3.1
VEHICLE_COMMAND_DO_SET_MODE: Final = 176
VEHICLE_COMMAND_COMPONENT_ARM_DISARM: Final = 400
class Px4RoverControlError(RuntimeError):
"""The virtual-only PX4 rover command boundary failed closed."""
@dataclass(frozen=True, slots=True)
class Px4RoverControlSnapshot:
run_id: str
armed: bool
offboard: bool
ttl_expired_count: int
class Ros2Px4AckermannControl:
"""Publish canonical speed+steering through the PX4 ROS 2 offboard boundary."""
def __init__(
self,
*,
heartbeat_seconds: float = OFFBOARD_HEARTBEAT_SECONDS,
admission_timeout_seconds: float = OFFBOARD_ADMISSION_TIMEOUT_SECONDS,
) -> None:
if not 0.05 <= heartbeat_seconds <= 0.25:
raise ValueError("PX4 heartbeat period must remain within 4..20 Hz")
if admission_timeout_seconds <= 0:
raise ValueError("PX4 admission timeout must be positive")
self.heartbeat_seconds = heartbeat_seconds
self.admission_timeout_seconds = admission_timeout_seconds
self._lock = threading.RLock()
self._started = threading.Event()
self._control_ready = threading.Event()
self._disarmed = threading.Event()
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._run_id: str | None = None
self._latest_speed_mps = 0.0
self._latest_steering_normalized = 0.0
self._valid_until_monotonic_ns = 0
self._ttl_expired_count = 0
self._ttl_expired_for_latest = False
self._armed = False
self._offboard = False
self._disarm_requested = False
self._failure: BaseException | None = None
def start(self, run_id: str) -> Px4RoverControlSnapshot:
with self._lock:
if self._thread is not None:
raise Px4RoverControlError("PX4 command controller already owns a run")
self._run_id = run_id
self._latest_speed_mps = 0.0
self._latest_steering_normalized = 0.0
self._valid_until_monotonic_ns = 0
self._ttl_expired_for_latest = False
self._failure = None
self._stop.clear()
self._started.clear()
self._control_ready.clear()
self._disarmed.clear()
self._disarm_requested = False
self._thread = threading.Thread(
target=self._run,
name=f"px4-rover-control:{run_id}",
daemon=True,
)
self._thread.start()
if not self._started.wait(timeout=ROS_PUBLISHER_STARTUP_TIMEOUT_SECONDS):
self._abort_thread()
raise Px4RoverControlError("PX4 ROS 2 command publisher did not start")
self._raise_failure()
if not self._control_ready.wait(timeout=self.admission_timeout_seconds):
self._abort_thread()
self._raise_failure()
raise Px4RoverControlError("PX4 did not confirm armed Offboard rover control")
self._raise_failure()
return self.snapshot(run_id)
def submit(self, command: AckermannControlSetpoint) -> Px4RoverControlSnapshot:
with self._lock:
if self._run_id != command.run_id or self._thread is None:
raise Px4RoverControlError("PX4 command belongs to another or inactive run")
self._raise_failure_locked()
ttl_ns = command.valid_until_sim_ns - command.issued_at_sim_ns
self._latest_speed_mps = command.speed_mps
self._latest_steering_normalized = command.steering_normalized
self._valid_until_monotonic_ns = time.monotonic_ns() + ttl_ns
self._ttl_expired_for_latest = False
return self.snapshot(command.run_id)
def stop(self, run_id: str) -> Px4RoverControlSnapshot:
with self._lock:
if self._run_id != run_id or self._thread is None:
raise Px4RoverControlError("PX4 stop belongs to another or inactive run")
self._latest_speed_mps = 0.0
self._latest_steering_normalized = 0.0
self._valid_until_monotonic_ns = 0
self._disarm_requested = True
self._disarmed.clear()
self._disarmed.wait(timeout=2)
snapshot = self.snapshot(run_id)
self._stop.set()
thread = self._thread
thread.join(timeout=3)
if thread.is_alive():
raise Px4RoverControlError("PX4 command publisher did not stop")
self._raise_failure()
with self._lock:
self._thread = None
self._run_id = None
return snapshot
def snapshot(self, run_id: str) -> Px4RoverControlSnapshot:
with self._lock:
if self._run_id != run_id:
raise Px4RoverControlError("PX4 snapshot belongs to another run")
return Px4RoverControlSnapshot(
run_id=run_id,
armed=self._armed,
offboard=self._offboard,
ttl_expired_count=self._ttl_expired_count,
)
def _abort_thread(self) -> None:
self._stop.set()
thread = self._thread
if thread is not None:
thread.join(timeout=3)
with self._lock:
self._thread = None
self._run_id = None
def _raise_failure(self) -> None:
with self._lock:
self._raise_failure_locked()
def _raise_failure_locked(self) -> None:
if self._failure is not None:
raise Px4RoverControlError(
f"PX4 ROS 2 command publisher failed: {type(self._failure).__name__}"
) from self._failure
def _run(self) -> None:
rclpy: Any | None = None
node: Any | None = None
try:
rclpy = importlib.import_module("rclpy")
qos_module = importlib.import_module("rclpy.qos")
messages = importlib.import_module("px4_msgs.msg")
rclpy.init(args=None)
node = rclpy.create_node("missioncore_px4_ackermann_control")
qos = qos_module.QoSProfile(
reliability=qos_module.ReliabilityPolicy.BEST_EFFORT,
durability=qos_module.DurabilityPolicy.TRANSIENT_LOCAL,
history=qos_module.HistoryPolicy.KEEP_LAST,
depth=1,
)
offboard_publisher = node.create_publisher(
messages.OffboardControlMode,
"/fmu/in/offboard_control_mode",
qos,
)
throttle_publisher = node.create_publisher(
messages.RoverThrottleSetpoint,
"/fmu/in/rover_throttle_setpoint",
qos,
)
steering_publisher = node.create_publisher(
messages.RoverSteeringSetpoint,
"/fmu/in/rover_steering_setpoint",
qos,
)
command_publisher = node.create_publisher(
messages.VehicleCommand,
"/fmu/in/vehicle_command",
qos,
)
def receive_status(status: Any) -> None:
with self._lock:
self._armed = status.arming_state == messages.VehicleStatus.ARMING_STATE_ARMED
self._offboard = (
status.nav_state == messages.VehicleStatus.NAVIGATION_STATE_OFFBOARD
)
if self._armed and self._offboard:
self._control_ready.set()
node.create_subscription(
messages.VehicleStatus,
"/fmu/out/vehicle_status_v1",
receive_status,
qos,
)
self._started.set()
heartbeat_count = 0
last_mode_request_monotonic = 0.0
while not self._stop.is_set():
now_monotonic = time.monotonic()
now_monotonic_ns = time.monotonic_ns()
rclpy.spin_once(node, timeout_sec=0)
with self._lock:
expired = (
self._valid_until_monotonic_ns > 0
and now_monotonic_ns >= self._valid_until_monotonic_ns
)
if expired:
self._latest_speed_mps = 0.0
self._latest_steering_normalized = 0.0
self._valid_until_monotonic_ns = 0
if not self._ttl_expired_for_latest:
self._ttl_expired_count += 1
self._ttl_expired_for_latest = True
speed_mps = self._latest_speed_mps
steering_normalized = self._latest_steering_normalized
disarm_requested = self._disarm_requested
control_confirmed = self._armed and self._offboard
timestamp_us = int(node.get_clock().now().nanoseconds // 1_000)
self._publish_setpoint(
messages,
offboard_publisher,
throttle_publisher,
steering_publisher,
timestamp_us,
speed_mps,
steering_normalized,
)
heartbeat_count += 1
if (
heartbeat_count >= OFFBOARD_WARMUP_HEARTBEATS
and not control_confirmed
and now_monotonic - last_mode_request_monotonic >= 1.0
):
self._publish_vehicle_command(
messages,
command_publisher,
timestamp_us,
VEHICLE_COMMAND_DO_SET_MODE,
param1=1.0,
param2=6.0,
)
self._publish_vehicle_command(
messages,
command_publisher,
timestamp_us,
VEHICLE_COMMAND_COMPONENT_ARM_DISARM,
param1=1.0,
)
last_mode_request_monotonic = now_monotonic
if disarm_requested:
self._publish_vehicle_command(
messages,
command_publisher,
timestamp_us,
VEHICLE_COMMAND_COMPONENT_ARM_DISARM,
param1=0.0,
)
with self._lock:
self._armed = False
self._offboard = False
self._disarm_requested = False
self._disarmed.set()
self._stop.wait(self.heartbeat_seconds)
except BaseException as exc:
with self._lock:
self._failure = exc
self._started.set()
self._control_ready.set()
self._disarmed.set()
finally:
if node is not None:
node.destroy_node()
if rclpy is not None:
with suppress(Exception):
rclpy.shutdown()
@staticmethod
def _publish_setpoint(
messages: Any,
offboard_publisher: Any,
throttle_publisher: Any,
steering_publisher: Any,
timestamp_us: int,
speed_mps: float,
steering_normalized: float,
) -> None:
offboard = messages.OffboardControlMode()
offboard.timestamp = timestamp_us
offboard.position = False
offboard.velocity = False
offboard.acceleration = False
offboard.attitude = False
offboard.body_rate = False
offboard.thrust_and_torque = True
offboard.direct_actuator = False
offboard_publisher.publish(offboard)
throttle = messages.RoverThrottleSetpoint()
throttle.timestamp = timestamp_us
throttle.throttle_body_x = float(speed_mps / STOCK_ROVER_MAX_THROTTLE_SPEED_MPS)
throttle.throttle_body_y = math.nan
throttle_publisher.publish(throttle)
steering = messages.RoverSteeringSetpoint()
steering.timestamp = timestamp_us
steering.normalized_steering_setpoint = float(steering_normalized)
steering_publisher.publish(steering)
@staticmethod
def _publish_vehicle_command(
messages: Any,
publisher: Any,
timestamp_us: int,
command: int,
*,
param1: float,
param2: float = 0.0,
) -> None:
message = messages.VehicleCommand()
message.timestamp = timestamp_us
message.param1 = param1
message.param2 = param2
message.command = command
message.target_system = 1
message.target_component = 1
message.source_system = 1
message.source_component = 1
message.from_external = True
publisher.publish(message)

View File

@ -0,0 +1,641 @@
from __future__ import annotations
import json
import os
import shutil
import stat
import threading
from contextlib import suppress
from pathlib import Path
from typing import Any, Final
from uuid import uuid4
from k1link.artifacts import write_json_atomic
from k1link.simulation.contracts import (
ControlSetpoint,
QualificationArtifact,
QualificationEvent,
QualificationRun,
RunKind,
RunState,
SimulationContractError,
control_setpoint_from_dict,
)
MAX_RECORD_BYTES: Final = 1024 * 1024
SEQUENCE_WIDTH: Final = 20
ACTIVE_RECOVERY_STATES: Final = {
RunState.STARTING,
RunState.RUNNING,
RunState.PAUSED,
RunState.STOPPING,
}
COMMANDABLE_RUN_KINDS: Final = {
RunKind.SIMULATION_CLOSED_LOOP,
RunKind.DIGITAL_TWIN_CLOSED_LOOP,
}
TRANSITIONS: Final[dict[RunState, frozenset[RunState]]] = {
RunState.ADMITTED: frozenset({RunState.STARTING, RunState.ABORTED}),
RunState.STARTING: frozenset({RunState.RUNNING, RunState.FAILED, RunState.ABORTED}),
RunState.RUNNING: frozenset(
{RunState.PAUSED, RunState.STOPPING, RunState.FAILED, RunState.ABORTED}
),
RunState.PAUSED: frozenset(
{RunState.RUNNING, RunState.STOPPING, RunState.FAILED, RunState.ABORTED}
),
RunState.STOPPING: frozenset({RunState.COMPLETED, RunState.FAILED, RunState.ABORTED}),
RunState.COMPLETED: frozenset(),
RunState.FAILED: frozenset(),
RunState.ABORTED: frozenset(),
}
class QualificationRunStoreError(RuntimeError):
"""Base error for the append-only qualification-run repository."""
class QualificationRunNotFoundError(QualificationRunStoreError):
"""The requested qualification run is not present."""
class QualificationRunConflictError(QualificationRunStoreError):
"""The caller used stale revision/sequence state or an existing identity."""
class QualificationRunIntegrityError(QualificationRunStoreError):
"""Stored qualification evidence violates its immutable contract."""
class QualificationRunTransitionError(QualificationRunStoreError):
"""A requested lifecycle transition is not allowed."""
class QualificationRunStore:
"""Single-writer, crash-safe, append-only Polygon run repository.
The run manifest is immutable. Lifecycle, domain events, accepted commands
and artifact-index entries are one-file-per-record journals created with
exclusive filesystem semantics and fsynced before acknowledgement.
"""
def __init__(self, root: Path, *, read_only: bool = False) -> None:
configured_root = root.expanduser()
if read_only:
if not configured_root.is_dir():
raise QualificationRunIntegrityError("qualification repository is missing")
_reject_symlink(configured_root, "qualification repository")
self.root = configured_root.resolve()
else:
self.root = configured_root.resolve()
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
_reject_symlink(self.root, "qualification repository")
_chmod_private(self.root)
self.read_only = read_only
self._lock = threading.RLock()
def create(self, run: QualificationRun) -> QualificationRun:
self._require_writable()
if (
run.state is not RunState.ADMITTED
or run.revision != 0
or run.artifacts
or run.started_at_utc is not None
or run.ended_at_utc is not None
or run.terminal_reason is not None
):
raise QualificationRunTransitionError(
"a new qualification run must be admitted at revision zero"
)
final = self._run_path(run.run_id)
with self._lock:
if final.exists():
raise QualificationRunConflictError("qualification run identity already exists")
staging = self.root / f".{run.run_id}.{uuid4().hex}.tmp"
try:
staging.mkdir(mode=0o700)
for name in ("events", "commands", "artifacts"):
(staging / name).mkdir(mode=0o700)
write_json_atomic(staging / "manifest.json", run.to_manifest_dict())
_fsync_directory(staging)
try:
staging.rename(final)
except FileExistsError as exc:
raise QualificationRunConflictError(
"qualification run identity already exists"
) from exc
_fsync_directory(self.root)
finally:
if staging.exists():
shutil.rmtree(staging)
return self.load(run.run_id)
def load(self, run_id: str) -> QualificationRun:
path = self._existing_run_path(run_id)
with self._lock:
manifest = QualificationRun.from_manifest_dict(
_read_json(path / "manifest.json", "run manifest")
)
if manifest.run_id != run_id:
raise QualificationRunIntegrityError("manifest identity does not match its path")
events = self._read_events(path, run_id)
artifacts = self._read_artifacts(path, run_id)
return _replay_runtime(manifest, events, artifacts)
def list_runs(self) -> tuple[QualificationRun, ...]:
with self._lock:
run_ids = tuple(
child.name
for child in sorted(self.root.iterdir(), key=lambda item: item.name)
if child.is_dir() and not child.name.startswith(".")
)
return tuple(self.load(run_id) for run_id in run_ids)
def transition(
self,
run_id: str,
new_state: RunState,
*,
expected_revision: int,
observed_at_utc: str,
host_monotonic_ns: int,
sim_time_ns: int | None = None,
reason: str | None = None,
) -> QualificationRun:
self._require_writable()
with self._lock:
current = self.load(run_id)
if current.revision != expected_revision:
raise QualificationRunConflictError(
f"run revision is {current.revision}, not {expected_revision}"
)
if new_state not in TRANSITIONS[current.state]:
raise QualificationRunTransitionError(
f"transition {current.state.value} -> {new_state.value} is not allowed"
)
if new_state.terminal and (reason is None or not reason.strip()):
raise QualificationRunTransitionError("terminal transition requires a reason")
if not new_state.terminal and reason is not None:
raise QualificationRunTransitionError(
"a nonterminal transition cannot set a terminal reason"
)
self._append_event_locked(
current,
event_type="lifecycle.state-changed",
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=sim_time_ns,
payload={
"from": current.state.value,
"to": new_state.value,
"reason": reason,
},
)
return self.load(run_id)
def append_event(
self,
run_id: str,
*,
event_type: str,
observed_at_utc: str,
host_monotonic_ns: int,
payload: dict[str, Any],
sim_time_ns: int | None = None,
expected_revision: int | None = None,
) -> QualificationEvent:
self._require_writable()
if event_type == "lifecycle.state-changed":
raise QualificationRunTransitionError("lifecycle events must use transition()")
with self._lock:
current = self.load(run_id)
if current.state.terminal:
raise QualificationRunTransitionError(
"terminal qualification-run history is immutable"
)
if expected_revision is not None and current.revision != expected_revision:
raise QualificationRunConflictError(
f"run revision is {current.revision}, not {expected_revision}"
)
return self._append_event_locked(
current,
event_type=event_type,
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=sim_time_ns,
payload=payload,
)
def submit_command(self, command: ControlSetpoint) -> ControlSetpoint:
self._require_writable()
with self._lock:
run = self.load(command.run_id)
_validate_command_admission(command, run)
directory = self._existing_run_path(command.run_id) / "commands"
expected_sequence = _next_sequence(directory)
if command.sequence != expected_sequence:
raise QualificationRunConflictError(f"command sequence must be {expected_sequence}")
_append_json_exclusive(
directory / _sequence_name(command.sequence),
command.to_dict(),
)
return command
def submit_admitted_command(
self,
command: ControlSetpoint,
*,
admitted_run: QualificationRun,
) -> ControlSetpoint:
"""Fsync one command using the worker's single-writer admitted run snapshot."""
self._require_writable()
with self._lock:
if admitted_run.run_id != command.run_id:
raise QualificationRunConflictError("command belongs to another admitted run")
_validate_command_admission(command, admitted_run)
directory = self._existing_run_path(command.run_id) / "commands"
if command.sequence < 1:
raise QualificationRunConflictError("command sequence must be positive")
previous = directory / _sequence_name(command.sequence - 1)
if command.sequence > 1 and not previous.is_file():
raise QualificationRunConflictError("command sequence would create a journal gap")
_append_json_exclusive(
directory / _sequence_name(command.sequence),
command.to_dict(),
)
return command
def list_commands(self, run_id: str) -> tuple[ControlSetpoint, ...]:
path = self._existing_run_path(run_id)
with self._lock:
records = _read_sequence_directory(path / "commands", "command")
commands: list[ControlSetpoint] = []
for sequence, record in records:
try:
command = control_setpoint_from_dict(record)
except SimulationContractError as exc:
raise QualificationRunIntegrityError(str(exc)) from exc
if command.run_id != run_id or command.sequence != sequence:
raise QualificationRunIntegrityError(
"command identity/sequence does not match its journal path"
)
commands.append(command)
return tuple(commands)
def list_events(self, run_id: str) -> tuple[QualificationEvent, ...]:
path = self._existing_run_path(run_id)
with self._lock:
return self._read_events(path, run_id)
def register_artifact(
self,
run_id: str,
artifact: QualificationArtifact,
) -> QualificationArtifact:
self._require_writable()
with self._lock:
run = self.load(run_id)
if run.state.terminal:
raise QualificationRunTransitionError(
"terminal qualification-run artifacts are sealed"
)
directory = self._existing_run_path(run_id) / "artifacts"
if any(existing.artifact_id == artifact.artifact_id for existing in run.artifacts):
raise QualificationRunConflictError("artifact id is already registered")
sequence = _next_sequence(directory)
if artifact.sequence not in {0, sequence}:
raise QualificationRunConflictError(f"artifact sequence must be zero or {sequence}")
admitted = QualificationArtifact(
artifact_id=artifact.artifact_id,
kind=artifact.kind,
relative_path=artifact.relative_path,
sha256=artifact.sha256,
byte_length=artifact.byte_length,
source_of_record=artifact.source_of_record,
sequence=sequence,
)
record = {
"schema_version": "missioncore.qualification-artifact/v1",
"run_id": run_id,
**admitted.to_dict(),
}
_append_json_exclusive(directory / _sequence_name(sequence), record)
return admitted
def create_reset_episode(
self,
previous_run_id: str,
*,
run_id: str,
episode_id: str,
created_at_utc: str,
) -> QualificationRun:
self._require_writable()
previous = self.load(previous_run_id)
if not previous.state.terminal:
raise QualificationRunTransitionError(
"reset requires the previous run to be terminal and sealed"
)
replacement = QualificationRun(
run_id=run_id,
episode_id=episode_id,
kind=previous.kind,
state=RunState.ADMITTED,
scenario_generation=previous.scenario_generation,
scenario_sha256=previous.scenario_sha256,
profile_generation=previous.profile_generation,
profile_sha256=previous.profile_sha256,
mission_core_commit=previous.mission_core_commit,
providers=previous.providers,
host_profile_id=previous.host_profile_id,
host_profile_sha256=previous.host_profile_sha256,
seed=previous.seed,
reproducibility_tier=previous.reproducibility_tier,
authority=previous.authority,
clock_domain=previous.clock_domain,
created_at_utc=created_at_utc,
parent_run_id=previous.run_id,
)
return self.create(replacement)
def reconcile_interrupted(
self,
run_id: str,
*,
observed_at_utc: str,
host_monotonic_ns: int,
) -> QualificationRun:
self._require_writable()
run = self.load(run_id)
if run.state not in ACTIVE_RECOVERY_STATES:
return run
return self.transition(
run_id,
RunState.FAILED,
expected_revision=run.revision,
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
reason="orchestrator-recovery-interrupted",
)
def _append_event_locked(
self,
run: QualificationRun,
*,
event_type: str,
observed_at_utc: str,
host_monotonic_ns: int,
sim_time_ns: int | None,
payload: dict[str, Any],
) -> QualificationEvent:
sequence = run.revision + 1
event = QualificationEvent(
run_id=run.run_id,
sequence=sequence,
event_type=event_type,
observed_at_utc=observed_at_utc,
host_monotonic_ns=host_monotonic_ns,
sim_time_ns=sim_time_ns,
payload=payload,
)
directory = self._existing_run_path(run.run_id) / "events"
if _next_sequence(directory) != sequence:
raise QualificationRunConflictError("event journal changed concurrently")
_append_json_exclusive(directory / _sequence_name(sequence), event.to_dict())
return event
def _read_events(self, run_path: Path, run_id: str) -> tuple[QualificationEvent, ...]:
events: list[QualificationEvent] = []
for sequence, record in _read_sequence_directory(run_path / "events", "event"):
try:
event = QualificationEvent.from_dict(record)
except SimulationContractError as exc:
raise QualificationRunIntegrityError(str(exc)) from exc
if event.run_id != run_id or event.sequence != sequence:
raise QualificationRunIntegrityError(
"event identity/sequence does not match its journal path"
)
events.append(event)
return tuple(events)
def _read_artifacts(
self,
run_path: Path,
run_id: str,
) -> tuple[QualificationArtifact, ...]:
artifacts: list[QualificationArtifact] = []
identifiers: set[str] = set()
for sequence, record in _read_sequence_directory(
run_path / "artifacts",
"artifact",
):
if record.get("schema_version") != "missioncore.qualification-artifact/v1":
raise QualificationRunIntegrityError("unsupported qualification-artifact schema")
if record.get("run_id") != run_id:
raise QualificationRunIntegrityError("artifact run identity does not match")
try:
artifact = QualificationArtifact.from_dict(record)
except SimulationContractError as exc:
raise QualificationRunIntegrityError(str(exc)) from exc
if artifact.sequence != sequence:
raise QualificationRunIntegrityError(
"artifact sequence does not match its journal path"
)
if artifact.artifact_id in identifiers:
raise QualificationRunIntegrityError("artifact journal contains a duplicate id")
identifiers.add(artifact.artifact_id)
artifacts.append(artifact)
return tuple(artifacts)
def _run_path(self, run_id: str) -> Path:
try:
QualificationEvent(
run_id=run_id,
sequence=1,
event_type="identity.check",
observed_at_utc="1970-01-01T00:00:00Z",
host_monotonic_ns=0,
sim_time_ns=None,
payload={},
)
except SimulationContractError as exc:
raise QualificationRunIntegrityError(str(exc)) from exc
return self.root / run_id
def _existing_run_path(self, run_id: str) -> Path:
path = self._run_path(run_id)
if not path.is_dir():
raise QualificationRunNotFoundError("qualification run was not found")
_reject_symlink(path, "qualification run")
for name in ("events", "commands", "artifacts"):
directory = path / name
if not directory.is_dir():
raise QualificationRunIntegrityError(f"run {name} journal is missing")
_reject_symlink(directory, f"run {name} journal")
return path
def _require_writable(self) -> None:
if self.read_only:
raise QualificationRunTransitionError(
"the qualification repository is open read-only"
)
def _replay_runtime(
manifest: QualificationRun,
events: tuple[QualificationEvent, ...],
artifacts: tuple[QualificationArtifact, ...],
) -> QualificationRun:
state = RunState.ADMITTED
started_at_utc: str | None = None
ended_at_utc: str | None = None
terminal_reason: str | None = None
for event in events:
if state.terminal:
raise QualificationRunIntegrityError("event exists after terminal lifecycle state")
if event.event_type != "lifecycle.state-changed":
continue
source = event.payload.get("from")
target = event.payload.get("to")
reason = event.payload.get("reason")
if source != state.value or not isinstance(target, str):
raise QualificationRunIntegrityError("lifecycle journal does not match current state")
try:
new_state = RunState(target)
except ValueError as exc:
raise QualificationRunIntegrityError("lifecycle journal has an unknown state") from exc
if new_state not in TRANSITIONS[state]:
raise QualificationRunIntegrityError("lifecycle journal contains an illegal transition")
if new_state is RunState.RUNNING and started_at_utc is None:
started_at_utc = event.observed_at_utc
if new_state.terminal:
if not isinstance(reason, str) or not reason.strip():
raise QualificationRunIntegrityError("terminal lifecycle event has no reason")
ended_at_utc = event.observed_at_utc
terminal_reason = reason
elif reason is not None:
raise QualificationRunIntegrityError("nonterminal lifecycle event has a reason")
state = new_state
try:
return manifest.with_runtime(
state=state,
revision=len(events),
started_at_utc=started_at_utc,
ended_at_utc=ended_at_utc,
terminal_reason=terminal_reason,
artifacts=artifacts,
)
except SimulationContractError as exc:
raise QualificationRunIntegrityError(str(exc)) from exc
def _read_sequence_directory(
directory: Path,
label: str,
) -> tuple[tuple[int, dict[str, Any]], ...]:
_reject_symlink(directory, f"{label} journal")
records: list[tuple[int, dict[str, Any]]] = []
expected = 1
for path in sorted(directory.iterdir(), key=lambda item: item.name):
_reject_regular_file(path, f"{label} record")
if path.name != _sequence_name(expected):
raise QualificationRunIntegrityError(f"{label} journal is not a contiguous sequence")
records.append((expected, _read_json(path, f"{label} record")))
expected += 1
return tuple(records)
def _next_sequence(directory: Path) -> int:
return len(_read_sequence_directory(directory, "journal")) + 1
def _validate_command_admission(
command: ControlSetpoint,
run: QualificationRun,
) -> None:
if run.kind not in COMMANDABLE_RUN_KINDS:
raise QualificationRunTransitionError(
f"{run.kind.value} cannot causally accept control commands"
)
if run.state is not RunState.RUNNING:
raise QualificationRunTransitionError(
"control commands are accepted only while the run is running"
)
if command.authority_generation != run.authority.generation:
raise QualificationRunConflictError("command authority generation is stale")
ttl_ns = command.valid_until_sim_ns - command.issued_at_sim_ns
if ttl_ns > run.authority.command_ttl_max_ns:
raise QualificationRunTransitionError("command TTL exceeds the authority limit")
def _sequence_name(sequence: int) -> str:
return f"{sequence:0{SEQUENCE_WIDTH}d}.json"
def _append_json_exclusive(path: Path, payload: object) -> None:
serialized = (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode()
if len(serialized) > MAX_RECORD_BYTES:
raise QualificationRunIntegrityError("journal record exceeds the size limit")
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
descriptor = os.open(path, flags, 0o600)
except FileExistsError as exc:
raise QualificationRunConflictError("journal sequence already exists") from exc
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(serialized)
stream.flush()
os.fsync(stream.fileno())
except BaseException:
path.unlink(missing_ok=True)
raise
_fsync_directory(path.parent)
def _read_json(path: Path, label: str) -> dict[str, Any]:
_reject_regular_file(path, label)
if path.stat().st_size > MAX_RECORD_BYTES:
raise QualificationRunIntegrityError(f"{label} exceeds the size limit")
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise QualificationRunIntegrityError(f"{label} is not valid UTF-8 JSON") from exc
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
raise QualificationRunIntegrityError(f"{label} must be a JSON object")
return value
def _reject_regular_file(path: Path, label: str) -> None:
try:
mode = path.lstat().st_mode
except FileNotFoundError as exc:
raise QualificationRunIntegrityError(f"{label} is missing") from exc
if not stat.S_ISREG(mode):
raise QualificationRunIntegrityError(f"{label} must be a regular file")
def _reject_symlink(path: Path, label: str) -> None:
try:
mode = path.lstat().st_mode
except FileNotFoundError as exc:
raise QualificationRunIntegrityError(f"{label} is missing") from exc
if stat.S_ISLNK(mode):
raise QualificationRunIntegrityError(f"{label} must not be a symlink")
def _chmod_private(path: Path) -> None:
with suppress(OSError):
path.chmod(0o700)
def _fsync_directory(path: Path) -> None:
flags = os.O_RDONLY
if hasattr(os, "O_DIRECTORY"):
flags |= os.O_DIRECTORY
descriptor = os.open(path, flags)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)

1131
src/k1link/simulation/s0.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,191 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Final
import yaml
from k1link.simulation.process_supervisor import ProcessSpec
LIFECYCLE_PROFILE_SCHEMA: Final = "missioncore.stock-rover-lifecycle/v1"
EXPECTED_D_ROOT: Final = Path("/mnt/d/NDC_MISSIONCORE/simulation")
class StockRoverProfileError(ValueError):
"""The reviewed stock-rover lifecycle profile is unsafe or incomplete."""
@dataclass(frozen=True, slots=True)
class StockRoverLifecycleProfile:
profile_id: str
speed_factor: int
dwell_seconds: float
agent_ready_log_patterns: tuple[str, ...]
px4_ready_log_patterns: tuple[str, ...]
@dataclass(frozen=True, slots=True)
class StockRoverTargetPaths:
d_root: Path
px4_root: Path
agent_prefix: Path
process_home: Path
xdg_cache_home: Path
@classmethod
def from_d_root(cls, d_root: Path) -> StockRoverTargetPaths:
root = d_root.expanduser().resolve()
if root != EXPECTED_D_ROOT:
raise StockRoverProfileError(
f"stock-rover lifecycle requires exact D root {EXPECTED_D_ROOT}"
)
return cls(
d_root=root,
px4_root=root / "source/PX4-Autopilot",
agent_prefix=root / "runtime/micro-xrce-dds-agent/2.4.3",
process_home=root / "runtime/s1/home",
xdg_cache_home=root / "cache/s1/xdg",
)
@property
def agent_binary(self) -> Path:
return self.agent_prefix / "bin/MicroXRCEAgent"
@property
def scenario_path(self) -> Path:
return self.px4_root / "Tools/simulation/gz/worlds/rover.sdf"
def load_stock_rover_lifecycle_profile(path: Path) -> StockRoverLifecycleProfile:
try:
document = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, yaml.YAMLError) as exc:
raise StockRoverProfileError("stock-rover profile is not valid UTF-8 YAML") from exc
if not isinstance(document, dict) or any(not isinstance(key, str) for key in document):
raise StockRoverProfileError("stock-rover profile must be a YAML mapping")
expected_keys = {
"schema_version",
"profile_id",
"speed_factor",
"dwell_seconds",
"authority",
"agent_ready_log_patterns",
"px4_ready_log_patterns",
}
if set(document) != expected_keys:
raise StockRoverProfileError("stock-rover profile keys do not match the v1 schema")
if document["schema_version"] != LIFECYCLE_PROFILE_SCHEMA:
raise StockRoverProfileError("stock-rover profile schema is incompatible")
authority = document["authority"]
if authority != {
"simulation_or_shadow_only": True,
"actuator_authority": False,
"navigation_or_safety_accepted": False,
"direct_actuator_setpoints_allowed": False,
}:
raise StockRoverProfileError("stock-rover lifecycle cannot grant actuator authority")
profile_id = document["profile_id"]
speed_factor = document["speed_factor"]
dwell_seconds = document["dwell_seconds"]
if not isinstance(profile_id, str) or not profile_id:
raise StockRoverProfileError("stock-rover profile id must be nonempty")
if speed_factor != 1:
raise StockRoverProfileError("S1B lifecycle admits only speed factor 1")
if not isinstance(dwell_seconds, int | float) or isinstance(dwell_seconds, bool):
raise StockRoverProfileError("stock-rover dwell must be numeric")
if not 1 <= float(dwell_seconds) <= 30:
raise StockRoverProfileError("stock-rover dwell must be between 1 and 30 seconds")
return StockRoverLifecycleProfile(
profile_id=profile_id,
speed_factor=speed_factor,
dwell_seconds=float(dwell_seconds),
agent_ready_log_patterns=_patterns(
document["agent_ready_log_patterns"],
"agent",
),
px4_ready_log_patterns=_patterns(
document["px4_ready_log_patterns"],
"PX4",
),
)
def stock_rover_process_environment(
paths: StockRoverTargetPaths,
) -> dict[str, str]:
paths.process_home.mkdir(mode=0o700, parents=True, exist_ok=True)
paths.xdg_cache_home.mkdir(mode=0o700, parents=True, exist_ok=True)
return {
"HOME": str(paths.process_home),
"LANG": os.environ.get("LANG", "C.UTF-8"),
"LOGNAME": "missioncore",
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"USER": "missioncore",
"XDG_CACHE_HOME": str(paths.xdg_cache_home),
}
def stock_rover_process_specs(
paths: StockRoverTargetPaths,
profile: StockRoverLifecycleProfile,
) -> tuple[ProcessSpec, ...]:
return (
ProcessSpec(
process_id="micro-xrce-dds-agent",
argv=(
str(paths.agent_binary),
"udp4",
"-p",
"8888",
"-v",
"4",
),
start_order=10,
shutdown_order=30,
environment=(("LD_LIBRARY_PATH", str(paths.agent_prefix / "lib")),),
startup_grace_seconds=0,
ready_log_patterns=profile.agent_ready_log_patterns,
health_timeout_seconds=15,
health_poll_interval_seconds=0.1,
interrupt_timeout_seconds=3,
terminate_timeout_seconds=2,
),
ProcessSpec(
process_id="px4-gazebo-stock-rover",
argv=(
"/usr/bin/make",
"-C",
str(paths.px4_root),
"px4_sitl",
"gz_rover_ackermann",
),
start_order=20,
shutdown_order=40,
environment=(
("HEADLESS", "1"),
("PX4_SIM_SPEED_FACTOR", str(profile.speed_factor)),
),
startup_grace_seconds=0,
ready_log_patterns=profile.px4_ready_log_patterns,
health_timeout_seconds=120,
health_poll_interval_seconds=0.25,
keep_stdin_open=True,
interrupt_timeout_seconds=10,
terminate_timeout_seconds=5,
),
)
def _patterns(value: object, label: str) -> tuple[str, ...]:
if (
not isinstance(value, list)
or not value
or any(not isinstance(item, str) or not item or "\x00" in item for item in value)
):
raise StockRoverProfileError(f"{label} ready patterns must be nonempty strings")
patterns = tuple(value)
if len(patterns) != len(set(patterns)):
raise StockRoverProfileError(f"{label} ready patterns must be unique")
return patterns

View File

@ -0,0 +1,134 @@
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Protocol
from k1link.simulation.contracts import QualificationRun
from k1link.simulation.orchestrator import WorkerStartResult, WorkerStopResult
from k1link.simulation.process_supervisor import (
PosixProcessSupervisor,
ProcessSpec,
)
from k1link.simulation.s0 import S0Profile, load_s0_profile
class WorkerAdmissionError(RuntimeError):
"""The worker no longer matches the accepted S0 generation or D boundary."""
@dataclass(frozen=True, slots=True)
class WorkerAdmission:
profile: S0Profile
profile_sha256: str
artifact_run_root: PurePosixPath
process_run_root: PurePosixPath
class SimulationWorldControl(Protocol):
def pause(self, run_id: str) -> None: ...
def resume(self, run_id: str) -> None: ...
def step(self, run_id: str, step_count: int) -> int: ...
class S0WorkerGuard:
"""Bind every S1 start to the exact accepted S0 profile and D artifact root."""
def __init__(self, profile_path: Path) -> None:
self.profile_path = profile_path.expanduser().resolve()
self.profile = load_s0_profile(self.profile_path)
self.profile_sha256 = hashlib.sha256(self.profile_path.read_bytes()).hexdigest()
def admit(
self,
run: QualificationRun,
artifact_run_root: PurePosixPath,
process_run_root: PurePosixPath,
) -> WorkerAdmission:
if run.host_profile_id != self.profile.profile_id:
raise WorkerAdmissionError("run host profile id does not match accepted S0")
if run.host_profile_sha256 != self.profile_sha256:
raise WorkerAdmissionError("run host profile digest does not match accepted S0")
artifact_roots = tuple(
item.wsl_path
for item in self.profile.storage.mutable_paths
if item.identifier == "artifacts"
)
if len(artifact_roots) != 1:
raise WorkerAdmissionError("accepted S0 profile has no unique artifact root")
runtime_roots = tuple(
item.wsl_path
for item in self.profile.storage.mutable_paths
if item.identifier == "runtime"
)
if len(runtime_roots) != 1:
raise WorkerAdmissionError("accepted S0 profile has no unique runtime root")
expected_artifact_parent = artifact_roots[0] / "s1" / "runs"
expected_runtime_parent = runtime_roots[0] / "s1" / "runs"
if artifact_run_root != expected_artifact_parent / run.run_id:
raise WorkerAdmissionError("S1 run root escapes the accepted D-only layout")
if process_run_root != expected_runtime_parent / run.run_id:
raise WorkerAdmissionError("S1 process root escapes the accepted D-only layout")
if not all(
component.qualification_state == "accepted" for component in self.profile.components
):
raise WorkerAdmissionError("S0 component generation is no longer accepted")
return WorkerAdmission(
profile=self.profile,
profile_sha256=self.profile_sha256,
artifact_run_root=artifact_run_root,
process_run_root=process_run_root,
)
class LocalProcessWorkerAdapter:
"""Worker-local S1B adapter; transport and PX4 mapping remain separate ports."""
def __init__(
self,
guard: S0WorkerGuard,
supervisor: PosixProcessSupervisor,
specs: tuple[ProcessSpec, ...],
world_control: SimulationWorldControl,
) -> None:
self.guard = guard
self.supervisor = supervisor
self.specs = specs
self.world_control = world_control
def start(self, run: QualificationRun) -> WorkerStartResult:
artifact_root = next(
item.wsl_path
for item in self.guard.profile.storage.mutable_paths
if item.identifier == "artifacts"
)
artifact_run_root = artifact_root / "s1" / "runs" / run.run_id
process_run_root = PurePosixPath(self.supervisor.runtime_root.as_posix()) / run.run_id
admission = self.guard.admit(run, artifact_run_root, process_run_root)
records = self.supervisor.start(run.run_id, self.specs)
return WorkerStartResult(
provider_ids=tuple(record.process_id for record in records),
profile_sha256=admission.profile_sha256,
)
def pause(self, run: QualificationRun) -> None:
self.world_control.pause(run.run_id)
def resume(self, run: QualificationRun) -> None:
self.world_control.resume(run.run_id)
def step(self, run: QualificationRun, step_count: int) -> int:
return self.world_control.step(run.run_id, step_count)
def stop(self, run: QualificationRun) -> WorkerStopResult:
result = self.supervisor.stop()
return WorkerStopResult(
stopped_provider_ids=result.stopped_process_ids,
residue_provider_ids=result.residue_process_ids,
)
def reconcile(self, run: QualificationRun) -> WorkerStopResult:
return self.stop(run)

View File

@ -0,0 +1,859 @@
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 contextlib import suppress
from datetime import UTC, datetime
from pathlib import Path
from types import FrameType
from typing import Any, Final
from k1link.simulation.contracts import (
AckermannControlSetpoint,
AuthorityProfile,
ProviderPin,
QualificationRun,
ReproducibilityTier,
RunKind,
RunState,
)
from k1link.simulation.orchestrator import SimulationApplicationService
from k1link.simulation.process_supervisor import PosixProcessSupervisor
from k1link.simulation.px4_rover_control import (
Px4RoverControlSnapshot,
Ros2Px4AckermannControl,
)
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 (
COMMAND_ACCEPTANCE_SCHEMA,
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"
SIM_CLOCK_CACHE_MAX_AGE_NS: Final = 2_000_000_000
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()
self._latest_sim_time_ns: int | None = None
self._latest_host_monotonic_ns: int | None = None
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",
)
host_monotonic_ns = time.monotonic_ns()
with self._lock:
self._sequence += 1
sequence = self._sequence
self._latest_sim_time_ns = sim_time_ns
self._latest_host_monotonic_ns = host_monotonic_ns
return {
"schema_version": VEHICLE_STATE_SCHEMA,
"run_id": run_id,
"sequence": sequence,
"observed_at_utc": _utc_now(),
"host_monotonic_ns": host_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,
},
}
def estimate_sim_time_ns(self) -> int:
"""Return a bounded speed-factor-1 estimate anchored to a Gazebo sample."""
now_monotonic_ns = time.monotonic_ns()
with self._lock:
sim_time_ns = self._latest_sim_time_ns
sampled_at_ns = self._latest_host_monotonic_ns
if sim_time_ns is None or sampled_at_ns is None:
raise WorkerAgentError("Gazebo simulation clock has not been admitted")
age_ns = now_monotonic_ns - sampled_at_ns
if not 0 <= age_ns <= SIM_CLOCK_CACHE_MAX_AGE_NS:
raise WorkerAgentError("Gazebo simulation clock sample is stale")
return sim_time_ns + age_ns
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.command_controller = Ros2Px4AckermannControl()
self._command_lock = threading.RLock()
self._command_history: dict[str, dict[str, AckermannControlSetpoint]] = {}
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(),
)
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")
self._active_run_cache = active[0] if active else None
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:
with suppress(Exception):
self.command_controller.stop(active.run_id)
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",
)
self._active_run_cache = None
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),
)
if operation == "command":
_exact_keys(
payload,
{"run_id", "speed_mps", "steering_normalized", "idempotency_key"},
"command payload",
)
return self.command(
run_id=_safe_id(payload["run_id"], "run id"),
speed_mps=_bounded_number(payload["speed_mps"], "speed", -1.5, 1.5),
steering_normalized=_bounded_number(
payload["steering_normalized"],
"steering",
-1.0,
1.0,
),
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}")
self._active_run_cache = running
try:
self.command_controller.start(run_id)
self.collector.collect(run_id)
except Exception as exc:
with suppress(Exception):
self.command_controller.stop(run_id)
try:
self.service.stop(
run_id,
idempotency_key=f"{run_id}:command-controller-admission-failed",
observed_at_utc=_utc_now(),
host_monotonic_ns=time.monotonic_ns(),
sim_time_ns=None,
terminal_state=RunState.ABORTED,
reason="px4-command-controller-admission-failed",
)
self._active_run_cache = None
finally:
raise WorkerAgentError("PX4 command controller admission failed") from exc
self._command_history[run_id] = {
command.command_id: command
for command in self.store.list_commands(run_id)
if isinstance(command, AckermannControlSetpoint)
}
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
command_stop_error: Exception | None = None
try:
self.command_controller.stop(run_id)
except Exception as exc:
command_stop_error = exc
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,
terminal_state=(
RunState.ABORTED if command_stop_error is not None else RunState.COMPLETED
),
reason=(
"px4-command-controller-stop-failed"
if command_stop_error is not None
else "operator-stop-clean"
),
)
if command_stop_error is not None:
raise WorkerAgentError(
"PX4 command controller did not stop cleanly"
) from command_stop_error
if terminal.state is not RunState.COMPLETED:
raise WorkerAgentError(f"provider stop ended in terminal state {terminal.state.value}")
self._active_run_cache = None
return self.status()
def command(
self,
*,
run_id: str,
speed_mps: float,
steering_normalized: float,
idempotency_key: str,
) -> dict[str, Any]:
with self._command_lock:
return self._command_locked(
run_id=run_id,
speed_mps=speed_mps,
steering_normalized=steering_normalized,
idempotency_key=idempotency_key,
)
def _command_locked(
self,
*,
run_id: str,
speed_mps: float,
steering_normalized: float,
idempotency_key: str,
) -> dict[str, Any]:
active = self._active_run()
if active is None or active.run_id != run_id or active.state is not RunState.RUNNING:
raise WorkerAgentError("requested run does not own running command authority")
command_id = f"cmd-{hashlib.sha256(idempotency_key.encode()).hexdigest()[:24]}"
history = self._command_history_for(run_id)
existing = history.get(command_id)
if existing is not None:
if not isinstance(existing, AckermannControlSetpoint) or (
existing.speed_mps != speed_mps
or existing.steering_normalized != steering_normalized
):
raise WorkerAgentError("command idempotency key is bound to another setpoint")
snapshot = self.command_controller.snapshot(run_id)
return _command_acceptance(existing, snapshot)
issued_at_sim_ns = self.collector.estimate_sim_time_ns()
ttl_ns = min(active.authority.command_ttl_max_ns, 250_000_000)
command = AckermannControlSetpoint(
run_id=run_id,
command_id=command_id,
sequence=len(history) + 1,
issued_at_sim_ns=issued_at_sim_ns,
valid_until_sim_ns=issued_at_sim_ns + ttl_ns,
authority_generation=active.authority.generation,
speed_mps=speed_mps,
steering_normalized=steering_normalized,
source="mission-core-control-station",
)
self.store.submit_admitted_command(command, admitted_run=active)
history[command.command_id] = command
try:
snapshot = self.command_controller.submit(command)
except Exception as exc:
current = self.store.load(run_id)
self.store.append_event(
run_id,
event_type="command.delivery-failed",
observed_at_utc=_utc_now(),
host_monotonic_ns=time.monotonic_ns(),
sim_time_ns=issued_at_sim_ns,
payload={
"command_id": command.command_id,
"provider": "px4-ros2-offboard",
"detail": type(exc).__name__,
},
expected_revision=current.revision,
)
raise WorkerAgentError("PX4 rejected the canonical rover setpoint") from exc
return _command_acceptance(command, snapshot)
def _command_history_for(
self,
run_id: str,
) -> dict[str, AckermannControlSetpoint]:
histories = getattr(self, "_command_history", None)
if histories is None:
histories = {}
self._command_history = histories
history = histories.get(run_id)
if history is None:
commands = self.store.list_commands(run_id)
history = {
command.command_id: command
for command in commands
if isinstance(command, AckermannControlSetpoint)
}
if len(history) != len(commands):
raise WorkerAgentError("running command history contains another command contract")
histories[run_id] = history
return history
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:
if hasattr(self, "_active_run_cache"):
return self._active_run_cache
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")
result = active[0] if active else None
self._active_run_cache = result
return result
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 _bounded_number(
value: object,
label: str,
minimum: float,
maximum: float,
) -> float:
result = _number(value, label)
if not minimum <= result <= maximum:
raise WorkerAgentError(f"{label} is outside the admitted 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 _command_acceptance(
command: AckermannControlSetpoint,
snapshot: Px4RoverControlSnapshot,
) -> dict[str, Any]:
return {
"schema_version": COMMAND_ACCEPTANCE_SCHEMA,
"run_id": command.run_id,
"command_id": command.command_id,
"sequence": command.sequence,
"issued_at_sim_ns": command.issued_at_sim_ns,
"valid_until_sim_ns": command.valid_until_sim_ns,
"speed_mps": command.speed_mps,
"steering_normalized": command.steering_normalized,
"authority_scope": command.authority_scope.value,
"delivery": {
"provider": "px4-ros2-offboard",
"mode": "speed-steering",
"armed": snapshot.armed,
"offboard": snapshot.offboard,
"ttl_expired_count": snapshot.ttl_expired_count,
},
}
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())

View File

@ -0,0 +1,344 @@
from __future__ import annotations
import json
import math
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"
COMMAND_ACCEPTANCE_SCHEMA: Final = "missioncore.command-acceptance/v1"
MAX_MESSAGE_BYTES: Final = 64 * 1024
OPERATIONS: Final = frozenset({"status", "live", "start", "command", "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]: ...
def command(
self,
*,
run_id: str,
speed_mps: float,
steering_normalized: float,
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 command(
self,
*,
run_id: str,
speed_mps: float,
steering_normalized: float,
idempotency_key: str,
) -> dict[str, Any]:
result = self._request(
"command",
{
"run_id": run_id,
"speed_mps": speed_mps,
"steering_normalized": steering_normalized,
"idempotency_key": idempotency_key,
},
timeout_seconds=self.request_timeout_seconds,
)
_validate_command_acceptance(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")
def _validate_command_acceptance(value: Mapping[str, Any]) -> None:
expected = {
"schema_version",
"run_id",
"command_id",
"sequence",
"issued_at_sim_ns",
"valid_until_sim_ns",
"speed_mps",
"steering_normalized",
"authority_scope",
"delivery",
}
if set(value) != expected or value.get("schema_version") != COMMAND_ACCEPTANCE_SCHEMA:
raise SimulationWorkerGatewayError("command acceptance does not match v1")
delivery = value.get("delivery")
numeric_fields = (
"sequence",
"issued_at_sim_ns",
"valid_until_sim_ns",
"speed_mps",
"steering_normalized",
)
if (
not isinstance(value.get("run_id"), str)
or not isinstance(value.get("command_id"), str)
or any(
isinstance(value.get(field), bool)
or not isinstance(value.get(field), int | float)
or not math.isfinite(value[field])
for field in numeric_fields
)
or value.get("authority_scope") != "virtual-only"
or not isinstance(delivery, dict)
or set(delivery)
!= {
"provider",
"mode",
"armed",
"offboard",
"ttl_expired_count",
}
or delivery.get("provider") != "px4-ros2-offboard"
or delivery.get("mode") != "speed-steering"
or not isinstance(delivery.get("armed"), bool)
or not isinstance(delivery.get("offboard"), bool)
or isinstance(delivery.get("ttl_expired_count"), bool)
or not isinstance(delivery.get("ttl_expired_count"), int)
):
raise SimulationWorkerGatewayError("command acceptance contains invalid values")

View File

@ -43,6 +43,7 @@ from k1link.web.plugin_runtime import (
PluginNotFoundError,
PluginRuntimeUnavailableError,
)
from k1link.web.polygon_api import build_polygon_router
from k1link.web.session_api import build_session_router
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
@ -396,6 +397,7 @@ app.include_router(
point_color_renderers=plugin_environment.point_color_renderers,
)
)
app.include_router(build_polygon_router())
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"

View File

@ -0,0 +1,396 @@
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, Header, HTTPException, Query
from fastapi import Path as PathParameter
from pydantic import BaseModel, ConfigDict, Field
from k1link.simulation import (
QualificationRun,
QualificationRunIntegrityError,
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 публикует только квалификационные доказательства; "
"содержимое команд и mutation-маршруты в архиве отсутствуют.",
"Артефакты представлены относительными метаданными; их содержимое не публикуется этим API.",
"Терминальное состояние прогона не означает приёмку навигации, "
"восприятия или физической безопасности.",
)
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
class AckermannCommandRequest(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
speed_mps: float = Field(ge=-1.5, le=1.5, allow_inf_nan=False)
steering_normalized: float = Field(ge=-1.0, le=1.0, allow_inf_nan=False)
def configured_polygon_runs_root() -> Path | None:
raw = os.environ.get(POLYGON_RUNS_ROOT_ENV)
if raw is None or not raw.strip():
return 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"])
@router.get("/runs")
def list_polygon_runs(
limit: Annotated[int, Query(ge=1, le=100)] = 20,
) -> dict[str, Any]:
store = _open_read_only_store(root_provider)
try:
ordered = sorted(
store.list_runs(),
key=lambda run: (run.created_at_utc, run.run_id),
reverse=True,
)
except QualificationRunIntegrityError as exc:
raise HTTPException(
status_code=500,
detail="Журнал прогонов Полигона не прошёл проверку целостности.",
) from exc
return {
"schema_version": CATALOG_SCHEMA,
"access": "read-only",
"items": [_run_summary(run) for run in ordered[:limit]],
"total": len(ordered),
"limitations": list(READ_ONLY_LIMITATIONS),
}
@router.get("/runs/{run_id}")
def get_polygon_run(
run_id: Annotated[
str,
PathParameter(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"),
],
event_limit: Annotated[int, Query(ge=1, le=500)] = 200,
) -> dict[str, Any]:
store = _open_read_only_store(root_provider)
try:
run = store.load(run_id)
events = store.list_events(run_id)
commands = store.list_commands(run_id)
except QualificationRunNotFoundError as exc:
raise HTTPException(status_code=404, detail="Прогон Полигона не найден.") from exc
except QualificationRunIntegrityError as exc:
raise HTTPException(
status_code=500,
detail="Доказательства прогона не прошли проверку целостности.",
) from exc
visible_events = events[-event_limit:]
return {
"schema_version": DETAIL_SCHEMA,
"access": "read-only",
"run": {
**_run_summary(run),
"scenario_sha256": run.scenario_sha256,
"profile_sha256": run.profile_sha256,
"host_profile_sha256": run.host_profile_sha256,
"seed": run.seed,
"parent_run_id": run.parent_run_id,
"providers": [provider.to_dict() for provider in run.providers],
"authority": run.authority.to_dict(),
},
"events": [event.to_dict() for event in visible_events],
"events_total": len(events),
"events_truncated": len(visible_events) != len(events),
"commands": {
"count": len(commands),
"content_exposed": False,
},
"artifacts": [artifact.to_dict() for artifact in run.artifacts],
"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
@router.post("/worker/runs/{run_id}/commands")
def command_polygon_worker_run(
run_id: Annotated[
str,
PathParameter(pattern=r"^[a-z0-9][a-z0-9-]{0,63}$"),
],
request: AckermannCommandRequest,
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.command(
run_id=run_id,
speed_mps=request.speed_mps,
steering_normalized=request.steering_normalized,
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
def _open_read_only_store(root_provider: RootProvider) -> QualificationRunStore:
root = root_provider()
if root is None:
raise HTTPException(
status_code=503,
detail="Источник журналов Полигона не настроен на этом экземпляре Mission Core.",
)
if not root.is_absolute():
raise HTTPException(
status_code=503,
detail="Источник журналов Полигона настроен некорректно.",
)
try:
return QualificationRunStore(root, read_only=True)
except QualificationRunIntegrityError as exc:
raise HTTPException(
status_code=503,
detail="Источник журналов Полигона недоступен или настроен некорректно.",
) from exc
def _run_summary(run: QualificationRun) -> dict[str, Any]:
return {
"run_id": run.run_id,
"episode_id": run.episode_id,
"kind": run.kind.value,
"state": run.state.value,
"created_at_utc": run.created_at_utc,
"started_at_utc": run.started_at_utc,
"ended_at_utc": run.ended_at_utc,
"terminal_reason": run.terminal_reason,
"scenario_generation": run.scenario_generation,
"profile_generation": run.profile_generation,
"mission_core_commit": run.mission_core_commit,
"host_profile_id": run.host_profile_id,
"reproducibility_tier": run.reproducibility_tier.value,
"clock_domain": run.clock_domain,
"revision": run.revision,
"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)}"

372
tests/test_polygon_api.py Normal file
View File

@ -0,0 +1,372 @@
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from fastapi import APIRouter, HTTPException
from fastapi.routing import APIRoute
from k1link.simulation import (
AuthorityProfile,
ProviderPin,
QualificationArtifact,
QualificationRun,
QualificationRunStore,
ReproducibilityTier,
RunKind,
RunState,
)
from k1link.web.polygon_api import (
AckermannCommandRequest,
StartStockRoverRequest,
build_polygon_router,
)
SHA_A = "a" * 64
SHA_B = "b" * 64
SHA_C = "c" * 64
def _run(run_id: str = "s1b-ui0-001") -> 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=SHA_A,
profile_generation="stock-rover-lifecycle-v1",
profile_sha256=SHA_B,
mission_core_commit="6cb1495a1234567890abcdef1234567890abcdef",
providers=(
ProviderPin(
identifier="px4-autopilot",
version="v1.17.0",
revision="v1.17.0",
),
ProviderPin(
identifier="gazebo",
version="harmonic",
revision="8.9.0",
),
),
host_profile_id="mission-gpu-s0",
host_profile_sha256=SHA_C,
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="2026-07-24T15:35:00Z",
)
def _accepted_run(root: Path) -> QualificationRun:
store = QualificationRunStore(root)
admitted = store.create(_run())
starting = store.transition(
admitted.run_id,
RunState.STARTING,
expected_revision=0,
observed_at_utc="2026-07-24T15:35:01Z",
host_monotonic_ns=1,
)
running = store.transition(
admitted.run_id,
RunState.RUNNING,
expected_revision=starting.revision,
observed_at_utc="2026-07-24T15:35:02Z",
host_monotonic_ns=2,
sim_time_ns=0,
)
event = store.append_event(
admitted.run_id,
event_type="orchestrator.providers-started",
observed_at_utc="2026-07-24T15:35:03Z",
host_monotonic_ns=3,
sim_time_ns=1_000_000,
payload={"provider_ids": ["micro-xrce-dds-agent", "px4-gazebo-stock-rover"]},
expected_revision=running.revision,
)
store.register_artifact(
admitted.run_id,
QualificationArtifact(
artifact_id="provider-log-index",
kind="log-index",
relative_path="provider-runtime/processes.json",
sha256=SHA_A,
byte_length=512,
source_of_record=True,
),
)
stopping = store.transition(
admitted.run_id,
RunState.STOPPING,
expected_revision=event.sequence,
observed_at_utc="2026-07-24T15:35:04Z",
host_monotonic_ns=4,
sim_time_ns=2_000_000,
)
return store.transition(
admitted.run_id,
RunState.COMPLETED,
expected_revision=stopping.revision,
observed_at_utc="2026-07-24T15:35:05Z",
host_monotonic_ns=5,
sim_time_ns=2_000_000,
reason="operator-stop-clean",
)
def _endpoint(router: APIRouter, path: str, method: str) -> Callable[..., Any]:
for route in router.routes:
if isinstance(route, APIRoute) and route.path == path and method in route.methods:
return route.endpoint
raise AssertionError(f"{method} {path} route is missing")
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)]
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
assert "не настроен" in failure.value.detail
def test_polygon_api_publishes_bounded_path_free_qualification_evidence(
tmp_path: Path,
) -> None:
root = tmp_path / "runs"
accepted = _accepted_run(root)
router = build_polygon_router(root_provider=lambda: root)
catalog = _endpoint(router, "/api/v1/polygon/runs", "GET")(limit=20)
detail = _endpoint(router, "/api/v1/polygon/runs/{run_id}", "GET")(
run_id=accepted.run_id,
event_limit=2,
)
assert catalog["schema_version"] == "missioncore.polygon-run-catalog/v1"
assert catalog["access"] == "read-only"
assert catalog["total"] == 1
assert catalog["items"][0]["state"] == "completed"
assert catalog["items"][0]["terminal_reason"] == "operator-stop-clean"
assert detail["schema_version"] == "missioncore.polygon-run-detail/v1"
assert detail["access"] == "read-only"
assert detail["run"]["run_id"] == accepted.run_id
assert detail["run"]["authority"]["actuator_authority"] is False
assert detail["commands"] == {"count": 0, "content_exposed": False}
assert detail["events_total"] == accepted.revision
assert detail["events_truncated"] is True
assert len(detail["events"]) == 2
assert detail["artifacts"][0]["relative_path"] == "provider-runtime/processes.json"
serialized = repr({"catalog": catalog, "detail": detail})
assert str(tmp_path) not in serialized
assert "command_ttl_max_ns" in serialized
assert all("http" not in artifact for artifact in detail["artifacts"])
def test_polygon_api_rejects_corrupt_evidence_without_partial_response(
tmp_path: Path,
) -> None:
root = tmp_path / "runs"
accepted = _accepted_run(root)
event_path = root / accepted.run_id / "events" / "00000000000000000001.json"
event_path.write_text("{broken", encoding="utf-8")
router = build_polygon_router(root_provider=lambda: root)
with pytest.raises(HTTPException) as failure:
_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 command(
self,
*,
run_id: str,
speed_mps: float,
steering_normalized: float,
idempotency_key: str,
) -> dict[str, Any]:
self.calls.append(("command", idempotency_key))
assert run_id == self.active_run_id
return {
"schema_version": "missioncore.command-acceptance/v1",
"run_id": run_id,
"command_id": "cmd-test",
"sequence": 1,
"issued_at_sim_ns": 1_000_000,
"valid_until_sim_ns": 251_000_000,
"speed_mps": speed_mps,
"steering_normalized": steering_normalized,
"authority_scope": "virtual-only",
"delivery": {
"provider": "px4-ros2-offboard",
"mode": "speed-steering",
"armed": True,
"offboard": True,
"ttl_expired_count": 0,
},
}
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"
command = _endpoint(
enabled,
"/api/v1/polygon/worker/runs/{run_id}/commands",
"POST",
)(
run_id=running["active_run_id"],
request=AckermannCommandRequest(speed_mps=1.0, steering_normalized=-0.25),
idempotency_key="command-001",
)
assert command["authority_scope"] == "virtual-only"
assert command["delivery"]["offboard"] is True
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"),
("command", "command-001"),
("stop", "stop-001"),
]
def test_polygon_worker_command_rejects_values_outside_lab_envelope() -> None:
with pytest.raises(ValueError):
AckermannCommandRequest(speed_mps=1.51, steering_normalized=0)
with pytest.raises(ValueError):
AckermannCommandRequest(speed_mps=0, steering_normalized=-1.01)
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"

268
tests/test_simulation_s0.py Normal file
View File

@ -0,0 +1,268 @@
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import pytest
import yaml
from typer.testing import CliRunner
from k1link.simulation import (
CheckStatus,
DoctorVerdict,
S0ProfileError,
load_s0_profile,
run_s0_doctor,
)
from k1link.simulation.cli import app
PROFILE = Path(__file__).resolve().parents[1] / "simulation" / "s0" / "qualification-profile.yaml"
runner = CliRunner()
def _profile_document() -> dict[str, object]:
loaded = yaml.safe_load(PROFILE.read_text(encoding="utf-8"))
assert isinstance(loaded, dict)
return loaded
def _write_profile(tmp_path: Path, document: dict[str, object]) -> Path:
path = tmp_path / "profile.yaml"
path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8")
return path
def test_canonical_s0_profile_is_strict_and_safe() -> None:
profile = load_s0_profile(PROFILE)
assert profile.profile_id == "ai-worker-px4-gazebo-v1"
assert profile.target_host.wsl_distribution == "MissionCore-Sim"
assert profile.target_host.container_runtime == "none"
assert profile.storage.windows_root.drive.upper() == "D:"
assert profile.storage.wsl_root.as_posix() == "/mnt/d/NDC_MISSIONCORE"
assert profile.clock.authority == "gazebo:/clock"
assert profile.clock.ros_use_sim_time is True
assert profile.clock.px4_uxrce_dds_sync_enabled is False
assert profile.authority.simulation_or_shadow_only is True
assert profile.authority.actuator_authority is False
assert profile.authority.navigation_or_safety_accepted is False
assert profile.authority.direct_actuator_setpoints_allowed is False
assert profile.ros.domain_id == 0
assert profile.runtime_acceptance.world == "rover"
assert profile.runtime_acceptance.model == "rover_ackermann_0"
assert profile.runtime_acceptance.physics_step_ns == 2_000_000
assert profile.runtime_acceptance.pause_max_advance_ns == 0
assert profile.runtime_acceptance.speed_factors == (1, 2)
assert profile.runtime_acceptance.rtf_tolerance_percent == 20
assert "docker" not in profile.required_tools
assert {item.identifier for item in profile.components} >= {
"px4-autopilot",
"px4-msgs",
"gazebo",
"nav2",
}
components = {item.identifier: item for item in profile.components}
assert components["px4-gazebo-models"].resolved_commit == (
"b6127f4ec20de867e215fb5f78ae88b80f371909"
)
assert {component.qualification_state for component in components.values()} == {"accepted"}
micro_xrce = next(item for item in profile.ports if item.identifier == "micro-xrce-dds")
assert micro_xrce.host_scope == "loopback-only-netns"
assert micro_xrce.bind == "0.0.0.0"
assert {item.identifier for item in profile.processes} == {
"simulation-orchestrator",
"gazebo-server",
"px4-sitl",
"micro-xrce-dds-agent",
"ros2-bridge",
"nav2",
}
def test_profile_rejects_real_actuator_authority(tmp_path: Path) -> None:
document = _profile_document()
authority = document["authority"]
assert isinstance(authority, dict)
authority["actuator_authority"] = True
with pytest.raises(S0ProfileError, match="cannot grant real"):
load_s0_profile(_write_profile(tmp_path, document))
def test_profile_rejects_mutable_c_drive_path(tmp_path: Path) -> None:
document = _profile_document()
storage = document["storage"]
assert isinstance(storage, dict)
mutable = storage["mutable_paths"]
assert isinstance(mutable, list)
first = mutable[0]
assert isinstance(first, dict)
first["windows_path"] = r"C:\NDC_MISSIONCORE\wsl"
with pytest.raises(S0ProfileError, match="escapes D-only root"):
load_s0_profile(_write_profile(tmp_path, document))
def test_profile_rejects_duplicate_scoped_port(tmp_path: Path) -> None:
document = _profile_document()
ports = document["ports"]
assert isinstance(ports, list)
duplicate = dict(ports[0])
duplicate["id"] = "duplicate-port"
duplicate["owner"] = "simulation-orchestrator"
ports.append(duplicate)
with pytest.raises(S0ProfileError, match="duplicates a scoped binding"):
load_s0_profile(_write_profile(tmp_path, document))
def test_profile_rejects_wildcard_bind_outside_loopback_namespace(
tmp_path: Path,
) -> None:
document = _profile_document()
ports = document["ports"]
assert isinstance(ports, list)
first = ports[0]
assert isinstance(first, dict)
first["host_scope"] = "worker-wsl"
with pytest.raises(S0ProfileError, match="wildcard bind requires loopback-only-netns"):
load_s0_profile(_write_profile(tmp_path, document))
def test_profile_rejects_runtime_speed_contract_drift(tmp_path: Path) -> None:
document = _profile_document()
runtime = document["runtime_acceptance"]
assert isinstance(runtime, dict)
runtime["speed_factors"] = [1, 4]
with pytest.raises(S0ProfileError, match="exactly the 1x and 2x"):
load_s0_profile(_write_profile(tmp_path, document))
def test_doctor_never_claims_go_without_target_evidence(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"k1link.simulation.s0._target_host_match",
lambda target: (False, "test host is not target"),
)
report = run_s0_doctor(PROFILE)
assert report.verdict is DoctorVerdict.INCOMPLETE
assert report.target_host_match is False
assert any(
check.identifier == "factual-evidence" and check.status is CheckStatus.UNKNOWN
for check in report.checks
)
payload = report.to_dict()
assert payload["authority"] == {
"simulation_or_shadow_only": True,
"actuator_authority": False,
"navigation_or_safety_accepted": False,
"direct_actuator_setpoints_allowed": False,
}
def test_doctor_validates_digest_bound_evidence(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setattr(
"k1link.simulation.s0._target_host_match",
lambda target: (False, "test host is not target"),
)
profile = load_s0_profile(PROFILE)
checks: list[dict[str, object]] = []
for identifier in profile.required_evidence:
artifact = tmp_path / f"{identifier}.txt"
artifact.write_text(f"evidence for {identifier}\n", encoding="utf-8")
checks.append(
{
"id": identifier,
"status": "pass",
"artifact": artifact.name,
"sha256": hashlib.sha256(artifact.read_bytes()).hexdigest(),
"note": "synthetic unit-test evidence",
}
)
evidence = tmp_path / "evidence.yaml"
evidence.write_text(
yaml.safe_dump(
{
"schema_version": "missioncore.simulation-s0-evidence/v1",
"profile_sha256": hashlib.sha256(PROFILE.read_bytes()).hexdigest(),
"checks": checks,
},
sort_keys=False,
),
encoding="utf-8",
)
report = run_s0_doctor(PROFILE, evidence_path=evidence)
assert report.verdict is DoctorVerdict.INCOMPLETE
assert any(
check.identifier == "factual-evidence" and check.status is CheckStatus.PASS
for check in report.checks
)
def test_doctor_rejects_changed_evidence_artifact(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setattr(
"k1link.simulation.s0._target_host_match",
lambda target: (False, "test host is not target"),
)
profile = load_s0_profile(PROFILE)
checks: list[dict[str, object]] = []
artifacts: list[Path] = []
for identifier in profile.required_evidence:
artifact = tmp_path / f"{identifier}.txt"
artifact.write_text("original\n", encoding="utf-8")
artifacts.append(artifact)
checks.append(
{
"id": identifier,
"status": "pass",
"artifact": artifact.name,
"sha256": hashlib.sha256(artifact.read_bytes()).hexdigest(),
"note": "synthetic unit-test evidence",
}
)
evidence = tmp_path / "evidence.yaml"
evidence.write_text(
yaml.safe_dump(
{
"schema_version": "missioncore.simulation-s0-evidence/v1",
"profile_sha256": hashlib.sha256(PROFILE.read_bytes()).hexdigest(),
"checks": checks,
},
sort_keys=False,
),
encoding="utf-8",
)
artifacts[0].write_text("changed\n", encoding="utf-8")
report = run_s0_doctor(PROFILE, evidence_path=evidence)
assert report.verdict is DoctorVerdict.BLOCKED
assert any(
check.identifier == "factual-evidence"
and check.status is CheckStatus.FAIL
and "digest changed" in check.detail
for check in report.checks
)
def test_simulation_doctor_cli_emits_json_without_side_effects() -> None:
result = runner.invoke(app, ["s0", "doctor", "--profile", str(PROFILE), "--json"])
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["schema_version"] == "missioncore.simulation-s0-doctor/v1"
assert payload["profile_id"] == "ai-worker-px4-gazebo-v1"
assert payload["verdict"] in {"incomplete", "blocked"}
assert payload["authority"]["actuator_authority"] is False

View File

@ -0,0 +1,477 @@
from __future__ import annotations
import hashlib
import os
import sys
from pathlib import Path, PurePosixPath
import pytest
from k1link.simulation import (
ActiveQualificationRunError,
AuthorityProfile,
PosixProcessSupervisor,
ProcessSpec,
ProcessSupervisorError,
ProviderPin,
QualificationRun,
QualificationRunConflictError,
QualificationRunStore,
ReproducibilityTier,
RunKind,
RunState,
S0WorkerGuard,
SimulationApplicationService,
StockRoverTargetPaths,
WorkerAdmissionError,
WorkerStartResult,
WorkerStopResult,
load_stock_rover_lifecycle_profile,
stock_rover_process_specs,
)
PROFILE = Path(__file__).resolve().parents[1] / "simulation/s0/qualification-profile.yaml"
S1B_PROFILE = Path(__file__).resolve().parents[1] / "simulation/s1/stock-rover-lifecycle.yaml"
SHA_A = "a" * 64
SHA_B = "b" * 64
def _run(
run_id: str = "run-s1b-001",
episode_id: str = "episode-s1b-001",
*,
host_profile_sha256: str | None = None,
) -> QualificationRun:
return QualificationRun(
run_id=run_id,
episode_id=episode_id,
kind=RunKind.SIMULATION_CLOSED_LOOP,
state=RunState.ADMITTED,
scenario_generation="stock-rover-v1",
scenario_sha256=SHA_A,
profile_generation="s1-ackermann-v1",
profile_sha256=SHA_B,
mission_core_commit="12fba7126d247f809f8d135c5f68922eb08d4683",
providers=(
ProviderPin("px4-autopilot", "v1.17.0", "v1.17.0"),
ProviderPin("gazebo", "harmonic", "8.14.0"),
),
host_profile_id="ai-worker-px4-gazebo-v1",
host_profile_sha256=host_profile_sha256 or ("c" * 64),
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="2026-07-24T18:00:00Z",
)
class _FakeWorker:
def __init__(self, profile_sha256: str = "c" * 64) -> None:
self.profile_sha256 = profile_sha256
self.calls: list[str] = []
self.fail_start = False
self.residue: tuple[str, ...] = ()
def start(self, run: QualificationRun) -> WorkerStartResult:
self.calls.append(f"start:{run.run_id}")
if self.fail_start:
raise RuntimeError("synthetic start failure")
return WorkerStartResult(("gazebo-server", "px4-sitl"), self.profile_sha256)
def pause(self, run: QualificationRun) -> None:
self.calls.append(f"pause:{run.run_id}")
def resume(self, run: QualificationRun) -> None:
self.calls.append(f"resume:{run.run_id}")
def step(self, run: QualificationRun, step_count: int) -> int:
self.calls.append(f"step:{run.run_id}:{step_count}")
return step_count * 2_000_000
def stop(self, run: QualificationRun) -> WorkerStopResult:
self.calls.append(f"stop:{run.run_id}")
return WorkerStopResult(("px4-sitl", "gazebo-server"), self.residue)
def reconcile(self, run: QualificationRun) -> WorkerStopResult:
self.calls.append(f"reconcile:{run.run_id}")
return WorkerStopResult(("px4-sitl", "gazebo-server"), self.residue)
def _service(tmp_path: Path) -> tuple[SimulationApplicationService, _FakeWorker]:
store = QualificationRunStore(tmp_path)
store.create(_run())
worker = _FakeWorker()
return SimulationApplicationService(store, worker), worker
def test_application_service_owns_idempotent_start_pause_step_resume_stop(
tmp_path: Path,
) -> None:
service, worker = _service(tmp_path)
running = service.start(
"run-s1b-001",
idempotency_key="start-001",
observed_at_utc="2026-07-24T18:00:01Z",
host_monotonic_ns=1,
)
repeated = service.start(
"run-s1b-001",
idempotency_key="start-001",
observed_at_utc="2026-07-24T18:00:02Z",
host_monotonic_ns=2,
)
paused = service.pause(
running.run_id,
observed_at_utc="2026-07-24T18:00:03Z",
host_monotonic_ns=3,
sim_time_ns=2_000_000,
)
advanced = service.step(
running.run_id,
step_count=2,
observed_at_utc="2026-07-24T18:00:04Z",
host_monotonic_ns=4,
sim_time_ns=2_000_000,
)
resumed = service.resume(
running.run_id,
observed_at_utc="2026-07-24T18:00:05Z",
host_monotonic_ns=5,
sim_time_ns=6_000_000,
)
completed = service.stop(
running.run_id,
idempotency_key="stop-001",
observed_at_utc="2026-07-24T18:00:06Z",
host_monotonic_ns=6,
sim_time_ns=6_000_000,
)
repeated_stop = service.stop(
running.run_id,
idempotency_key="stop-001",
observed_at_utc="2026-07-24T18:00:07Z",
host_monotonic_ns=7,
sim_time_ns=6_000_000,
)
assert running.state is RunState.RUNNING
assert repeated.state is RunState.RUNNING
assert paused.state is RunState.PAUSED
assert advanced == 4_000_000
assert resumed.state is RunState.RUNNING
assert completed.state is RunState.COMPLETED
assert repeated_stop == completed
assert worker.calls.count("start:run-s1b-001") == 1
assert worker.calls.count("stop:run-s1b-001") == 1
def test_application_service_rejects_second_active_owner_and_rebound_keys(
tmp_path: Path,
) -> None:
store = QualificationRunStore(tmp_path)
store.create(_run())
store.create(_run("run-s1b-002", "episode-s1b-002"))
worker = _FakeWorker()
service = SimulationApplicationService(store, worker)
service.start(
"run-s1b-001",
idempotency_key="start-001",
observed_at_utc="2026-07-24T18:00:01Z",
host_monotonic_ns=1,
)
with pytest.raises(ActiveQualificationRunError, match="owned"):
service.start(
"run-s1b-002",
idempotency_key="start-002",
observed_at_utc="2026-07-24T18:00:02Z",
host_monotonic_ns=2,
)
with pytest.raises(QualificationRunConflictError, match="another start"):
service.start(
"run-s1b-001",
idempotency_key="different",
observed_at_utc="2026-07-24T18:00:02Z",
host_monotonic_ns=2,
)
def test_start_failure_and_profile_drift_fail_terminal(tmp_path: Path) -> None:
service, worker = _service(tmp_path)
worker.fail_start = True
failed = service.start(
"run-s1b-001",
idempotency_key="start-fail",
observed_at_utc="2026-07-24T18:00:01Z",
host_monotonic_ns=1,
)
assert failed.state is RunState.FAILED
assert failed.terminal_reason == "orchestrator-start-failed"
assert any(
event.payload.get("detail") == "RuntimeError: synthetic start failure"
for event in service.store.list_events("run-s1b-001")
)
store = QualificationRunStore(tmp_path / "drift")
store.create(_run())
drift_worker = _FakeWorker("d" * 64)
drift_service = SimulationApplicationService(store, drift_worker)
drifted = drift_service.start(
"run-s1b-001",
idempotency_key="start-drift",
observed_at_utc="2026-07-24T18:00:01Z",
host_monotonic_ns=1,
)
assert drifted.state is RunState.FAILED
assert drifted.terminal_reason == "orchestrator-profile-drift-failed"
assert "stop:run-s1b-001" in drift_worker.calls
def test_reset_stops_parent_and_creates_new_identity(tmp_path: Path) -> None:
service, _ = _service(tmp_path)
service.start(
"run-s1b-001",
idempotency_key="start-001",
observed_at_utc="2026-07-24T18:00:01Z",
host_monotonic_ns=1,
)
reset = service.reset(
"run-s1b-001",
idempotency_key="reset-001",
new_run_id="run-s1b-002",
new_episode_id="episode-s1b-002",
observed_at_utc="2026-07-24T18:00:02Z",
host_monotonic_ns=2,
sim_time_ns=2_000_000,
)
assert service.store.load("run-s1b-001").state is RunState.ABORTED
assert reset.state is RunState.ADMITTED
assert reset.parent_run_id == "run-s1b-001"
def test_restart_reconcile_never_resumes_and_records_residue(tmp_path: Path) -> None:
service, worker = _service(tmp_path)
service.start(
"run-s1b-001",
idempotency_key="start-001",
observed_at_utc="2026-07-24T18:00:01Z",
host_monotonic_ns=1,
)
worker.residue = ("px4-sitl",)
reconciled = service.reconcile(
observed_at_utc="2026-07-24T18:01:00Z",
host_monotonic_ns=60,
)
assert reconciled[0].state is RunState.FAILED
assert reconciled[0].terminal_reason == "orchestrator-recovery-interrupted"
assert any(
event.event_type == "orchestrator.recovery-residue"
for event in service.store.list_events("run-s1b-001")
)
@pytest.mark.skipif(os.name != "posix", reason="target supervisor is POSIX-only")
def test_process_supervisor_owns_groups_and_stops_in_declared_order(
tmp_path: Path,
) -> None:
order_path = tmp_path / "order.log"
child = (
"import os,signal,time;"
"p=os.environ['ORDER_FILE'];n=os.environ['NAME'];"
"signal.signal(signal.SIGINT,lambda *_:(open(p,'a').write(n+'\\n'),exit(0)));"
"time.sleep(60)"
)
supervisor = PosixProcessSupervisor(tmp_path / "runtime")
records = supervisor.start(
"run-process-001",
(
ProcessSpec(
process_id="gazebo-server",
argv=(sys.executable, "-c", child),
start_order=1,
shutdown_order=20,
environment=(("ORDER_FILE", str(order_path)), ("NAME", "gazebo")),
),
ProcessSpec(
process_id="px4-sitl",
argv=(sys.executable, "-c", child),
start_order=2,
shutdown_order=10,
environment=(("ORDER_FILE", str(order_path)), ("NAME", "px4")),
),
),
)
assert [record.process_id for record in records] == ["gazebo-server", "px4-sitl"]
assert all(record.pid == record.pgid for record in records)
result = supervisor.stop()
assert result.residue_process_ids == ()
assert result.stopped_process_ids == ("px4-sitl", "gazebo-server")
assert order_path.read_text(encoding="utf-8").splitlines() == ["px4", "gazebo"]
assert supervisor.residue() == ()
@pytest.mark.skipif(os.name != "posix", reason="target supervisor is POSIX-only")
def test_process_supervisor_rolls_back_partial_start(tmp_path: Path) -> None:
supervisor = PosixProcessSupervisor(tmp_path / "runtime")
with pytest.raises(ProcessSupervisorError, match="exited during startup"):
supervisor.start(
"run-process-001",
(
ProcessSpec(
process_id="long-lived",
argv=(sys.executable, "-c", "import time;time.sleep(60)"),
start_order=1,
shutdown_order=10,
),
ProcessSpec(
process_id="failed",
argv=(sys.executable, "-c", "raise SystemExit(7)"),
start_order=2,
shutdown_order=20,
),
),
)
assert supervisor.residue() == ()
@pytest.mark.skipif(os.name != "posix", reason="target supervisor is POSIX-only")
def test_process_supervisor_waits_for_all_declared_ready_markers(tmp_path: Path) -> None:
child = (
"import signal,sys,time;"
"signal.signal(signal.SIGINT,lambda *_:exit(0));"
"print('provider booting',flush=True);"
"time.sleep(0.05);"
"sys.stdout.write('transport ');sys.stdout.flush();"
"time.sleep(0.05);"
"print('connected',flush=True);"
"time.sleep(60)"
)
supervisor = PosixProcessSupervisor(tmp_path / "runtime")
records = supervisor.start(
"run-ready-001",
(
ProcessSpec(
process_id="ready-provider",
argv=(sys.executable, "-c", child),
start_order=1,
shutdown_order=1,
ready_log_patterns=("provider booting", "transport connected"),
health_timeout_seconds=1,
health_poll_interval_seconds=0.01,
),
),
)
assert tuple(record.process_id for record in records) == ("ready-provider",)
assert supervisor.stop().residue_process_ids == ()
@pytest.mark.skipif(os.name != "posix", reason="target supervisor is POSIX-only")
def test_process_supervisor_readiness_timeout_rolls_back_without_residue(
tmp_path: Path,
) -> None:
supervisor = PosixProcessSupervisor(tmp_path / "runtime")
with pytest.raises(ProcessSupervisorError, match="missing markers: never-ready"):
supervisor.start(
"run-ready-timeout-001",
(
ProcessSpec(
process_id="not-ready",
argv=(sys.executable, "-c", "import time;time.sleep(60)"),
start_order=1,
shutdown_order=1,
ready_log_patterns=("never-ready",),
health_timeout_seconds=0.05,
health_poll_interval_seconds=0.01,
),
),
)
assert supervisor.residue() == ()
@pytest.mark.skipif(os.name != "posix", reason="target supervisor is POSIX-only")
def test_process_supervisor_rejects_exit_before_readiness(tmp_path: Path) -> None:
supervisor = PosixProcessSupervisor(tmp_path / "runtime")
with pytest.raises(ProcessSupervisorError, match="exited before readiness"):
supervisor.start(
"run-ready-exit-001",
(
ProcessSpec(
process_id="early-exit",
argv=(sys.executable, "-c", "raise SystemExit(7)"),
start_order=1,
shutdown_order=1,
ready_log_patterns=("never-ready",),
health_timeout_seconds=1,
health_poll_interval_seconds=0.01,
),
),
)
assert supervisor.residue() == ()
def test_stock_rover_profile_builds_exact_reviewed_provider_graph() -> None:
profile = load_stock_rover_lifecycle_profile(S1B_PROFILE)
paths = StockRoverTargetPaths.from_d_root(Path("/mnt/d/NDC_MISSIONCORE/simulation"))
specs = stock_rover_process_specs(paths, profile)
assert profile.profile_id == "s1b-stock-rover-lifecycle-v1"
assert tuple(spec.process_id for spec in specs) == (
"micro-xrce-dds-agent",
"px4-gazebo-stock-rover",
)
assert specs[0].ready_log_patterns == ("running...",)
assert specs[1].argv[-2:] == ("px4_sitl", "gz_rover_ackermann")
assert specs[1].ready_log_patterns[-1] == (
"successfully created rt/fmu/out/vehicle_status_v1 data writer"
)
assert specs[1].keep_stdin_open
assert dict(specs[1].environment) == {
"HEADLESS": "1",
"PX4_SIM_SPEED_FACTOR": "1",
}
def test_ready_patterns_require_a_positive_timeout() -> None:
with pytest.raises(ValueError, match="positive health timeout"):
ProcessSpec(
process_id="unsafe-readiness",
argv=(sys.executable, "-c", "pass"),
start_order=1,
shutdown_order=1,
ready_log_patterns=("ready",),
)
def test_s0_worker_guard_binds_exact_digest_and_d_only_run_path() -> None:
guard = S0WorkerGuard(PROFILE)
run = _run(host_profile_sha256=hashlib.sha256(PROFILE.read_bytes()).hexdigest())
root = PurePosixPath("/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/runs/run-s1b-001")
process_root = PurePosixPath("/mnt/d/NDC_MISSIONCORE/simulation/runtime/s1/runs/run-s1b-001")
admission = guard.admit(run, root, process_root)
assert admission.profile.profile_id == "ai-worker-px4-gazebo-v1"
assert admission.profile_sha256 == run.host_profile_sha256
with pytest.raises(WorkerAdmissionError, match="escapes"):
guard.admit(run, PurePosixPath("/tmp/run-s1b-001"), process_root)
with pytest.raises(WorkerAdmissionError, match="process root"):
guard.admit(run, root, PurePosixPath("/tmp/run-s1b-001"))
with pytest.raises(WorkerAdmissionError, match="digest"):
guard.admit(_run(), root, process_root)

View File

@ -0,0 +1,461 @@
from __future__ import annotations
import json
from dataclasses import replace
from pathlib import Path
import pytest
from k1link.simulation import (
AckermannControlSetpoint,
AuthorityProfile,
CommandAuthorityScope,
DifferentialControlSetpoint,
ProviderPin,
QualificationArtifact,
QualificationRun,
QualificationRunConflictError,
QualificationRunIntegrityError,
QualificationRunStore,
QualificationRunTransitionError,
ReproducibilityTier,
RunKind,
RunState,
SimulationContractError,
)
SHA_A = "a" * 64
SHA_B = "b" * 64
SHA_C = "c" * 64
def _run(
*,
run_id: str = "run-s1-001",
episode_id: str = "episode-s1-001",
kind: RunKind = RunKind.SIMULATION_CLOSED_LOOP,
) -> QualificationRun:
return QualificationRun(
run_id=run_id,
episode_id=episode_id,
kind=kind,
state=RunState.ADMITTED,
scenario_generation="stock-rover-v1",
scenario_sha256=SHA_A,
profile_generation="s1-ackermann-v1",
profile_sha256=SHA_B,
mission_core_commit="a19053dfd47577fb66cc63f605ba29ea2314f6aa",
providers=(
ProviderPin(
identifier="px4-autopilot",
version="v1.17.0",
revision="v1.17.0",
),
ProviderPin(
identifier="gazebo",
version="harmonic",
revision="8.9.0",
),
),
host_profile_id="mission-gpu-s0",
host_profile_sha256=SHA_C,
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="2026-07-24T17:00:00Z",
)
def _start(store: QualificationRunStore, run_id: str = "run-s1-001") -> QualificationRun:
starting = store.transition(
run_id,
RunState.STARTING,
expected_revision=0,
observed_at_utc="2026-07-24T17:00:01Z",
host_monotonic_ns=1,
)
return store.transition(
run_id,
RunState.RUNNING,
expected_revision=starting.revision,
observed_at_utc="2026-07-24T17:00:02Z",
host_monotonic_ns=2,
sim_time_ns=0,
)
def test_run_manifest_is_immutable_and_runtime_replays_from_events(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path / "runs")
created = store.create(_run())
assert created.state is RunState.ADMITTED
manifest_before = (tmp_path / "runs/run-s1-001/manifest.json").read_bytes()
running = _start(store)
restored = QualificationRunStore(tmp_path / "runs").load(running.run_id)
assert restored.state is RunState.RUNNING
assert restored.revision == 2
assert restored.started_at_utc == "2026-07-24T17:00:02Z"
assert (tmp_path / "runs/run-s1-001/manifest.json").read_bytes() == manifest_before
assert len(tuple((tmp_path / "runs/run-s1-001/events").iterdir())) == 2
def test_read_only_store_replays_evidence_without_mutating_repository(
tmp_path: Path,
) -> None:
root = tmp_path / "runs"
writer = QualificationRunStore(root)
writer.create(_run())
running = _start(writer)
mode_before = root.stat().st_mode
reader = QualificationRunStore(root, read_only=True)
assert reader.load(running.run_id).state is RunState.RUNNING
assert reader.list_runs()[0].run_id == running.run_id
assert root.stat().st_mode == mode_before
with pytest.raises(QualificationRunTransitionError, match="read-only"):
reader.transition(
running.run_id,
RunState.PAUSED,
expected_revision=running.revision,
observed_at_utc="2026-07-24T17:00:03Z",
host_monotonic_ns=3,
)
def test_read_only_store_requires_an_existing_regular_repository(tmp_path: Path) -> None:
with pytest.raises(QualificationRunIntegrityError, match="missing"):
QualificationRunStore(tmp_path / "missing", read_only=True)
linked = tmp_path / "linked"
linked.symlink_to(tmp_path, target_is_directory=True)
with pytest.raises(QualificationRunIntegrityError, match="symlink"):
QualificationRunStore(linked, read_only=True)
def test_lifecycle_rejects_stale_illegal_and_post_terminal_changes(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path)
store.create(_run())
with pytest.raises(QualificationRunTransitionError, match="not allowed"):
store.transition(
"run-s1-001",
RunState.RUNNING,
expected_revision=0,
observed_at_utc="2026-07-24T17:00:01Z",
host_monotonic_ns=1,
)
running = _start(store)
with pytest.raises(QualificationRunConflictError, match="revision"):
store.transition(
running.run_id,
RunState.PAUSED,
expected_revision=0,
observed_at_utc="2026-07-24T17:00:03Z",
host_monotonic_ns=3,
)
stopping = store.transition(
running.run_id,
RunState.STOPPING,
expected_revision=running.revision,
observed_at_utc="2026-07-24T17:00:03Z",
host_monotonic_ns=3,
)
completed = store.transition(
running.run_id,
RunState.COMPLETED,
expected_revision=stopping.revision,
observed_at_utc="2026-07-24T17:00:04Z",
host_monotonic_ns=4,
reason="goal-reached",
)
assert completed.terminal_reason == "goal-reached"
assert completed.ended_at_utc == "2026-07-24T17:00:04Z"
with pytest.raises(QualificationRunTransitionError, match="immutable"):
store.append_event(
completed.run_id,
event_type="metric.observed",
observed_at_utc="2026-07-24T17:00:05Z",
host_monotonic_ns=5,
payload={"name": "distance", "value": 1.0},
)
def test_pause_resume_and_domain_event_share_monotonic_journal(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path)
store.create(_run())
running = _start(store)
event = store.append_event(
running.run_id,
event_type="watchdog.heartbeat",
observed_at_utc="2026-07-24T17:00:03Z",
host_monotonic_ns=3,
sim_time_ns=2_000_000,
payload={"authority_generation": 1},
expected_revision=2,
)
paused = store.transition(
running.run_id,
RunState.PAUSED,
expected_revision=event.sequence,
observed_at_utc="2026-07-24T17:00:04Z",
host_monotonic_ns=4,
sim_time_ns=2_000_000,
)
resumed = store.transition(
running.run_id,
RunState.RUNNING,
expected_revision=paused.revision,
observed_at_utc="2026-07-24T17:00:05Z",
host_monotonic_ns=5,
sim_time_ns=2_000_000,
)
assert event.sequence == 3
assert resumed.revision == 5
assert resumed.started_at_utc == "2026-07-24T17:00:02Z"
def test_ackermann_and_differential_commands_are_typed_and_sequenced(
tmp_path: Path,
) -> None:
store = QualificationRunStore(tmp_path)
store.create(_run())
_start(store)
first = AckermannControlSetpoint(
run_id="run-s1-001",
command_id="command-1",
sequence=1,
issued_at_sim_ns=10,
valid_until_sim_ns=200_000_010,
authority_generation=1,
speed_mps=1.5,
steering_normalized=-0.25,
)
second = DifferentialControlSetpoint(
run_id="run-s1-001",
command_id="command-2",
sequence=2,
issued_at_sim_ns=20,
valid_until_sim_ns=200_000_020,
authority_generation=1,
speed_mps=0.5,
yaw_rate_rps=0.2,
)
store.submit_command(first)
store.submit_command(second)
assert store.list_commands("run-s1-001") == (first, second)
assert first.to_dict()["authority_scope"] == "virtual-only"
assert first.to_dict()["source"] == "simulation-orchestrator"
def test_commands_fail_closed_on_state_sequence_authority_and_ttl(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path)
store.create(_run())
command = AckermannControlSetpoint(
run_id="run-s1-001",
command_id="command-1",
sequence=1,
issued_at_sim_ns=0,
valid_until_sim_ns=100,
authority_generation=1,
speed_mps=1.0,
steering_normalized=0.0,
)
with pytest.raises(QualificationRunTransitionError, match="only while"):
store.submit_command(command)
_start(store)
with pytest.raises(QualificationRunConflictError, match="authority"):
store.submit_command(replace(command, authority_generation=2))
with pytest.raises(QualificationRunConflictError, match="sequence"):
store.submit_command(replace(command, sequence=2))
with pytest.raises(QualificationRunTransitionError, match="TTL"):
store.submit_command(replace(command, valid_until_sim_ns=250_000_001))
def test_command_contract_rejects_nonfinite_and_direct_actuator_authority() -> None:
with pytest.raises(SimulationContractError, match="finite"):
AckermannControlSetpoint(
run_id="run-s1-001",
command_id="command-1",
sequence=1,
issued_at_sim_ns=0,
valid_until_sim_ns=100,
authority_generation=1,
speed_mps=float("nan"),
steering_normalized=0.0,
)
with pytest.raises(SimulationContractError, match="-1..1"):
AckermannControlSetpoint(
run_id="run-s1-001",
command_id="command-1",
sequence=1,
issued_at_sim_ns=0,
valid_until_sim_ns=100,
authority_generation=1,
speed_mps=1.0,
steering_normalized=1.1,
)
with pytest.raises(SimulationContractError, match="without actuator"):
AuthorityProfile(
generation=1,
command_ttl_max_ns=100,
heartbeat_timeout_monotonic_ns=100,
direct_actuator_setpoints_allowed=True,
)
with pytest.raises(SimulationContractError, match="virtual-only"):
replace(
AckermannControlSetpoint(
run_id="run-s1-001",
command_id="command-scope",
sequence=1,
issued_at_sim_ns=0,
valid_until_sim_ns=100,
authority_generation=1,
speed_mps=0.0,
steering_normalized=0.0,
),
authority_scope="physical", # type: ignore[arg-type]
)
assert CommandAuthorityScope.VIRTUAL_ONLY.value == "virtual-only"
with pytest.raises(SimulationContractError, match="valid explicit UTC"):
replace(_run(), created_at_utc="2026-02-30T17:00:00Z")
def test_replay_and_ungated_hil_cannot_acquire_command_authority(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path)
store.create(_run(kind=RunKind.REPLAY_SHADOW))
_start(store)
command = AckermannControlSetpoint(
run_id="run-s1-001",
command_id="command-1",
sequence=1,
issued_at_sim_ns=0,
valid_until_sim_ns=100,
authority_generation=1,
speed_mps=1.0,
steering_normalized=0.0,
)
with pytest.raises(QualificationRunTransitionError, match="cannot causally"):
store.submit_command(command)
with pytest.raises(SimulationContractError, match="separate physical"):
_run(kind=RunKind.HIL)
def test_artifact_index_is_append_only_confined_and_digest_bound(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path)
store.create(_run())
admitted = store.register_artifact(
"run-s1-001",
QualificationArtifact(
artifact_id="run-manifest-copy",
kind="source.manifest",
relative_path="source/run-manifest.json",
sha256=SHA_A,
byte_length=123,
source_of_record=True,
),
)
assert admitted.sequence == 1
assert store.load("run-s1-001").artifacts == (admitted,)
with pytest.raises(QualificationRunConflictError, match="already registered"):
store.register_artifact("run-s1-001", admitted)
with pytest.raises(SimulationContractError, match="confined"):
QualificationArtifact(
artifact_id="escape",
kind="source.log",
relative_path="../outside.log",
sha256=SHA_A,
byte_length=1,
source_of_record=True,
)
with pytest.raises(SimulationContractError, match="confined"):
replace(admitted, artifact_id="noncanonical", relative_path="source//artifact.json")
def test_reset_creates_new_episode_identity_without_overwriting_parent(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path)
store.create(_run())
running = _start(store)
stopping = store.transition(
running.run_id,
RunState.STOPPING,
expected_revision=running.revision,
observed_at_utc="2026-07-24T17:00:03Z",
host_monotonic_ns=3,
)
store.transition(
running.run_id,
RunState.ABORTED,
expected_revision=stopping.revision,
observed_at_utc="2026-07-24T17:00:04Z",
host_monotonic_ns=4,
reason="operator-reset",
)
reset = store.create_reset_episode(
running.run_id,
run_id="run-s1-002",
episode_id="episode-s1-002",
created_at_utc="2026-07-24T17:00:05Z",
)
assert reset.parent_run_id == "run-s1-001"
assert reset.state is RunState.ADMITTED
assert store.load("run-s1-001").state is RunState.ABORTED
assert {item.run_id for item in store.list_runs()} == {"run-s1-001", "run-s1-002"}
def test_restart_reconciliation_fails_active_run_without_hidden_resume(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path)
store.create(_run())
_start(store)
recovered = QualificationRunStore(tmp_path).reconcile_interrupted(
"run-s1-001",
observed_at_utc="2026-07-24T17:01:00Z",
host_monotonic_ns=1_000,
)
assert recovered.state is RunState.FAILED
assert recovered.terminal_reason == "orchestrator-recovery-interrupted"
assert len(QualificationRunStore(tmp_path).list_commands(recovered.run_id)) == 0
def test_corrupt_or_noncontiguous_journal_fails_closed(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path)
store.create(_run())
_start(store)
events = tmp_path / "run-s1-001/events"
(events / "00000000000000000002.json").rename(events / "00000000000000000003.json")
with pytest.raises(QualificationRunIntegrityError, match="contiguous"):
store.load("run-s1-001")
def test_manifest_identity_tampering_is_detected(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path)
store.create(_run())
manifest = tmp_path / "run-s1-001/manifest.json"
document = json.loads(manifest.read_text(encoding="utf-8"))
document["run_id"] = "other-run"
manifest.write_text(json.dumps(document), encoding="utf-8")
with pytest.raises(QualificationRunIntegrityError, match="identity"):
store.load("run-s1-001")

View File

@ -0,0 +1,215 @@
from __future__ import annotations
import threading
from math import isnan
from pathlib import Path
import pytest
from k1link.simulation.contracts import (
AckermannControlSetpoint,
AuthorityProfile,
ProviderPin,
QualificationRun,
ReproducibilityTier,
RunKind,
RunState,
)
from k1link.simulation.px4_rover_control import (
Px4RoverControlSnapshot,
Ros2Px4AckermannControl,
)
from k1link.simulation.run_store import QualificationRunStore
from k1link.simulation.worker_agent import SimulationWorkerAgent, WorkerAgentError
class _Collector:
def collect(self, run_id: str) -> dict[str, object]:
return {"run_id": run_id, "sim_time_ns": 1_000_000_000}
def estimate_sim_time_ns(self) -> int:
return 1_000_000_000
class _Controller:
def __init__(self) -> None:
self.commands: list[object] = []
def submit(self, command: AckermannControlSetpoint) -> Px4RoverControlSnapshot:
self.commands.append(command)
return Px4RoverControlSnapshot(
run_id=command.run_id,
armed=True,
offboard=True,
ttl_expired_count=0,
)
def snapshot(self, run_id: str) -> Px4RoverControlSnapshot:
return Px4RoverControlSnapshot(
run_id=run_id,
armed=True,
offboard=True,
ttl_expired_count=0,
)
class _Message:
pass
class _Messages:
OffboardControlMode = _Message
RoverThrottleSetpoint = _Message
RoverSteeringSetpoint = _Message
class _Publisher:
def __init__(self) -> None:
self.messages: list[_Message] = []
def publish(self, message: _Message) -> None:
self.messages.append(message)
def _running_store(root: Path) -> QualificationRunStore:
store = QualificationRunStore(root)
admitted = store.create(
QualificationRun(
run_id="s1c-command-test",
episode_id="episode-s1c-command-test",
kind=RunKind.SIMULATION_CLOSED_LOOP,
state=RunState.ADMITTED,
scenario_generation="stock-rover",
scenario_sha256="a" * 64,
profile_generation="stock-rover-lifecycle-v1",
profile_sha256="b" * 64,
mission_core_commit="c" * 40,
providers=(
ProviderPin("px4-autopilot", "v1.17.0", "v1.17.0"),
ProviderPin("gazebo", "harmonic", "8.14.0"),
),
host_profile_id="mission-gpu-s0",
host_profile_sha256="d" * 64,
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="2026-07-24T18:00:00Z",
)
)
starting = store.transition(
admitted.run_id,
RunState.STARTING,
expected_revision=0,
observed_at_utc="2026-07-24T18:00:01Z",
host_monotonic_ns=1,
)
store.transition(
admitted.run_id,
RunState.RUNNING,
expected_revision=starting.revision,
observed_at_utc="2026-07-24T18:00:02Z",
host_monotonic_ns=2,
sim_time_ns=0,
)
return store
def test_worker_agent_journals_and_idempotently_delivers_virtual_command(
tmp_path: Path,
) -> None:
agent = object.__new__(SimulationWorkerAgent)
agent.store = _running_store(tmp_path)
agent.collector = _Collector()
controller = _Controller()
agent.command_controller = controller
agent._command_lock = threading.RLock()
accepted = agent.command(
run_id="s1c-command-test",
speed_mps=1.0,
steering_normalized=-0.25,
idempotency_key="browser-intent-001",
)
repeated = agent.command(
run_id="s1c-command-test",
speed_mps=1.0,
steering_normalized=-0.25,
idempotency_key="browser-intent-001",
)
assert accepted == repeated
assert accepted["authority_scope"] == "virtual-only"
assert accepted["delivery"]["provider"] == "px4-ros2-offboard"
assert accepted["delivery"]["armed"] is True
commands = agent.store.list_commands("s1c-command-test")
assert len(commands) == 1
assert commands[0].valid_until_sim_ns - commands[0].issued_at_sim_ns == 250_000_000
assert len(controller.commands) == 1
def test_worker_agent_command_dispatch_fails_closed_on_unsafe_envelope() -> None:
agent = object.__new__(SimulationWorkerAgent)
with pytest.raises(WorkerAgentError, match="outside"):
agent.dispatch(
{
"schema_version": "missioncore.simulation-worker-request/v1",
"request_id": "request-001",
"operation": "command",
"payload": {
"run_id": "s1c-command-test",
"speed_mps": 1.51,
"steering_normalized": 0.0,
"idempotency_key": "browser-intent-002",
},
}
)
def test_worker_agent_caches_the_absence_of_an_active_run() -> None:
class EmptyStore:
def __init__(self) -> None:
self.list_calls = 0
def list_runs(self) -> tuple[QualificationRun, ...]:
self.list_calls += 1
return ()
agent = object.__new__(SimulationWorkerAgent)
store = EmptyStore()
agent.__dict__["store"] = store
first = agent.status()
second = agent.status()
assert first["available"] is True
assert first["active_run_id"] is None
assert second["active_run_id"] is None
assert store.list_calls == 1
def test_px4_adapter_maps_speed_to_stock_rover_throttle_without_direct_actuators() -> None:
publishers = [_Publisher() for _ in range(3)]
Ros2Px4AckermannControl._publish_setpoint(
_Messages,
*publishers,
123,
1.0,
-0.55,
)
offboard = publishers[0].messages[0]
throttle = publishers[1].messages[0]
steering = publishers[2].messages[0]
assert offboard.velocity is False
assert offboard.thrust_and_torque is True
assert offboard.direct_actuator is False
assert throttle.throttle_body_x == pytest.approx(1.0 / 3.1)
assert isnan(throttle.throttle_body_y)
assert steering.normalized_steering_setpoint == -0.55

View File

@ -0,0 +1,170 @@
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 _command_acceptance() -> dict[str, Any]:
return {
"schema_version": "missioncore.command-acceptance/v1",
"run_id": "s1c-command-test",
"command_id": "cmd-001",
"sequence": 1,
"issued_at_sim_ns": 1_000_000,
"valid_until_sim_ns": 251_000_000,
"speed_mps": 1.0,
"steering_normalized": -0.25,
"authority_scope": "virtual-only",
"delivery": {
"provider": "px4-ros2-offboard",
"mode": "speed-steering",
"armed": True,
"offboard": True,
"ttl_expired_count": 0,
},
}
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)
def test_unix_worker_gateway_sends_bounded_virtual_rover_command() -> None:
socket_path = Path(f"/tmp/mc-{uuid4().hex}.sock")
thread, requests = _serve_once(socket_path, _command_acceptance())
gateway = UnixSocketWorkerGateway(socket_path)
try:
accepted = gateway.command(
run_id="s1c-command-test",
speed_mps=1.0,
steering_normalized=-0.25,
idempotency_key="command-001",
)
thread.join(timeout=2)
assert accepted["delivery"]["offboard"] is True
assert requests[0]["operation"] == "command"
assert requests[0]["payload"] == {
"run_id": "s1c-command-test",
"speed_mps": 1.0,
"steering_normalized": -0.25,
"idempotency_key": "command-001",
}
finally:
socket_path.unlink(missing_ok=True)