Compare commits
22 Commits
main
...
feat/simul
| Author | SHA1 | Date |
|---|---|---|
|
|
60e79160c9 | |
|
|
17a0e7a7f5 | |
|
|
ee7d1dd26c | |
|
|
6cc40282e0 | |
|
|
1995dfdf68 | |
|
|
b7ccca3481 | |
|
|
359f0d1d39 | |
|
|
b790f29d59 | |
|
|
a78c8c83f5 | |
|
|
6cb14954d4 | |
|
|
b4e3b3b735 | |
|
|
01ec12263a | |
|
|
e1809ac9e1 | |
|
|
630d1ae819 | |
|
|
12fba7126d | |
|
|
fc8c81dbc4 | |
|
|
a19053dfd4 | |
|
|
2548bb5740 | |
|
|
fc5cbdff8a | |
|
|
c9b1009259 | |
|
|
6290fcb2ce | |
|
|
81761067e9 |
119
README.md
119
README.md
|
|
@ -121,6 +121,116 @@ 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
|
||||
top-level Polygon control surface 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, show worker/run/provider status and render a bounded
|
||||
top-down ENU trajectory from Gazebo dynamic-pose ground truth. That pose is
|
||||
explicitly `diagnostic`, not accepted PX4/ROS 2 telemetry.
|
||||
|
||||
The exact D-only generation passed 21 target tests and a real UI-driven run
|
||||
`s1c-b7ccca3-20260724t174030z-8e46b3`: both providers reached readiness, the
|
||||
browser received live `VehicleState`, the run reached `completed` after
|
||||
52.836 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.
|
||||
|
||||
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 and open the direct route:
|
||||
|
||||
```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/?workspace=polygon-run&run=<qualification-run-id>
|
||||
```
|
||||
|
||||
The archive API remains GET-only. The worker API adds status/live GETs and
|
||||
explicit start/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. Product 3D uses canonical state in browser-native Rerun/WebGL views,
|
||||
so 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
|
||||
|
|
@ -219,6 +329,13 @@ 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 planned as a seventh section. The read-only direct UI-0 run view is
|
||||
implemented inside the current shell but intentionally omitted from all
|
||||
top-level and System navigation. It opens only through
|
||||
`?workspace=polygon-run[&run=<id>]` and reads the server-owned qualification
|
||||
repository. Top-level Polygon navigation and control remain gated on accepted
|
||||
clock/frame/command/safety behavior.
|
||||
|
||||
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 +467,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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,393 @@
|
|||
import type { PolygonRunState } from "./runArchive";
|
||||
|
||||
export interface PolygonWorkerStatus {
|
||||
workerId: string;
|
||||
available: boolean;
|
||||
controlAvailable: boolean;
|
||||
activeRunId: string | null;
|
||||
runState: PolygonRunState | null;
|
||||
providerIds: string[];
|
||||
isolation: {
|
||||
network: string;
|
||||
processIdentity: string;
|
||||
artifactPolicy: "d-only";
|
||||
};
|
||||
}
|
||||
|
||||
export interface PolygonVehicleState {
|
||||
runId: string;
|
||||
sequence: number;
|
||||
observedAtUtc: string;
|
||||
hostMonotonicNs: number;
|
||||
simTimeNs: number;
|
||||
position: { x: number; y: number; z: number };
|
||||
orientation: { x: number; y: number; z: number; w: number };
|
||||
}
|
||||
|
||||
export class PolygonWorkerContractError extends Error {}
|
||||
|
||||
export class PolygonWorkerApiError extends Error {
|
||||
constructor(message: string, readonly status: number | null = null) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
type PolygonWorkerFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const RUN_STATES = new Set<PolygonRunState>([
|
||||
"admitted",
|
||||
"starting",
|
||||
"running",
|
||||
"paused",
|
||||
"stopping",
|
||||
"completed",
|
||||
"failed",
|
||||
"aborted",
|
||||
]);
|
||||
const STATUS_KEYS = new Set([
|
||||
"schema_version",
|
||||
"worker_id",
|
||||
"transport",
|
||||
"mode",
|
||||
"available",
|
||||
"control_available",
|
||||
"active_run_id",
|
||||
"run_state",
|
||||
"provider_ids",
|
||||
"isolation",
|
||||
"authority",
|
||||
]);
|
||||
const ISOLATION_KEYS = new Set(["network", "process_identity", "artifact_policy"]);
|
||||
const AUTHORITY_KEYS = new Set([
|
||||
"scope",
|
||||
"actuator_authority",
|
||||
"direct_actuator_setpoints_allowed",
|
||||
]);
|
||||
const VEHICLE_KEYS = new Set([
|
||||
"schema_version",
|
||||
"run_id",
|
||||
"sequence",
|
||||
"observed_at_utc",
|
||||
"host_monotonic_ns",
|
||||
"sim_time_ns",
|
||||
"frame_id",
|
||||
"child_frame_id",
|
||||
"pose",
|
||||
"source",
|
||||
"safety",
|
||||
]);
|
||||
const POSE_KEYS = new Set(["position_m", "orientation_xyzw"]);
|
||||
const POSITION_KEYS = new Set(["x", "y", "z"]);
|
||||
const ORIENTATION_KEYS = new Set(["x", "y", "z", "w"]);
|
||||
const SOURCE_KEYS = new Set(["provider", "topic", "signal", "quality"]);
|
||||
const SAFETY_KEYS = new Set([
|
||||
"scope",
|
||||
"actuator_authority",
|
||||
"navigation_or_safety_accepted",
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!isRecord(value)) {
|
||||
throw new PolygonWorkerContractError(`${label} должен быть объектом.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: ReadonlySet<string>,
|
||||
label: string,
|
||||
) {
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length !== expected.size || keys.some((key) => !expected.has(key))) {
|
||||
throw new PolygonWorkerContractError(`${label} содержит неизвестные или отсутствующие поля.`);
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string, maximum = 512): string {
|
||||
if (typeof value !== "string") {
|
||||
throw new PolygonWorkerContractError(`${label} должен быть строкой.`);
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized.length > maximum) {
|
||||
throw new PolygonWorkerContractError(`${label} имеет недопустимую длину.`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function safeId(value: unknown, label: string): string {
|
||||
const identifier = stringValue(value, label, 128);
|
||||
if (!SAFE_ID.test(identifier)) {
|
||||
throw new PolygonWorkerContractError(`${label} содержит небезопасный идентификатор.`);
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new PolygonWorkerContractError(`${label} должен быть boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new PolygonWorkerContractError(`${label} должен быть неотрицательным целым.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function finiteValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) >= 1e9) {
|
||||
throw new PolygonWorkerContractError(`${label} должен быть конечным числом.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function decodePolygonWorkerStatus(payload: unknown): PolygonWorkerStatus {
|
||||
const value = record(payload, "Статус Simulation Worker");
|
||||
exactKeys(value, STATUS_KEYS, "Статус Simulation Worker");
|
||||
if (
|
||||
value.schema_version !== "missioncore.simulation-worker-status/v1" ||
|
||||
value.transport !== "unix" ||
|
||||
value.mode !== "simulation"
|
||||
) {
|
||||
throw new PolygonWorkerContractError("Статус Simulation Worker имеет неизвестную схему.");
|
||||
}
|
||||
const isolation = record(value.isolation, "isolation");
|
||||
exactKeys(isolation, ISOLATION_KEYS, "isolation");
|
||||
if (isolation.artifact_policy !== "d-only") {
|
||||
throw new PolygonWorkerContractError("Simulation Worker нарушает D-only политику.");
|
||||
}
|
||||
const authority = record(value.authority, "authority");
|
||||
exactKeys(authority, AUTHORITY_KEYS, "authority");
|
||||
if (
|
||||
authority.scope !== "virtual-only" ||
|
||||
authority.actuator_authority !== false ||
|
||||
authority.direct_actuator_setpoints_allowed !== false
|
||||
) {
|
||||
throw new PolygonWorkerContractError("Simulation Worker вышел за virtual-only границу.");
|
||||
}
|
||||
if (!Array.isArray(value.provider_ids) || value.provider_ids.length > 32) {
|
||||
throw new PolygonWorkerContractError("provider_ids должен быть ограниченным массивом.");
|
||||
}
|
||||
const providerIds = value.provider_ids.map((item, index) =>
|
||||
safeId(item, `provider_ids[${index}]`));
|
||||
const activeRunId = value.active_run_id === null
|
||||
? null
|
||||
: safeId(value.active_run_id, "active_run_id");
|
||||
const runStateValue = value.run_state === null
|
||||
? null
|
||||
: stringValue(value.run_state, "run_state", 32);
|
||||
if (
|
||||
(runStateValue !== null && !RUN_STATES.has(runStateValue as PolygonRunState)) ||
|
||||
(activeRunId === null) !== (runStateValue === null)
|
||||
) {
|
||||
throw new PolygonWorkerContractError("Активный прогон и его состояние противоречат друг другу.");
|
||||
}
|
||||
return {
|
||||
workerId: safeId(value.worker_id, "worker_id"),
|
||||
available: booleanValue(value.available, "available"),
|
||||
controlAvailable: booleanValue(value.control_available, "control_available"),
|
||||
activeRunId,
|
||||
runState: runStateValue as PolygonRunState | null,
|
||||
providerIds,
|
||||
isolation: {
|
||||
network: stringValue(isolation.network, "isolation.network", 64),
|
||||
processIdentity: safeId(isolation.process_identity, "isolation.process_identity"),
|
||||
artifactPolicy: "d-only",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function decodePolygonVehicleState(payload: unknown): PolygonVehicleState {
|
||||
const value = record(payload, "VehicleState");
|
||||
exactKeys(value, VEHICLE_KEYS, "VehicleState");
|
||||
if (
|
||||
value.schema_version !== "missioncore.vehicle-state/v1" ||
|
||||
value.frame_id !== "map_enu" ||
|
||||
value.child_frame_id !== "base_link_flu"
|
||||
) {
|
||||
throw new PolygonWorkerContractError("VehicleState имеет неизвестную схему координат.");
|
||||
}
|
||||
const pose = record(value.pose, "pose");
|
||||
exactKeys(pose, POSE_KEYS, "pose");
|
||||
const position = record(pose.position_m, "position_m");
|
||||
exactKeys(position, POSITION_KEYS, "position_m");
|
||||
const orientation = record(pose.orientation_xyzw, "orientation_xyzw");
|
||||
exactKeys(orientation, ORIENTATION_KEYS, "orientation_xyzw");
|
||||
const source = record(value.source, "source");
|
||||
exactKeys(source, SOURCE_KEYS, "source");
|
||||
if (
|
||||
source.provider !== "gazebo" ||
|
||||
source.signal !== "ground-truth" ||
|
||||
source.quality !== "diagnostic"
|
||||
) {
|
||||
throw new PolygonWorkerContractError("Live-сигнал не маркирован как Gazebo diagnostic.");
|
||||
}
|
||||
const safety = record(value.safety, "safety");
|
||||
exactKeys(safety, SAFETY_KEYS, "safety");
|
||||
if (
|
||||
safety.scope !== "virtual-only" ||
|
||||
safety.actuator_authority !== false ||
|
||||
safety.navigation_or_safety_accepted !== false
|
||||
) {
|
||||
throw new PolygonWorkerContractError("VehicleState нарушает virtual-only границу.");
|
||||
}
|
||||
const observedAtUtc = stringValue(value.observed_at_utc, "observed_at_utc", 64);
|
||||
if (!Number.isFinite(Date.parse(observedAtUtc))) {
|
||||
throw new PolygonWorkerContractError("observed_at_utc должен быть ISO-датой.");
|
||||
}
|
||||
return {
|
||||
runId: safeId(value.run_id, "run_id"),
|
||||
sequence: integerValue(value.sequence, "sequence"),
|
||||
observedAtUtc,
|
||||
hostMonotonicNs: integerValue(value.host_monotonic_ns, "host_monotonic_ns"),
|
||||
simTimeNs: integerValue(value.sim_time_ns, "sim_time_ns"),
|
||||
position: {
|
||||
x: finiteValue(position.x, "position.x"),
|
||||
y: finiteValue(position.y, "position.y"),
|
||||
z: finiteValue(position.z, "position.z"),
|
||||
},
|
||||
orientation: {
|
||||
x: finiteValue(orientation.x, "orientation.x"),
|
||||
y: finiteValue(orientation.y, "orientation.y"),
|
||||
z: finiteValue(orientation.z, "orientation.z"),
|
||||
w: finiteValue(orientation.w, "orientation.w"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function responseBody(response: Response): Promise<unknown> {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.toLowerCase().startsWith("application/json")) {
|
||||
throw new PolygonWorkerContractError("Polygon Worker API вернул не JSON.");
|
||||
}
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
throw new PolygonWorkerContractError("Polygon Worker API вернул повреждённый JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
function apiError(body: unknown, fallback: string, status: number): PolygonWorkerApiError {
|
||||
if (isRecord(body) && typeof body.detail === "string") {
|
||||
const detail = body.detail.trim();
|
||||
if (detail && detail.length <= 1_000) return new PolygonWorkerApiError(detail, status);
|
||||
}
|
||||
return new PolygonWorkerApiError(`${fallback} HTTP ${status}.`, status);
|
||||
}
|
||||
|
||||
async function requestJson(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
fallback: string,
|
||||
fetcher: PolygonWorkerFetch,
|
||||
): Promise<unknown> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(url, init);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new PolygonWorkerApiError(fallback);
|
||||
}
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, fallback, response.status);
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function fetchPolygonWorkerStatus({
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: PolygonWorkerFetch;
|
||||
} = {}): Promise<PolygonWorkerStatus> {
|
||||
return decodePolygonWorkerStatus(await requestJson(
|
||||
"/api/v1/polygon/worker",
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
"Не удалось получить статус Simulation Worker.",
|
||||
fetcher,
|
||||
));
|
||||
}
|
||||
|
||||
export async function fetchPolygonVehicleState({
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: PolygonWorkerFetch;
|
||||
} = {}): Promise<PolygonVehicleState> {
|
||||
return decodePolygonVehicleState(await requestJson(
|
||||
"/api/v1/polygon/worker/live",
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
"Не удалось получить live-состояние ровера.",
|
||||
fetcher,
|
||||
));
|
||||
}
|
||||
|
||||
export async function startPolygonWorker({
|
||||
idempotencyKey,
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
idempotencyKey: string;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: PolygonWorkerFetch;
|
||||
}): Promise<PolygonWorkerStatus> {
|
||||
return decodePolygonWorkerStatus(await requestJson(
|
||||
"/api/v1/polygon/worker/runs",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": idempotencyKey,
|
||||
},
|
||||
body: JSON.stringify({ scenario_id: "stock-rover-ackermann" }),
|
||||
signal,
|
||||
},
|
||||
"Не удалось запустить Simulation Worker.",
|
||||
fetcher,
|
||||
));
|
||||
}
|
||||
|
||||
export async function stopPolygonWorker(
|
||||
runId: string,
|
||||
{
|
||||
idempotencyKey,
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
idempotencyKey: string;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: PolygonWorkerFetch;
|
||||
},
|
||||
): Promise<PolygonWorkerStatus> {
|
||||
if (!SAFE_ID.test(runId)) {
|
||||
throw new PolygonWorkerContractError("Некорректный идентификатор активного прогона.");
|
||||
}
|
||||
return decodePolygonWorkerStatus(await requestJson(
|
||||
`/api/v1/polygon/worker/runs/${encodeURIComponent(runId)}/stop`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": idempotencyKey,
|
||||
},
|
||||
body: "{}",
|
||||
signal,
|
||||
},
|
||||
"Не удалось остановить Simulation Worker.",
|
||||
fetcher,
|
||||
));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,6 +90,653 @@
|
|||
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 > 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));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
fetchPolygonVehicleState,
|
||||
fetchPolygonWorkerStatus,
|
||||
startPolygonWorker,
|
||||
stopPolygonWorker,
|
||||
type PolygonVehicleState,
|
||||
type PolygonWorkerStatus,
|
||||
} from "../core/polygon/liveWorker";
|
||||
|
||||
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 [generation, setGeneration] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionBusy) return;
|
||||
const controller = new AbortController();
|
||||
let timer: number | null = null;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const status = await fetchPolygonWorkerStatus({ signal: controller.signal });
|
||||
if (controller.signal.aborted) return;
|
||||
setWorker(status);
|
||||
setError(null);
|
||||
if (status.available && status.activeRunId && status.runState === "running") {
|
||||
const state = await fetchPolygonVehicleState({ signal: controller.signal });
|
||||
if (controller.signal.aborted) return;
|
||||
setLive(state);
|
||||
setTrajectory((current) => {
|
||||
const sameRun = current[0]?.runId === state.runId;
|
||||
const next = sameRun ? [...current, state] : [state];
|
||||
return next.slice(-120);
|
||||
});
|
||||
} else {
|
||||
setLive(null);
|
||||
if (!status.activeRunId) setTrajectory([]);
|
||||
}
|
||||
} catch (pollError) {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(message(pollError));
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
timer = window.setTimeout(() => void poll(), 1_000);
|
||||
}
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
};
|
||||
}, [actionBusy, generation]);
|
||||
|
||||
const runActive = Boolean(worker?.activeRunId);
|
||||
|
||||
const runAction = async () => {
|
||||
if (!worker?.controlAvailable || actionBusy) return;
|
||||
setActionBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const idempotencyKey = crypto.randomUUID();
|
||||
const next = worker.activeRunId
|
||||
? await stopPolygonWorker(worker.activeRunId, { idempotencyKey })
|
||||
: await startPolygonWorker({ idempotencyKey });
|
||||
setWorker(next);
|
||||
if (!next.activeRunId) {
|
||||
setLive(null);
|
||||
setTrajectory([]);
|
||||
}
|
||||
setGeneration((value) => value + 1);
|
||||
} catch (actionError) {
|
||||
setError(message(actionError));
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassSurface className="polygon-live-panel" padding="lg">
|
||||
<header className="polygon-live-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПОЛИГОН / LIVE</span>
|
||||
<h2>Stock Ackermann Rover</h2>
|
||||
<p>PX4/Gazebo исполняются на отдельном worker; браузер показывает канонический срез.</p>
|
||||
</div>
|
||||
<div>
|
||||
<StatusBadge
|
||||
tone={worker?.available ? (runActive ? "accent" : "success") : "neutral"}
|
||||
>
|
||||
{worker?.available ? (runActive ? "Симуляция идёт" : "Worker готов") : "Worker offline"}
|
||||
</StatusBadge>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={runActive ? "secondary" : "primary"}
|
||||
disabled={!worker?.controlAvailable || actionBusy}
|
||||
onClick={() => void runAction()}
|
||||
>
|
||||
{actionBusy ? "Ожидаем PX4/Gazebo…" : runActive ? "Остановить" : "Запустить"}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="polygon-live-layout">
|
||||
<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>
|
||||
<span>Прогон</span>
|
||||
<strong>{worker?.activeRunId ?? "—"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Sim time</span>
|
||||
<strong>{live ? `${(live.simTimeNs / 1e9).toFixed(2)} s` : "—"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Позиция ENU</span>
|
||||
<strong>
|
||||
{live
|
||||
? `${live.position.x.toFixed(2)} · ${live.position.y.toFixed(2)} · ${live.position.z.toFixed(2)} m`
|
||||
: "—"}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Провайдеры</span>
|
||||
<strong>{worker?.providerIds.join(" · ") || "—"}</strong>
|
||||
</div>
|
||||
<div className="polygon-live-boundary">
|
||||
<StatusBadge tone="success">Virtual only</StatusBadge>
|
||||
<p>
|
||||
Нет actuator authority. Live-поза диагностическая и пока не доказывает
|
||||
приёмку PX4/ROS 2 telemetry, navigation или safety.
|
||||
</p>
|
||||
</div>
|
||||
{error && <p className="polygon-live-error">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,380 @@
|
|||
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 fetchPolygonWorkerStatus;
|
||||
let fetchPolygonVehicleState;
|
||||
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,
|
||||
fetchPolygonWorkerStatus,
|
||||
fetchPolygonVehicleState,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
assert.equal(status.available, true);
|
||||
assert.equal(status.activeRunId, live.runId);
|
||||
assert.equal(status.isolation.artifactPolicy, "d-only");
|
||||
assert.deepEqual(live.position, { x: 1.25, y: -0.5, z: 0.18 });
|
||||
assert.throws(
|
||||
() => decodePolygonWorkerStatus(workerStatus({
|
||||
authority: {
|
||||
...workerStatus().authority,
|
||||
actuator_authority: true,
|
||||
},
|
||||
})),
|
||||
PolygonWorkerContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodePolygonVehicleState(vehicleState({
|
||||
source: {
|
||||
...vehicleState().source,
|
||||
quality: "accepted",
|
||||
},
|
||||
})),
|
||||
PolygonWorkerContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("Polygon Live uses same-origin gateway and idempotent lifecycle requests", async () => {
|
||||
const calls = [];
|
||||
const stopped = workerStatus({
|
||||
active_run_id: null,
|
||||
run_state: null,
|
||||
provider_ids: [],
|
||||
});
|
||||
const fetcher = async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url).endsWith("/live")) return jsonResponse(vehicleState());
|
||||
if (String(url).endsWith("/stop")) return jsonResponse(stopped);
|
||||
return jsonResponse(workerStatus());
|
||||
};
|
||||
|
||||
await fetchPolygonWorkerStatus({ fetcher });
|
||||
await fetchPolygonVehicleState({ fetcher });
|
||||
await startPolygonWorker({ idempotencyKey: "start-001", fetcher });
|
||||
await stopPolygonWorker("s1c-6cb1495-20260724t180000z-aabbcc", {
|
||||
idempotencyKey: "stop-001",
|
||||
fetcher,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls.map(({ url }) => url), [
|
||||
"/api/v1/polygon/worker",
|
||||
"/api/v1/polygon/worker/live",
|
||||
"/api/v1/polygon/worker/runs",
|
||||
"/api/v1/polygon/worker/runs/s1c-6cb1495-20260724t180000z-aabbcc/stop",
|
||||
]);
|
||||
assert.equal(calls[2].init.headers["Idempotency-Key"], "start-001");
|
||||
assert.equal(calls[3].init.headers["Idempotency-Key"], "stop-001");
|
||||
assert.equal(calls[2].init.body, JSON.stringify({ scenario_id: "stock-rover-ackermann" }));
|
||||
});
|
||||
|
|
@ -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/UI PASS — exact commit `b7ccca3` passed 21 focused target tests and a UI-driven D-only PX4/Gazebo run. Mission Core registered the loopback-only unprivileged worker through a private Unix socket, rendered diagnostic ENU `VehicleState`, persisted eight events and stopped both provider groups with zero residue. The run sent zero commands; PX4 command delivery/motion, canonical PX4/ROS 2 telemetry, pause/step/reset, watchdog/failsafe, navigation/safety acceptance, auth/RBAC and real actuator authority remain absent; top-level Polygon remains gated |
|
||||
|
||||
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,77 @@ 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. The exact D-only generation passed 21 target tests and one
|
||||
UI-driven provider run with diagnostic Gazebo `VehicleState`, eight persisted
|
||||
lifecycle events, terminal `completed` and zero process residue. Its
|
||||
zero-command stationary rover does not close PX4 command delivery, canonical
|
||||
PX4/ROS 2 telemetry, pause/step/reset, heartbeat/watchdog or failsafe checkers;
|
||||
these remain the next S1 increments.
|
||||
|
||||
UI-0 now 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. The Control Station opens the internal
|
||||
`polygon-run-internal` workspace only for the direct
|
||||
`?workspace=polygon-run[&run=<id>]` route; it is filtered out of System
|
||||
navigation and adds no seventh top-level section. Browser QA loaded the accepted
|
||||
archive and the S1C live worker. The hidden UI-1 panel shows
|
||||
worker/run/provider status, sim time, ENU/FLU pose and a bounded 2D trajectory.
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,793 @@
|
|||
# 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
|
||||
27–46 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;
|
||||
- UI-0 is implemented as a hidden direct read-only Control Station workspace
|
||||
over GET-only Polygon API routes. 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;
|
||||
- the hidden UI-1 live panel starts/stops the stock rover through Mission Core,
|
||||
polls canonical worker state and renders a bounded ENU trajectory from
|
||||
Gazebo dynamic-pose ground truth. The signal is marked `diagnostic`;
|
||||
- the UI-driven target run
|
||||
`s1c-b7ccca3-20260724t174030z-8e46b3` reached `completed` with eight
|
||||
persisted events, 52.836 seconds of Gazebo time and zero provider residue;
|
||||
it contained zero commands and therefore does not prove rover motion or PX4
|
||||
command delivery;
|
||||
- `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;
|
||||
- implement a top-level UI before the backend S1 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. Browser-native Rerun/WebGL/canvas components
|
||||
provide orbit, zoom and layer controls. 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 | 2–4 days | Product thesis, SRS, ADR and canonical contracts accepted |
|
||||
| S0 | 3–5 days | Target compatibility, D-only runtime, exact pins, clock/process/resource evidence |
|
||||
| S1 | 7–12 days | Stock Ackermann and Differential lifecycle/control/failsafe evidence |
|
||||
| S2 | 15–25 days | LiDAR/odometry/Nav2 baseline, evaluator and obstacle scenarios |
|
||||
| S2B | 5–8 days | Representative 30-world BARN pilot |
|
||||
| S3 | 20–35 days | K1-like virtual sensors and Mission Core perception closed loop |
|
||||
| S4 | 10–20 days | Canonical replay/shadow adapters and reports |
|
||||
| S5 | 20–40 days | Attested RAVNOVES00 digital twin |
|
||||
| S6 | 20–40+ 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.
|
||||
|
||||
### 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 planned as the seventh top-level bounded context:
|
||||
|
||||
```text
|
||||
Center | Fleet | Observation | Polygon | Missions | Data | System
|
||||
```
|
||||
|
||||
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. The UI opens only through
|
||||
`?workspace=polygon-run[&run=<run-id>]`; its hidden workspace is filtered from
|
||||
System navigation and no seventh top-level section is added.
|
||||
|
||||
UI-0 has no PX4 transport, no lifecycle/command operations and no top-level
|
||||
Polygon navigation claim. It is a development/acceptance surface backed only
|
||||
by the server-owned run repository. A shared review deployment requires a
|
||||
reviewed read-only D mount or a co-located backend; SSH is not a product data
|
||||
plane.
|
||||
|
||||
### UI-1 — top-level Polygon
|
||||
|
||||
Top-level UI implementation 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.
|
||||
|
||||
UI-1 may present Scenarios, Run, Control, History, Compare and Report. It 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.8–1.2 |
|
||||
| 2× | 0 ns | +2,000,000 ns / +1 iteration | 1.967258 | 1.6–2.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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
# SIM S1C live-worker runbook
|
||||
|
||||
## Scope
|
||||
|
||||
This runbook starts 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:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8765/?workspace=polygon-run
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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;
|
||||
- [ ] command count remains zero until the command-delivery checker is accepted.
|
||||
|
||||
## 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.
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run the persistent Simulation Worker agent in a fresh loopback-only network
|
||||
# namespace. The Unix socket remains reachable from Mission Core because the
|
||||
# worker shares the host mount namespace while PX4/Gazebo have no routed NIC.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly EXPECTED_DISTRO="MissionCore-Sim"
|
||||
readonly D_ROOT="/mnt/d/NDC_MISSIONCORE/simulation"
|
||||
readonly SOURCE_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
readonly MISSION_CORE_COMMIT="${1:-}"
|
||||
readonly SOCKET_ROOT="/run/missioncore-sim"
|
||||
readonly SOCKET_PATH="${SOCKET_ROOT}/worker.sock"
|
||||
|
||||
if [[ -z "${MISSION_CORE_COMMIT}" ]]; then
|
||||
echo "usage: $0 <exact-40-character-mission-core-commit>" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "${SOURCE_ROOT}" != "${D_ROOT}/source/"* ]]; then
|
||||
echo "error: worker source generation must be staged below the reviewed D root" >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! [[ "${MISSION_CORE_COMMIT}" =~ ^[a-f0-9]{40}$ ]]; then
|
||||
echo "error: Mission Core commit must contain exactly 40 lowercase hex characters" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ "${MISSIONCORE_S1_NETNS:-0}" != "1" ]]; then
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "error: initial worker invocation must run as root to create the namespace" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "${WSL_DISTRO_NAME:-}" != "${EXPECTED_DISTRO}" ]]; then
|
||||
echo "error: expected WSL distro ${EXPECTED_DISTRO}" >&2
|
||||
exit 2
|
||||
fi
|
||||
install -d -m 0700 -o missioncore -g missioncore -- "${SOCKET_ROOT}"
|
||||
if [[ -e "${SOCKET_PATH}" && ! -S "${SOCKET_PATH}" ]]; then
|
||||
echo "error: worker socket path is occupied by a non-socket" >&2
|
||||
exit 2
|
||||
fi
|
||||
exec unshare --net -- bash -c '
|
||||
set -e
|
||||
ip link set lo up
|
||||
exec runuser -u missioncore -- env MISSIONCORE_S1_NETNS=1 "$@"
|
||||
' bash "$0" "$@"
|
||||
fi
|
||||
|
||||
if [[ "${EUID}" -eq 0 || "${WSL_DISTRO_NAME:-}" != "${EXPECTED_DISTRO}" ]]; then
|
||||
echo "error: worker agent escaped the reviewed worker identity" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exec env \
|
||||
PYTHONPATH="${SOURCE_ROOT}/src" \
|
||||
python3 -m k1link.simulation.worker_agent \
|
||||
--socket "${SOCKET_PATH}" \
|
||||
--mission-core-commit "${MISSION_CORE_COMMIT}" \
|
||||
--lifecycle-profile "${SOURCE_ROOT}/simulation/s1/stock-rover-lifecycle.yaml"
|
||||
|
|
@ -0,0 +1,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"
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,609 @@
|
|||
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)
|
||||
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")
|
||||
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 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 _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)
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,632 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from types import FrameType
|
||||
from typing import Any, Final
|
||||
|
||||
from k1link.simulation.contracts import (
|
||||
AuthorityProfile,
|
||||
ProviderPin,
|
||||
QualificationRun,
|
||||
ReproducibilityTier,
|
||||
RunKind,
|
||||
RunState,
|
||||
)
|
||||
from k1link.simulation.orchestrator import SimulationApplicationService
|
||||
from k1link.simulation.process_supervisor import PosixProcessSupervisor
|
||||
from k1link.simulation.run_store import (
|
||||
ACTIVE_RECOVERY_STATES,
|
||||
QualificationRunNotFoundError,
|
||||
QualificationRunStore,
|
||||
)
|
||||
from k1link.simulation.s0 import ComponentPin
|
||||
from k1link.simulation.stock_rover import (
|
||||
StockRoverTargetPaths,
|
||||
load_stock_rover_lifecycle_profile,
|
||||
stock_rover_process_environment,
|
||||
stock_rover_process_specs,
|
||||
)
|
||||
from k1link.simulation.worker import (
|
||||
LocalProcessWorkerAdapter,
|
||||
S0WorkerGuard,
|
||||
SimulationWorldControl,
|
||||
)
|
||||
from k1link.simulation.worker_gateway import (
|
||||
MAX_MESSAGE_BYTES,
|
||||
OPERATIONS,
|
||||
REQUEST_SCHEMA,
|
||||
RESPONSE_SCHEMA,
|
||||
STATUS_SCHEMA,
|
||||
VEHICLE_STATE_SCHEMA,
|
||||
)
|
||||
|
||||
EXPECTED_DISTRO: Final = "MissionCore-Sim"
|
||||
COMMIT_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$")
|
||||
SAFE_ID_PATTERN: Final = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
||||
PROVIDER_IDS: Final = (
|
||||
"px4-autopilot",
|
||||
"gazebo",
|
||||
"px4-gazebo-models",
|
||||
"micro-xrce-dds-agent",
|
||||
)
|
||||
ACTIVE_STATES: Final = frozenset(ACTIVE_RECOVERY_STATES)
|
||||
POSE_TOPIC: Final = "/world/rover/dynamic_pose/info"
|
||||
|
||||
|
||||
class WorkerAgentError(RuntimeError):
|
||||
"""The worker agent cannot safely satisfy a gateway request."""
|
||||
|
||||
|
||||
class _LifecycleOnlyWorldControl(SimulationWorldControl):
|
||||
def pause(self, run_id: str) -> None:
|
||||
raise WorkerAgentError(f"pause is not admitted for {run_id}")
|
||||
|
||||
def resume(self, run_id: str) -> None:
|
||||
raise WorkerAgentError(f"resume is not admitted for {run_id}")
|
||||
|
||||
def step(self, run_id: str, step_count: int) -> int:
|
||||
raise WorkerAgentError(f"step is not admitted for {run_id}:{step_count}")
|
||||
|
||||
|
||||
class GazeboPoseCollector:
|
||||
"""Read one bounded ground-truth pose sample from Gazebo Transport."""
|
||||
|
||||
def __init__(self, environment: dict[str, str]) -> None:
|
||||
self.environment = dict(environment)
|
||||
self._sequence = 0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def collect(self, run_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
(
|
||||
"/usr/bin/gz",
|
||||
"topic",
|
||||
"-e",
|
||||
"--json-output",
|
||||
"-t",
|
||||
POSE_TOPIC,
|
||||
"-n",
|
||||
"1",
|
||||
),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
env=self.environment,
|
||||
timeout=3,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise WorkerAgentError("Gazebo pose sample is unavailable") from exc
|
||||
if len(completed.stdout) > MAX_MESSAGE_BYTES:
|
||||
raise WorkerAgentError("Gazebo pose sample exceeds the size limit")
|
||||
try:
|
||||
document = json.loads(completed.stdout.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise WorkerAgentError("Gazebo pose sample is not valid JSON") from exc
|
||||
if not isinstance(document, dict):
|
||||
raise WorkerAgentError("Gazebo pose sample must be an object")
|
||||
pose = _rover_pose(document)
|
||||
stamp = _object(_object(document.get("header"), "header").get("stamp"), "stamp")
|
||||
sim_time_ns = _integer(stamp.get("sec"), "stamp.sec") * 1_000_000_000 + _integer(
|
||||
stamp.get("nsec"),
|
||||
"stamp.nsec",
|
||||
)
|
||||
with self._lock:
|
||||
self._sequence += 1
|
||||
sequence = self._sequence
|
||||
return {
|
||||
"schema_version": VEHICLE_STATE_SCHEMA,
|
||||
"run_id": run_id,
|
||||
"sequence": sequence,
|
||||
"observed_at_utc": _utc_now(),
|
||||
"host_monotonic_ns": time.monotonic_ns(),
|
||||
"sim_time_ns": sim_time_ns,
|
||||
"frame_id": "map_enu",
|
||||
"child_frame_id": "base_link_flu",
|
||||
"pose": {
|
||||
"position_m": {
|
||||
"x": _number(_object(pose.get("position"), "position").get("x"), "position.x"),
|
||||
"y": _number(_object(pose.get("position"), "position").get("y"), "position.y"),
|
||||
"z": _number(_object(pose.get("position"), "position").get("z"), "position.z"),
|
||||
},
|
||||
"orientation_xyzw": {
|
||||
"x": _number(
|
||||
_object(pose.get("orientation"), "orientation").get("x"),
|
||||
"orientation.x",
|
||||
),
|
||||
"y": _number(
|
||||
_object(pose.get("orientation"), "orientation").get("y"),
|
||||
"orientation.y",
|
||||
),
|
||||
"z": _number(
|
||||
_object(pose.get("orientation"), "orientation").get("z"),
|
||||
"orientation.z",
|
||||
),
|
||||
"w": _number(
|
||||
_object(pose.get("orientation"), "orientation").get("w"),
|
||||
"orientation.w",
|
||||
),
|
||||
},
|
||||
},
|
||||
"source": {
|
||||
"provider": "gazebo",
|
||||
"topic": POSE_TOPIC,
|
||||
"signal": "ground-truth",
|
||||
"quality": "diagnostic",
|
||||
},
|
||||
"safety": {
|
||||
"scope": "virtual-only",
|
||||
"actuator_authority": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class SimulationWorkerAgent:
|
||||
"""One loopback-only worker process owning provider lifecycle and live state."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
socket_path: Path,
|
||||
mission_core_commit: str,
|
||||
d_root: Path,
|
||||
s0_profile_path: Path,
|
||||
lifecycle_profile_path: Path,
|
||||
) -> None:
|
||||
if not COMMIT_PATTERN.fullmatch(mission_core_commit):
|
||||
raise WorkerAgentError("Mission Core commit must be an exact 40-character revision")
|
||||
if os.geteuid() == 0:
|
||||
raise WorkerAgentError("worker agent must run as an unprivileged identity")
|
||||
if os.environ.get("WSL_DISTRO_NAME") != EXPECTED_DISTRO:
|
||||
raise WorkerAgentError(f"worker agent requires {EXPECTED_DISTRO}")
|
||||
if tuple(sorted(name for _, name in socket.if_nameindex())) != ("lo",):
|
||||
raise WorkerAgentError("worker agent requires a loopback-only network namespace")
|
||||
self.socket_path = socket_path
|
||||
self.mission_core_commit = mission_core_commit
|
||||
self.paths = StockRoverTargetPaths.from_d_root(d_root)
|
||||
self.lifecycle_profile_path = lifecycle_profile_path.expanduser().resolve()
|
||||
self.lifecycle_profile = load_stock_rover_lifecycle_profile(self.lifecycle_profile_path)
|
||||
self.guard = S0WorkerGuard(s0_profile_path)
|
||||
self._preflight()
|
||||
self.store = QualificationRunStore(self.paths.d_root / "artifacts/s1/runs")
|
||||
self.supervisor = PosixProcessSupervisor(
|
||||
self.paths.d_root / "runtime/s1/runs",
|
||||
base_environment=stock_rover_process_environment(self.paths),
|
||||
)
|
||||
self.worker = LocalProcessWorkerAdapter(
|
||||
self.guard,
|
||||
self.supervisor,
|
||||
stock_rover_process_specs(self.paths, self.lifecycle_profile),
|
||||
_LifecycleOnlyWorldControl(),
|
||||
)
|
||||
self.service = SimulationApplicationService(self.store, self.worker)
|
||||
self.collector = GazeboPoseCollector(stock_rover_process_environment(self.paths))
|
||||
self._shutdown = threading.Event()
|
||||
self._server_socket: socket.socket | None = None
|
||||
self._connection_threads: set[threading.Thread] = set()
|
||||
self.service.reconcile(
|
||||
observed_at_utc=_utc_now(),
|
||||
host_monotonic_ns=time.monotonic_ns(),
|
||||
)
|
||||
|
||||
def serve_forever(self) -> None:
|
||||
self.socket_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
if self.socket_path.exists():
|
||||
if not self.socket_path.is_socket():
|
||||
raise WorkerAgentError("worker socket path is occupied by a non-socket")
|
||||
self.socket_path.unlink()
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
|
||||
self._server_socket = server
|
||||
server.bind(str(self.socket_path))
|
||||
self.socket_path.chmod(0o600)
|
||||
server.listen(8)
|
||||
server.settimeout(0.5)
|
||||
while not self._shutdown.is_set():
|
||||
try:
|
||||
connection, _ = server.accept()
|
||||
except TimeoutError:
|
||||
continue
|
||||
self._connection_threads = {
|
||||
thread for thread in self._connection_threads if thread.is_alive()
|
||||
}
|
||||
thread = threading.Thread(
|
||||
target=self._serve_connection,
|
||||
args=(connection,),
|
||||
daemon=True,
|
||||
)
|
||||
self._connection_threads.add(thread)
|
||||
thread.start()
|
||||
for thread in tuple(self._connection_threads):
|
||||
thread.join(timeout=1)
|
||||
self._server_socket = None
|
||||
if self.socket_path.is_socket():
|
||||
self.socket_path.unlink()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._shutdown.set()
|
||||
active = self._active_run()
|
||||
if active is not None:
|
||||
try:
|
||||
self.service.stop(
|
||||
active.run_id,
|
||||
idempotency_key=f"{active.run_id}:agent-shutdown",
|
||||
observed_at_utc=_utc_now(),
|
||||
host_monotonic_ns=time.monotonic_ns(),
|
||||
sim_time_ns=None,
|
||||
terminal_state=RunState.ABORTED,
|
||||
reason="worker-agent-shutdown",
|
||||
)
|
||||
except Exception:
|
||||
self.supervisor.stop()
|
||||
|
||||
def _serve_connection(self, connection: socket.socket) -> None:
|
||||
with connection:
|
||||
self._handle_connection(connection)
|
||||
|
||||
def dispatch(self, request: dict[str, Any]) -> dict[str, Any]:
|
||||
if set(request) != {"schema_version", "request_id", "operation", "payload"}:
|
||||
raise WorkerAgentError("request keys do not match v1")
|
||||
request_id = request.get("request_id")
|
||||
operation = request.get("operation")
|
||||
payload = request.get("payload")
|
||||
if (
|
||||
request.get("schema_version") != REQUEST_SCHEMA
|
||||
or not isinstance(request_id, str)
|
||||
or not 1 <= len(request_id) <= 64
|
||||
or operation not in OPERATIONS
|
||||
or not isinstance(payload, dict)
|
||||
):
|
||||
raise WorkerAgentError("request envelope is invalid")
|
||||
if operation == "status":
|
||||
_exact_keys(payload, set(), "status payload")
|
||||
return self.status()
|
||||
if operation == "live":
|
||||
_exact_keys(payload, set(), "live payload")
|
||||
active = self._active_run()
|
||||
if active is None or active.state is not RunState.RUNNING:
|
||||
raise WorkerAgentError("no running simulation is available")
|
||||
return self.collector.collect(active.run_id)
|
||||
if operation == "start":
|
||||
_exact_keys(
|
||||
payload,
|
||||
{"run_id", "mission_core_commit", "idempotency_key"},
|
||||
"start payload",
|
||||
)
|
||||
return self.start(
|
||||
run_id=_safe_id(payload["run_id"], "run id"),
|
||||
mission_core_commit=_string(payload["mission_core_commit"], "commit", 40),
|
||||
idempotency_key=_string(payload["idempotency_key"], "idempotency key", 160),
|
||||
)
|
||||
_exact_keys(payload, {"run_id", "idempotency_key"}, "stop payload")
|
||||
return self.stop(
|
||||
run_id=_safe_id(payload["run_id"], "run id"),
|
||||
idempotency_key=_string(payload["idempotency_key"], "idempotency key", 160),
|
||||
)
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
active = self._active_run()
|
||||
provider_ids: list[str] = []
|
||||
if active is not None and active.state in {RunState.RUNNING, RunState.PAUSED}:
|
||||
try:
|
||||
provider_ids = [record.process_id for record in self.supervisor.snapshot()]
|
||||
except Exception:
|
||||
provider_ids = []
|
||||
return {
|
||||
"schema_version": STATUS_SCHEMA,
|
||||
"worker_id": "mission-gpu-s1",
|
||||
"transport": "unix",
|
||||
"mode": "simulation",
|
||||
"available": True,
|
||||
"control_available": True,
|
||||
"active_run_id": active.run_id if active else None,
|
||||
"run_state": active.state.value if active else None,
|
||||
"provider_ids": provider_ids,
|
||||
"isolation": {
|
||||
"network": "loopback-only-netns",
|
||||
"process_identity": "missioncore",
|
||||
"artifact_policy": "d-only",
|
||||
},
|
||||
"authority": {
|
||||
"scope": "virtual-only",
|
||||
"actuator_authority": False,
|
||||
"direct_actuator_setpoints_allowed": False,
|
||||
},
|
||||
}
|
||||
|
||||
def start(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
mission_core_commit: str,
|
||||
idempotency_key: str,
|
||||
) -> dict[str, Any]:
|
||||
if mission_core_commit != self.mission_core_commit:
|
||||
raise WorkerAgentError("requested commit does not match the staged worker generation")
|
||||
try:
|
||||
run = self.store.load(run_id)
|
||||
except QualificationRunNotFoundError:
|
||||
run = self.store.create(self._new_run(run_id))
|
||||
if run.mission_core_commit != mission_core_commit:
|
||||
raise WorkerAgentError("existing run belongs to another Mission Core generation")
|
||||
running = self.service.start(
|
||||
run_id,
|
||||
idempotency_key=idempotency_key,
|
||||
observed_at_utc=_utc_now(),
|
||||
host_monotonic_ns=time.monotonic_ns(),
|
||||
)
|
||||
if running.state is not RunState.RUNNING:
|
||||
raise WorkerAgentError(
|
||||
f"provider start ended in terminal state {running.state.value}"
|
||||
)
|
||||
return self.status()
|
||||
|
||||
def stop(self, *, run_id: str, idempotency_key: str) -> dict[str, Any]:
|
||||
active = self._active_run()
|
||||
if active is None or active.run_id != run_id:
|
||||
raise WorkerAgentError("requested run does not own worker authority")
|
||||
sim_time_ns: int | None
|
||||
try:
|
||||
sim_time_ns = int(self.collector.collect(run_id)["sim_time_ns"])
|
||||
except Exception:
|
||||
sim_time_ns = None
|
||||
terminal = self.service.stop(
|
||||
run_id,
|
||||
idempotency_key=idempotency_key,
|
||||
observed_at_utc=_utc_now(),
|
||||
host_monotonic_ns=time.monotonic_ns(),
|
||||
sim_time_ns=sim_time_ns,
|
||||
)
|
||||
if terminal.state is not RunState.COMPLETED:
|
||||
raise WorkerAgentError(
|
||||
f"provider stop ended in terminal state {terminal.state.value}"
|
||||
)
|
||||
return self.status()
|
||||
|
||||
def _handle_connection(self, connection: socket.socket) -> None:
|
||||
request_id: str | None = None
|
||||
try:
|
||||
request_bytes = _read_line(connection)
|
||||
request = _json_object(request_bytes)
|
||||
raw_request_id = request.get("request_id")
|
||||
if isinstance(raw_request_id, str) and 1 <= len(raw_request_id) <= 64:
|
||||
request_id = raw_request_id
|
||||
result = self.dispatch(request)
|
||||
response = {
|
||||
"schema_version": RESPONSE_SCHEMA,
|
||||
"request_id": request_id,
|
||||
"ok": True,
|
||||
"result": result,
|
||||
"error": None,
|
||||
}
|
||||
except Exception as exc:
|
||||
response = {
|
||||
"schema_version": RESPONSE_SCHEMA,
|
||||
"request_id": request_id,
|
||||
"ok": False,
|
||||
"result": None,
|
||||
"error": _safe_error(exc),
|
||||
}
|
||||
encoded = (
|
||||
json.dumps(response, ensure_ascii=True, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
)
|
||||
if len(encoded) <= MAX_MESSAGE_BYTES:
|
||||
connection.sendall(encoded)
|
||||
|
||||
def _new_run(self, run_id: str) -> QualificationRun:
|
||||
return QualificationRun(
|
||||
run_id=run_id,
|
||||
episode_id=f"episode-{run_id}",
|
||||
kind=RunKind.SIMULATION_CLOSED_LOOP,
|
||||
state=RunState.ADMITTED,
|
||||
scenario_generation="px4-v1.17.0-stock-rover-ackermann",
|
||||
scenario_sha256=_sha256(self.paths.scenario_path),
|
||||
profile_generation=self.lifecycle_profile.profile_id,
|
||||
profile_sha256=_sha256(self.lifecycle_profile_path),
|
||||
mission_core_commit=self.mission_core_commit,
|
||||
providers=_provider_pins(self.guard),
|
||||
host_profile_id=self.guard.profile.profile_id,
|
||||
host_profile_sha256=self.guard.profile_sha256,
|
||||
seed=42,
|
||||
reproducibility_tier=ReproducibilityTier.R1,
|
||||
authority=AuthorityProfile(
|
||||
generation=1,
|
||||
command_ttl_max_ns=250_000_000,
|
||||
heartbeat_timeout_monotonic_ns=500_000_000,
|
||||
),
|
||||
clock_domain="gazebo:/clock",
|
||||
created_at_utc=_utc_now(),
|
||||
)
|
||||
|
||||
def _active_run(self) -> QualificationRun | None:
|
||||
active = tuple(run for run in self.store.list_runs() if run.state in ACTIVE_STATES)
|
||||
if len(active) > 1:
|
||||
raise WorkerAgentError("multiple active qualification runs violate worker authority")
|
||||
return active[0] if active else None
|
||||
|
||||
def _preflight(self) -> None:
|
||||
required = (self.paths.px4_root, self.paths.agent_binary, self.paths.scenario_path)
|
||||
if any(not path.exists() for path in required):
|
||||
raise WorkerAgentError("worker inputs are incomplete")
|
||||
if not all(
|
||||
component.qualification_state == "accepted"
|
||||
for component in self.guard.profile.components
|
||||
):
|
||||
raise WorkerAgentError("an S0 provider generation is no longer accepted")
|
||||
|
||||
|
||||
def _read_line(connection: socket.socket) -> bytes:
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
while True:
|
||||
chunk = connection.recv(min(4096, MAX_MESSAGE_BYTES + 1 - size))
|
||||
if not chunk:
|
||||
raise WorkerAgentError("gateway request ended before newline")
|
||||
newline = chunk.find(b"\n")
|
||||
if newline >= 0:
|
||||
chunks.append(chunk[:newline])
|
||||
size += newline
|
||||
if size > MAX_MESSAGE_BYTES:
|
||||
raise WorkerAgentError("gateway request exceeds the size limit")
|
||||
return b"".join(chunks)
|
||||
chunks.append(chunk)
|
||||
size += len(chunk)
|
||||
if size > MAX_MESSAGE_BYTES:
|
||||
raise WorkerAgentError("gateway request exceeds the size limit")
|
||||
|
||||
|
||||
def _json_object(value: bytes) -> dict[str, Any]:
|
||||
try:
|
||||
document = json.loads(value.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise WorkerAgentError("gateway request is not valid UTF-8 JSON") from exc
|
||||
if not isinstance(document, dict) or any(not isinstance(key, str) for key in document):
|
||||
raise WorkerAgentError("gateway request must be a JSON object")
|
||||
return document
|
||||
|
||||
|
||||
def _rover_pose(document: dict[str, Any]) -> dict[str, Any]:
|
||||
poses = document.get("pose")
|
||||
if not isinstance(poses, list) or not poses:
|
||||
raise WorkerAgentError("Gazebo pose sample contains no poses")
|
||||
for item in poses:
|
||||
if isinstance(item, dict) and str(item.get("name", "")).startswith("rover_ackermann"):
|
||||
return item
|
||||
raise WorkerAgentError("Gazebo pose sample contains no stock rover")
|
||||
|
||||
|
||||
def _provider_pins(guard: S0WorkerGuard) -> tuple[ProviderPin, ...]:
|
||||
components = {component.identifier: component for component in guard.profile.components}
|
||||
if any(identifier not in components for identifier in PROVIDER_IDS):
|
||||
raise WorkerAgentError("S0 profile is missing a required provider")
|
||||
return tuple(_provider_pin(components[identifier]) for identifier in PROVIDER_IDS)
|
||||
|
||||
|
||||
def _provider_pin(component: ComponentPin) -> ProviderPin:
|
||||
version = component.resolved_version or component.requested_ref
|
||||
return ProviderPin(
|
||||
identifier=component.identifier,
|
||||
version=version,
|
||||
revision=component.resolved_commit or version,
|
||||
)
|
||||
|
||||
|
||||
def _exact_keys(value: dict[str, Any], expected: set[str], label: str) -> None:
|
||||
if set(value) != expected:
|
||||
raise WorkerAgentError(f"{label} keys do not match v1")
|
||||
|
||||
|
||||
def _safe_id(value: object, label: str) -> str:
|
||||
result = _string(value, label, 64)
|
||||
if not SAFE_ID_PATTERN.fullmatch(result):
|
||||
raise WorkerAgentError(f"{label} is not a safe identifier")
|
||||
return result
|
||||
|
||||
|
||||
def _string(value: object, label: str, maximum: int) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise WorkerAgentError(f"{label} must be a string")
|
||||
result = value.strip()
|
||||
if not 1 <= len(result) <= maximum:
|
||||
raise WorkerAgentError(f"{label} has an invalid length")
|
||||
return result
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
||||
raise WorkerAgentError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _number(value: object, label: str) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, int | float | str):
|
||||
raise WorkerAgentError(f"{label} must be numeric")
|
||||
try:
|
||||
result = float(value)
|
||||
except ValueError as exc:
|
||||
raise WorkerAgentError(f"{label} must be numeric") from exc
|
||||
if not -1e9 < result < 1e9:
|
||||
raise WorkerAgentError(f"{label} is outside the accepted range")
|
||||
return result
|
||||
|
||||
|
||||
def _integer(value: object, label: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int | str):
|
||||
raise WorkerAgentError(f"{label} must be an integer")
|
||||
try:
|
||||
result = int(value)
|
||||
except ValueError as exc:
|
||||
raise WorkerAgentError(f"{label} must be an integer") from exc
|
||||
if result < 0:
|
||||
raise WorkerAgentError(f"{label} must not be negative")
|
||||
return result
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _safe_error(exc: Exception) -> str:
|
||||
if isinstance(exc, WorkerAgentError):
|
||||
normalized = " ".join(str(exc).split())
|
||||
return normalized[:512] or "worker request rejected"
|
||||
return f"{type(exc).__name__}: worker operation failed"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Serve the Mission Core Simulation Worker agent.")
|
||||
parser.add_argument("--socket", type=Path, required=True)
|
||||
parser.add_argument("--mission-core-commit", required=True)
|
||||
parser.add_argument(
|
||||
"--d-root",
|
||||
type=Path,
|
||||
default=Path("/mnt/d/NDC_MISSIONCORE/simulation"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--s0-profile",
|
||||
type=Path,
|
||||
default=Path("/mnt/d/NDC_MISSIONCORE/simulation/source/qualification-profile.yaml"),
|
||||
)
|
||||
parser.add_argument("--lifecycle-profile", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
agent = SimulationWorkerAgent(
|
||||
socket_path=args.socket,
|
||||
mission_core_commit=args.mission_core_commit,
|
||||
d_root=args.d_root,
|
||||
s0_profile_path=args.s0_profile,
|
||||
lifecycle_profile_path=args.lifecycle_profile,
|
||||
)
|
||||
|
||||
def stop(_signum: int, _frame: FrameType | None) -> None:
|
||||
agent.shutdown()
|
||||
|
||||
signal.signal(signal.SIGTERM, stop)
|
||||
signal.signal(signal.SIGINT, stop)
|
||||
try:
|
||||
agent.serve_forever()
|
||||
finally:
|
||||
agent.shutdown()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any, Final, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
REQUEST_SCHEMA: Final = "missioncore.simulation-worker-request/v1"
|
||||
RESPONSE_SCHEMA: Final = "missioncore.simulation-worker-response/v1"
|
||||
STATUS_SCHEMA: Final = "missioncore.simulation-worker-status/v1"
|
||||
VEHICLE_STATE_SCHEMA: Final = "missioncore.vehicle-state/v1"
|
||||
MAX_MESSAGE_BYTES: Final = 64 * 1024
|
||||
OPERATIONS: Final = frozenset({"status", "live", "start", "stop"})
|
||||
|
||||
|
||||
class SimulationWorkerGatewayError(RuntimeError):
|
||||
"""The worker transport or response violated the private gateway contract."""
|
||||
|
||||
|
||||
class SimulationWorkerUnavailableError(SimulationWorkerGatewayError):
|
||||
"""The configured worker cannot currently be reached."""
|
||||
|
||||
|
||||
class SimulationWorkerRejectedError(SimulationWorkerGatewayError):
|
||||
"""The worker safely rejected a valid gateway request."""
|
||||
|
||||
|
||||
class PolygonWorkerGateway(Protocol):
|
||||
def status(self) -> dict[str, Any]: ...
|
||||
|
||||
def live(self) -> dict[str, Any]: ...
|
||||
|
||||
def start(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
mission_core_commit: str,
|
||||
idempotency_key: str,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
def stop(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
idempotency_key: str,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class UnixSocketWorkerGateway:
|
||||
"""Bounded request/response transport between Mission Core and one local worker."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
socket_path: Path,
|
||||
*,
|
||||
request_timeout_seconds: float = 3.0,
|
||||
lifecycle_timeout_seconds: float = 150.0,
|
||||
) -> None:
|
||||
path = socket_path.expanduser()
|
||||
if not path.is_absolute():
|
||||
raise ValueError("worker socket path must be absolute")
|
||||
if request_timeout_seconds <= 0 or lifecycle_timeout_seconds <= 0:
|
||||
raise ValueError("worker gateway timeouts must be positive")
|
||||
self.socket_path = path
|
||||
self.request_timeout_seconds = request_timeout_seconds
|
||||
self.lifecycle_timeout_seconds = lifecycle_timeout_seconds
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
result = self._request("status", {}, timeout_seconds=self.request_timeout_seconds)
|
||||
_validate_status(result)
|
||||
return result
|
||||
|
||||
def live(self) -> dict[str, Any]:
|
||||
result = self._request("live", {}, timeout_seconds=self.request_timeout_seconds)
|
||||
_validate_vehicle_state(result)
|
||||
return result
|
||||
|
||||
def start(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
mission_core_commit: str,
|
||||
idempotency_key: str,
|
||||
) -> dict[str, Any]:
|
||||
result = self._request(
|
||||
"start",
|
||||
{
|
||||
"run_id": run_id,
|
||||
"mission_core_commit": mission_core_commit,
|
||||
"idempotency_key": idempotency_key,
|
||||
},
|
||||
timeout_seconds=self.lifecycle_timeout_seconds,
|
||||
)
|
||||
_validate_status(result)
|
||||
return result
|
||||
|
||||
def stop(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
idempotency_key: str,
|
||||
) -> dict[str, Any]:
|
||||
result = self._request(
|
||||
"stop",
|
||||
{
|
||||
"run_id": run_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
},
|
||||
timeout_seconds=self.lifecycle_timeout_seconds,
|
||||
)
|
||||
_validate_status(result)
|
||||
return result
|
||||
|
||||
def _request(
|
||||
self,
|
||||
operation: str,
|
||||
payload: Mapping[str, object],
|
||||
*,
|
||||
timeout_seconds: float,
|
||||
) -> dict[str, Any]:
|
||||
if operation not in OPERATIONS:
|
||||
raise ValueError("unsupported worker operation")
|
||||
request_id = uuid4().hex
|
||||
encoded = (
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": REQUEST_SCHEMA,
|
||||
"request_id": request_id,
|
||||
"operation": operation,
|
||||
"payload": dict(payload),
|
||||
},
|
||||
ensure_ascii=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
if len(encoded) > MAX_MESSAGE_BYTES:
|
||||
raise ValueError("worker request exceeds the bounded message size")
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
||||
client.settimeout(timeout_seconds)
|
||||
client.connect(str(self.socket_path))
|
||||
client.sendall(encoded)
|
||||
response_bytes = _read_line(client)
|
||||
except (FileNotFoundError, ConnectionError, OSError, TimeoutError) as exc:
|
||||
raise SimulationWorkerUnavailableError(
|
||||
"Simulation Worker недоступен через локальный шлюз."
|
||||
) from exc
|
||||
response = _json_object(response_bytes, "worker response")
|
||||
if set(response) != {"schema_version", "request_id", "ok", "result", "error"}:
|
||||
raise SimulationWorkerGatewayError("worker response keys do not match v1")
|
||||
if response["schema_version"] != RESPONSE_SCHEMA or response["request_id"] != request_id:
|
||||
raise SimulationWorkerGatewayError("worker response identity does not match request")
|
||||
if not isinstance(response["ok"], bool):
|
||||
raise SimulationWorkerGatewayError("worker response ok flag is invalid")
|
||||
if response["ok"]:
|
||||
if response["error"] is not None or not isinstance(response["result"], dict):
|
||||
raise SimulationWorkerGatewayError("successful worker response is malformed")
|
||||
return response["result"]
|
||||
error = response["error"]
|
||||
if response["result"] is not None or not isinstance(error, str) or not error.strip():
|
||||
raise SimulationWorkerGatewayError("rejected worker response is malformed")
|
||||
raise SimulationWorkerRejectedError(error[:512])
|
||||
|
||||
|
||||
def _read_line(connection: socket.socket) -> bytes:
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
while True:
|
||||
chunk = connection.recv(min(4096, MAX_MESSAGE_BYTES + 1 - size))
|
||||
if not chunk:
|
||||
raise SimulationWorkerGatewayError("worker closed the gateway without a response")
|
||||
newline = chunk.find(b"\n")
|
||||
if newline >= 0:
|
||||
chunks.append(chunk[:newline])
|
||||
size += newline
|
||||
if size > MAX_MESSAGE_BYTES:
|
||||
raise SimulationWorkerGatewayError("worker response exceeds the size limit")
|
||||
return b"".join(chunks)
|
||||
chunks.append(chunk)
|
||||
size += len(chunk)
|
||||
if size > MAX_MESSAGE_BYTES:
|
||||
raise SimulationWorkerGatewayError("worker response exceeds the size limit")
|
||||
|
||||
|
||||
def _json_object(value: bytes, label: str) -> dict[str, Any]:
|
||||
try:
|
||||
document = json.loads(value.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SimulationWorkerGatewayError(f"{label} is not valid UTF-8 JSON") from exc
|
||||
if not isinstance(document, dict) or any(not isinstance(key, str) for key in document):
|
||||
raise SimulationWorkerGatewayError(f"{label} must be a JSON object")
|
||||
return document
|
||||
|
||||
|
||||
def _validate_status(value: Mapping[str, Any]) -> None:
|
||||
expected = {
|
||||
"schema_version",
|
||||
"worker_id",
|
||||
"transport",
|
||||
"mode",
|
||||
"available",
|
||||
"control_available",
|
||||
"active_run_id",
|
||||
"run_state",
|
||||
"provider_ids",
|
||||
"isolation",
|
||||
"authority",
|
||||
}
|
||||
if set(value) != expected or value.get("schema_version") != STATUS_SCHEMA:
|
||||
raise SimulationWorkerGatewayError("worker status does not match v1")
|
||||
if (
|
||||
not isinstance(value.get("worker_id"), str)
|
||||
or value.get("transport") != "unix"
|
||||
or value.get("mode") != "simulation"
|
||||
or value.get("available") is not True
|
||||
or not isinstance(value.get("control_available"), bool)
|
||||
or not isinstance(value.get("provider_ids"), list)
|
||||
or any(not isinstance(item, str) for item in value["provider_ids"])
|
||||
or not isinstance(value.get("isolation"), dict)
|
||||
or not isinstance(value.get("authority"), dict)
|
||||
):
|
||||
raise SimulationWorkerGatewayError("worker status contains invalid values")
|
||||
if value.get("active_run_id") is not None and not isinstance(value["active_run_id"], str):
|
||||
raise SimulationWorkerGatewayError("worker active run id is invalid")
|
||||
if value.get("run_state") is not None and not isinstance(value["run_state"], str):
|
||||
raise SimulationWorkerGatewayError("worker run state is invalid")
|
||||
|
||||
|
||||
def _validate_vehicle_state(value: Mapping[str, Any]) -> None:
|
||||
expected = {
|
||||
"schema_version",
|
||||
"run_id",
|
||||
"sequence",
|
||||
"observed_at_utc",
|
||||
"host_monotonic_ns",
|
||||
"sim_time_ns",
|
||||
"frame_id",
|
||||
"child_frame_id",
|
||||
"pose",
|
||||
"source",
|
||||
"safety",
|
||||
}
|
||||
if set(value) != expected or value.get("schema_version") != VEHICLE_STATE_SCHEMA:
|
||||
raise SimulationWorkerGatewayError("vehicle state does not match v1")
|
||||
if (
|
||||
not isinstance(value.get("run_id"), str)
|
||||
or not isinstance(value.get("sequence"), int)
|
||||
or not isinstance(value.get("observed_at_utc"), str)
|
||||
or not isinstance(value.get("host_monotonic_ns"), int)
|
||||
or not isinstance(value.get("sim_time_ns"), int)
|
||||
or value.get("frame_id") != "map_enu"
|
||||
or value.get("child_frame_id") != "base_link_flu"
|
||||
or not isinstance(value.get("pose"), dict)
|
||||
or not isinstance(value.get("source"), dict)
|
||||
or not isinstance(value.get("safety"), dict)
|
||||
):
|
||||
raise SimulationWorkerGatewayError("vehicle state contains invalid values")
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,355 @@
|
|||
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
|
||||
|
||||
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 публикует только квалификационные доказательства; "
|
||||
"lifecycle-операции и команды отсутствуют.",
|
||||
"Артефакты представлены относительными метаданными; их содержимое не публикуется этим 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
|
||||
|
||||
|
||||
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
|
||||
|
||||
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)}"
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
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 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 _status(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": "missioncore.simulation-worker-status/v1",
|
||||
"worker_id": "mission-gpu-s1",
|
||||
"transport": "unix",
|
||||
"mode": "simulation",
|
||||
"available": True,
|
||||
"control_available": True,
|
||||
"active_run_id": self.active_run_id,
|
||||
"run_state": "running" if self.active_run_id else None,
|
||||
"provider_ids": ["px4-gazebo-stock-rover"] if self.active_run_id else [],
|
||||
"isolation": {
|
||||
"network": "loopback-only-netns",
|
||||
"process_identity": "missioncore",
|
||||
"artifact_policy": "d-only",
|
||||
},
|
||||
"authority": {
|
||||
"scope": "virtual-only",
|
||||
"actuator_authority": False,
|
||||
"direct_actuator_setpoints_allowed": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_polygon_worker_api_fails_closed_then_proxies_virtual_only_lifecycle() -> None:
|
||||
worker = _FakeWorkerGateway()
|
||||
disabled = build_polygon_router(
|
||||
root_provider=lambda: None,
|
||||
worker_provider=lambda: worker,
|
||||
control_provider=lambda: False,
|
||||
commit_provider=lambda: "d" * 40,
|
||||
)
|
||||
status = _endpoint(disabled, "/api/v1/polygon/worker", "GET")()
|
||||
assert status["available"] is True
|
||||
assert status["control_available"] is False
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
_endpoint(disabled, "/api/v1/polygon/worker/runs", "POST")(
|
||||
request=StartStockRoverRequest(scenario_id="stock-rover-ackermann"),
|
||||
idempotency_key="start-disabled",
|
||||
)
|
||||
assert failure.value.status_code == 403
|
||||
|
||||
enabled = build_polygon_router(
|
||||
root_provider=lambda: None,
|
||||
worker_provider=lambda: worker,
|
||||
control_provider=lambda: True,
|
||||
commit_provider=lambda: "d" * 40,
|
||||
)
|
||||
running = _endpoint(enabled, "/api/v1/polygon/worker/runs", "POST")(
|
||||
request=StartStockRoverRequest(scenario_id="stock-rover-ackermann"),
|
||||
idempotency_key="start-001",
|
||||
)
|
||||
assert running["active_run_id"].startswith("s1c-ddddddd-")
|
||||
live = _endpoint(enabled, "/api/v1/polygon/worker/live", "GET")()
|
||||
assert live["run_id"] == running["active_run_id"]
|
||||
assert live["source"]["quality"] == "diagnostic"
|
||||
stopped = _endpoint(enabled, "/api/v1/polygon/worker/runs/{run_id}/stop", "POST")(
|
||||
run_id=running["active_run_id"],
|
||||
idempotency_key="stop-001",
|
||||
)
|
||||
assert stopped["active_run_id"] is None
|
||||
assert worker.calls == [("start", "start-001"), ("stop", "stop-001")]
|
||||
|
||||
|
||||
def test_polygon_worker_status_is_explicitly_unavailable_when_not_registered() -> None:
|
||||
router = build_polygon_router(
|
||||
root_provider=lambda: None,
|
||||
worker_provider=lambda: None,
|
||||
)
|
||||
status = _endpoint(router, "/api/v1/polygon/worker", "GET")()
|
||||
assert status["schema_version"] == "missioncore.simulation-worker-status/v1"
|
||||
assert status["available"] is False
|
||||
assert status["control_available"] is False
|
||||
assert status["authority"]["scope"] == "virtual-only"
|
||||
|
|
@ -0,0 +1,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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.simulation.worker_gateway import (
|
||||
REQUEST_SCHEMA,
|
||||
RESPONSE_SCHEMA,
|
||||
SimulationWorkerGatewayError,
|
||||
SimulationWorkerUnavailableError,
|
||||
UnixSocketWorkerGateway,
|
||||
)
|
||||
|
||||
|
||||
def _status() -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": "missioncore.simulation-worker-status/v1",
|
||||
"worker_id": "mission-gpu-s1",
|
||||
"transport": "unix",
|
||||
"mode": "simulation",
|
||||
"available": True,
|
||||
"control_available": True,
|
||||
"active_run_id": None,
|
||||
"run_state": None,
|
||||
"provider_ids": [],
|
||||
"isolation": {
|
||||
"network": "loopback-only-netns",
|
||||
"process_identity": "missioncore",
|
||||
"artifact_policy": "d-only",
|
||||
},
|
||||
"authority": {
|
||||
"scope": "virtual-only",
|
||||
"actuator_authority": False,
|
||||
"direct_actuator_setpoints_allowed": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _serve_once(
|
||||
socket_path: Path,
|
||||
result: dict[str, Any],
|
||||
*,
|
||||
mutate_response: bool = False,
|
||||
) -> tuple[threading.Thread, list[dict[str, Any]]]:
|
||||
requests: list[dict[str, Any]] = []
|
||||
ready = threading.Event()
|
||||
|
||||
def serve() -> None:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
|
||||
server.bind(str(socket_path))
|
||||
server.listen(1)
|
||||
ready.set()
|
||||
connection, _ = server.accept()
|
||||
with connection:
|
||||
request = json.loads(connection.makefile("rb").readline())
|
||||
requests.append(request)
|
||||
response = {
|
||||
"schema_version": RESPONSE_SCHEMA,
|
||||
"request_id": "wrong" if mutate_response else request["request_id"],
|
||||
"ok": True,
|
||||
"result": result,
|
||||
"error": None,
|
||||
}
|
||||
connection.sendall(
|
||||
json.dumps(response, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
)
|
||||
|
||||
thread = threading.Thread(target=serve, daemon=True)
|
||||
thread.start()
|
||||
assert ready.wait(timeout=2)
|
||||
return thread, requests
|
||||
|
||||
|
||||
def test_unix_worker_gateway_uses_exact_bounded_private_protocol() -> None:
|
||||
socket_path = Path(f"/tmp/mc-{uuid4().hex}.sock")
|
||||
thread, requests = _serve_once(socket_path, _status())
|
||||
gateway = UnixSocketWorkerGateway(socket_path)
|
||||
|
||||
try:
|
||||
status = gateway.status()
|
||||
thread.join(timeout=2)
|
||||
|
||||
assert status["worker_id"] == "mission-gpu-s1"
|
||||
assert requests == [
|
||||
{
|
||||
"schema_version": REQUEST_SCHEMA,
|
||||
"request_id": requests[0]["request_id"],
|
||||
"operation": "status",
|
||||
"payload": {},
|
||||
}
|
||||
]
|
||||
assert len(requests[0]["request_id"]) == 32
|
||||
finally:
|
||||
socket_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_unix_worker_gateway_rejects_response_identity_drift() -> None:
|
||||
socket_path = Path(f"/tmp/mc-{uuid4().hex}.sock")
|
||||
thread, _ = _serve_once(socket_path, _status(), mutate_response=True)
|
||||
gateway = UnixSocketWorkerGateway(socket_path)
|
||||
|
||||
try:
|
||||
with pytest.raises(SimulationWorkerGatewayError, match="identity"):
|
||||
gateway.status()
|
||||
thread.join(timeout=2)
|
||||
finally:
|
||||
socket_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_unix_worker_gateway_reports_missing_worker_without_path_disclosure(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
gateway = UnixSocketWorkerGateway(tmp_path / "missing.sock")
|
||||
|
||||
with pytest.raises(SimulationWorkerUnavailableError) as failure:
|
||||
gateway.status()
|
||||
assert str(tmp_path) not in str(failure.value)
|
||||
Loading…
Reference in New Issue