feat: add Polygon UI-0 run view

This commit is contained in:
DCCONSTRUCTIONS 2026-07-24 19:02:10 +03:00
parent a78c8c83f5
commit b790f29d59
18 changed files with 2193 additions and 43 deletions

View File

@ -165,9 +165,29 @@ 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, namespace, admitted the run only after world/startup/DDS-writer health markers,
and stopped both owned PGIDs with zero residue. The persisted run reached and stopped both owned PGIDs with zero residue. The persisted run reached
`completed`, contains eight lifecycle events and zero control commands. This `completed`, contains eight lifecycle events and zero control commands. This
opens UI-0 implementation; live pause/resume/step/reset, canonical telemetry, run is now visible through UI-0: a direct internal read-only Control Station
PX4 command mapping, watchdog/failsafe evidence and the top-level Polygon surface backed by the same append-only qualification repository. It exposes
control surface remain gated. 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.
Configure a Mission Core backend that can read the accepted D repository and
open the direct route:
```bash
export MISSIONCORE_POLYGON_RUNS_ROOT=/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/runs
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 API provides `GET /api/v1/polygon/runs` and
`GET /api/v1/polygon/runs/{run-id}` only. Missing configuration returns `503`;
invalid/corrupt evidence fails closed. The browser receives no D root, artifact
bytes, command payloads or PX4/process-lifecycle operation.
See the [Polygon product/SRS](docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md), See the [Polygon product/SRS](docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md),
[ADR 0015](docs/adr/0015-simulation-polygon-qualification-boundary.md) and the [ADR 0015](docs/adr/0015-simulation-polygon-qualification-boundary.md) and the
@ -272,9 +292,11 @@ revision or content hash. Publishing/vendoring those packages or enforcing an
immutable donor revision remains a packaging and CI prerequisite. 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 Polygon is planned as a seventh section. The read-only direct UI-0 run view is
now permitted by the accepted S1B backend lifecycle and persisted run history, implemented inside the current shell but intentionally omitted from all
but is not yet present in the current shell. Top-level Polygon navigation and top-level and System navigation. It opens only through
control remain gated on accepted clock/frame/command/safety behavior. `?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 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 inside the Mission Core shell. It can open a compatible RRD over same-origin

View File

@ -46,6 +46,7 @@ import type {
} from "./core/observation/sessionArchive"; } from "./core/observation/sessionArchive";
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission"; import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile"; import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
import { resolvePolygonRunRoute } from "./core/polygon/runArchive";
import { import {
OBSERVATION_WORKSPACE_ID, OBSERVATION_WORKSPACE_ID,
OBSERVATION_WORKSPACE_LAYOUT_VERSION, OBSERVATION_WORKSPACE_LAYOUT_VERSION,
@ -149,12 +150,18 @@ function mergeViewerSettings(
export default function App() { export default function App() {
const runtime = useMissionRuntime(); const runtime = useMissionRuntime();
const { selection } = useDevicePluginHost(); const { selection } = useDevicePluginHost();
const polygonRunRoute = useMemo(
() => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search),
[],
);
const workspace = useApplicationWorkspace<string>({ const workspace = useApplicationWorkspace<string>({
navigationOpen: false, navigationOpen: false,
contentExpanded: true, contentExpanded: true,
}); });
const [activeRoot, setActiveRoot] = useState<RootId | null>(null); const [activeRoot, setActiveRoot] = useState<RootId | null>(
polygonRunRoute.active ? "system" : null,
);
const [sourceUrl, setSourceUrl] = useState(""); const [sourceUrl, setSourceUrl] = useState("");
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null); const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null); const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
@ -187,6 +194,7 @@ export default function App() {
const replayActiveRef = useRef(false); const replayActiveRef = useRef(false);
const sceneSettingsCommitterActiveRef = useRef(true); const sceneSettingsCommitterActiveRef = useRef(true);
const sceneSettingsCommitterRef = useRef<LatestAsyncCommitter<SceneSettings> | null>(null); const sceneSettingsCommitterRef = useRef<LatestAsyncCommitter<SceneSettings> | null>(null);
const polygonRunRouteOpenedRef = useRef(false);
const currentRoot = rootById(activeRoot); const currentRoot = rootById(activeRoot);
const activeDefinition = workspaceById(workspace.activeView); const activeDefinition = workspaceById(workspace.activeView);
@ -204,6 +212,12 @@ export default function App() {
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings; runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
replayActiveRef.current = replayActive; replayActiveRef.current = replayActive;
useEffect(() => {
if (!polygonRunRoute.active || polygonRunRouteOpenedRef.current) return;
polygonRunRouteOpenedRef.current = true;
workspace.openView("polygon-run-internal");
}, [polygonRunRoute.active, workspace]);
if (!sceneSettingsCommitterRef.current) { if (!sceneSettingsCommitterRef.current) {
sceneSettingsCommitterRef.current = createLatestAsyncCommitter<SceneSettings>({ sceneSettingsCommitterRef.current = createLatestAsyncCommitter<SceneSettings>({
commit: (settings) => replayActiveRef.current commit: (settings) => replayActiveRef.current
@ -718,6 +732,8 @@ export default function App() {
</span> </span>
) : null} ) : null}
</div> </div>
) : activeDefinition.kind === "polygon-run" ? (
<StatusBadge tone="accent">Только чтение</StatusBadge>
) : ( ) : (
<StatusBadge tone="warning">Интерфейс готов</StatusBadge> <StatusBadge tone="warning">Интерфейс готов</StatusBadge>
) )
@ -746,6 +762,7 @@ export default function App() {
livePerceptionLayers={livePerceptionLayers} livePerceptionLayers={livePerceptionLayers}
onLivePerceptionLayersChange={changeLivePerceptionLayers} onLivePerceptionLayersChange={changeLivePerceptionLayers}
observationLayout={observationLayout} observationLayout={observationLayout}
polygonRunRoute={polygonRunRoute}
spatialControls={selection?.SpatialControlsView spatialControls={selection?.SpatialControlsView
? { ? {
View: selection.SpatialControlsView, View: selection.SpatialControlsView,

View File

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

View File

@ -10,7 +10,8 @@ export type WorkspaceKind =
| "map" | "map"
| "timeline" | "timeline"
| "missions" | "missions"
| "catalog"; | "catalog"
| "polygon-run";
export type CapabilityStatus = "active" | "ready" | "contract" | "later"; export type CapabilityStatus = "active" | "ready" | "contract" | "later";
@ -35,6 +36,7 @@ export interface WorkspaceDefinition {
description: string; description: string;
icon: IconName; icon: IconName;
kind: WorkspaceKind; kind: WorkspaceKind;
internalOnly?: boolean;
groups: CapabilityGroup[]; groups: CapabilityGroup[];
} }
@ -130,6 +132,18 @@ export const roots: RootDefinition[] = [
]; ];
export const workspaces: WorkspaceDefinition[] = [ export const workspaces: WorkspaceDefinition[] = [
{
id: "polygon-run-internal",
root: "system",
label: "Прогон Полигона",
title: "Прогон Полигона",
eyebrow: "ПОЛИГОН / UI-0",
description: "Read-only доказательства квалификационного прогона PX4/Gazebo.",
icon: "activity",
kind: "polygon-run",
internalOnly: true,
groups: [],
},
{ {
id: "command-overview", id: "command-overview",
root: "center", root: "center",
@ -747,7 +761,9 @@ export function workspaceById(id: string | null): WorkspaceDefinition | null {
} }
export function workspacesForRoot(root: RootId | null): WorkspaceDefinition[] { 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> = { export const capabilityStatusLabel: Record<CapabilityStatus, string> = {

View File

@ -10,10 +10,16 @@
@media (max-width: 1280px) { @media (max-width: 1280px) {
.overview-grid, .overview-grid,
.mission-layout { .mission-layout,
.polygon-run-layout,
.polygon-run-evidence-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.polygon-run-providers {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.pipeline-strip { .pipeline-strip {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }
@ -67,6 +73,10 @@
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.polygon-run-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.workspace-lead { .workspace-lead {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
@ -131,10 +141,24 @@
.metrics-grid, .metrics-grid,
.capability-summary, .capability-summary,
.camera-grid { .camera-grid,
.polygon-run-metrics,
.polygon-run-providers {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.polygon-run-identity dl {
grid-template-columns: 1fr;
}
.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 { .camera-grid {
grid-template-rows: repeat(4, minmax(14rem, 1fr)); grid-template-rows: repeat(4, minmax(14rem, 1fr));
} }

View File

@ -90,6 +90,399 @@
line-height: 1.4; line-height: 1.4;
} }
.polygon-run-workspace {
gap: 0.85rem;
}
.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 { .capability-summary {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));

View File

@ -0,0 +1,322 @@
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";
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">
<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">
<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">
<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">
<section className="workspace-lead workspace-lead--compact">
<div>
<span className="section-eyebrow">ПОЛИГОН / КВАЛИФИКАЦИОННЫЙ ПРОГОН</span>
<h2>{run.runId}</h2>
<p>
Канонический журнал Mission Core. Экран не содержит lifecycle-операций,
команд управления или доступа к физическим актуаторам.
</p>
</div>
<div className="polygon-run-lead-status">
<StatusBadge tone={stateTone(run.state)}>{stateLabels[run.state]}</StatusBadge>
<span>read-only · {run.reproducibilityTier}</span>
</div>
</section>
<section className="polygon-run-metrics" aria-label="Сводка прогона">
<div>
<span>Состояние</span>
<strong>{stateLabels[run.state]}</strong>
<small>{run.terminalReason ?? "терминальная причина отсутствует"}</small>
</div>
<div>
<span>Провайдеры</span>
<strong>{run.providers.length}</strong>
<small>{run.providerIds.join(" · ")}</small>
</div>
<div>
<span>События</span>
<strong>{detail.eventsTotal}</strong>
<small>{detail.eventsTruncated ? "показан последний фрагмент" : "журнал целиком"}</small>
</div>
<div>
<span>Команды</span>
<strong>{detail.commandCount}</strong>
<small>содержимое не публикуется UI-0</small>
</div>
</section>
<div className="polygon-run-layout">
<GlassSurface className="polygon-run-catalog" padding="lg">
<header className="polygon-run-panel-heading">
<div>
<span className="section-eyebrow">ПОСЛЕДНИЕ ПРОГОНЫ</span>
<h3>{catalog?.total ?? 0} в источнике</h3>
</div>
<Button
size="compact"
variant="secondary"
onClick={() => setReloadGeneration((value) => value + 1)}
>
Обновить
</Button>
</header>
<div className="polygon-run-list">
{catalog?.items.map((item) => (
<button
type="button"
key={item.runId}
data-active={item.runId === run.runId ? "true" : undefined}
onClick={() => setSelectedRunId(item.runId)}
>
<i data-state={item.state} aria-hidden="true" />
<span>
<strong>{item.runId}</strong>
<small>{formatTimestamp(item.createdAtUtc)}</small>
</span>
<em>{stateLabels[item.state]}</em>
</button>
))}
</div>
</GlassSurface>
<GlassSurface className="polygon-run-identity" padding="lg">
<span className="section-eyebrow">ИДЕНТИЧНОСТЬ И ГРАНИЦА</span>
<dl>
<div><dt>Сценарий</dt><dd>{run.scenarioGeneration}</dd></div>
<div><dt>Профиль</dt><dd>{run.profileGeneration}</dd></div>
<div><dt>Host profile</dt><dd>{run.hostProfileId}</dd></div>
<div><dt>Mission Core</dt><dd><code>{run.missionCoreCommit.slice(0, 12)}</code></dd></div>
<div><dt>Clock</dt><dd><code>{run.clockDomain}</code></dd></div>
<div><dt>Seed</dt><dd>{run.seed}</dd></div>
<div><dt>Начало</dt><dd>{formatTimestamp(run.startedAtUtc)}</dd></div>
<div><dt>Завершение</dt><dd>{formatTimestamp(run.endedAtUtc)}</dd></div>
</dl>
<div className="polygon-run-safety-boundary">
<StatusBadge tone="success">Virtual only</StatusBadge>
<p>
Actuator authority: {run.authority.actuatorAuthority ? "да" : "нет"} ·
direct setpoints: {run.authority.directActuatorSetpointsAllowed ? "да" : "нет"} ·
navigation/safety accepted: {run.authority.navigationOrSafetyAccepted ? "да" : "нет"}
</p>
</div>
</GlassSurface>
</div>
<section className="polygon-run-providers" aria-label="Провайдеры прогона">
{run.providers.map((provider) => (
<div key={provider.identifier}>
<i aria-hidden="true" />
<span>
<strong>{provider.identifier}</strong>
<small>{provider.version}</small>
</span>
<code>{provider.revision.slice(0, 16)}</code>
</div>
))}
</section>
<GlassSurface className="polygon-run-events" padding="lg">
<header className="polygon-run-panel-heading">
<div>
<span className="section-eyebrow">ЖУРНАЛ СОБЫТИЙ</span>
<h3>Последние переходы и факты</h3>
</div>
<span>revision {run.revision}</span>
</header>
<div className="polygon-run-event-list">
{visibleEvents.map((event) => (
<article key={event.sequence}>
<span className="polygon-run-event-sequence">
{String(event.sequence).padStart(3, "0")}
</span>
<div>
<strong>{event.eventType}</strong>
<small>{formatTimestamp(event.observedAtUtc)}</small>
<code>{JSON.stringify(event.payload)}</code>
</div>
<span>{event.simTimeNs === null ? "host" : `${event.simTimeNs} ns`}</span>
</article>
))}
</div>
</GlassSurface>
<div className="polygon-run-evidence-grid">
<GlassSurface className="polygon-run-artifacts" padding="lg">
<span className="section-eyebrow">АРТЕФАКТЫ</span>
<h3>{detail.artifacts.length || run.artifactCount} ссылок в индексе</h3>
{detail.artifacts.length ? (
<div>
{detail.artifacts.map((artifact) => (
<article key={artifact.artifactId}>
<span>
<strong>{artifact.kind}</strong>
<small>{artifact.relativePath}</small>
</span>
<code>{artifact.sha256.slice(0, 12)} · {formatBytes(artifact.byteLength)}</code>
</article>
))}
</div>
) : (
<p>В журнале прогона нет зарегистрированных artifact-index записей.</p>
)}
</GlassSurface>
<GlassSurface className="polygon-run-limitations" padding="lg">
<span className="section-eyebrow">ЧЕСТНАЯ ГРАНИЦА UI-0</span>
<h3>Что этот результат ещё не доказывает</h3>
<ul>
{detail.limitations.map((limitation) => <li key={limitation}>{limitation}</li>)}
</ul>
</GlassSurface>
</div>
</div>
);
}

View File

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

View File

@ -0,0 +1,230 @@
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 workspaceById;
let workspacesForRoot;
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"));
({ workspaceById, workspacesForRoot } = 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" },
});
}
test("UI-0 route is direct-only and does not add Polygon to system navigation", () => {
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-internal").internalOnly, true);
assert.equal(
workspacesForRoot("system").some(({ id }) => id === "polygon-run-internal"),
false,
);
});
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"));
});

View File

@ -24,7 +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 | | 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 | | 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 | | 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 real-provider lifecycle PASS — exact commit `6cb1495` passed 25 target tests and one D-only loopback stock-Ackermann run. Mission Core admitted world/startup/DDS health, persisted eight events, sent zero commands and stopped both PGIDs with zero residue. UI-0 implementation is now unblocked. Pause/resume/step/reset, PX4 command delivery, canonical telemetry/frames, watchdog/failsafe, navigation/safety acceptance and real actuator authority remain absent; top-level Polygon remains gated | | Simulation Polygon | SIM S0 GO; S1A complete; S1B real-provider lifecycle PASS; UI-0 internal read-only run view PASS — exact commit `6cb1495` passed 25 target tests and one D-only loopback stock-Ackermann run. Mission Core admitted world/startup/DDS health, persisted eight events, sent zero commands and stopped both PGIDs with zero residue. The Control Station now reads that canonical journal through GET-only Polygon API routes and a direct hidden workspace; visual QA opened the real accepted run. Pause/resume/step/reset, PX4 command delivery, canonical telemetry/frames, watchdog/failsafe, navigation/safety acceptance and real actuator authority remain absent; top-level Polygon remains gated |
USB project copying remains optional ground truth rather than a blocker for the USB project copying remains optional ground truth rather than a blocker for the
now-verified network path. Owner-operated LixelGO traffic verifies the MQTT now-verified network path. Owner-operated LixelGO traffic verifies the MQTT
@ -162,6 +162,19 @@ groups stopped without residue. Live world control, PX4 command delivery,
heartbeat/watchdog execution and captured failsafe outcomes remain the next S1 heartbeat/watchdog execution and captured failsafe outcomes remain the next S1
increments. 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
real run with eight events, four provider pins, zero commands and the unchanged
virtual-only authority boundary. A shared operator-facing deployment still
requires a reviewed read-only D mount/source configuration; UI-0 does not use
SSH as a product data plane.
## Stage 0 — repository and host baseline ## Stage 0 — repository and host baseline
Deliverables: Deliverables:

View File

@ -9,7 +9,9 @@ Ops source of truth:
- [MISSIONCOR-39](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-39) is the - [MISSIONCOR-39](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-39) is the
canonical, changeable architecture plan. canonical, changeable architecture plan.
- [MISSIONCOR-40](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-40) is the - [MISSIONCOR-40](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-40) is the
active SIM S0 implementation and qualification gate. 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 and ADR 0015 are the repository truth. A material change to run This document and ADR 0015 are the repository truth. A material change to run
kinds, authority, clocks, frames, source-of-record, provider boundaries or phase kinds, authority, clocks, frames, source-of-record, provider boundaries or phase
@ -66,6 +68,10 @@ Polygon is now a parallel product branch. As of this document:
lifecycle in a fresh loopback-only namespace: two provider PGIDs, explicit lifecycle in a fresh loopback-only namespace: two provider PGIDs, explicit
world/startup/DDS-writer readiness, eight persisted events, zero commands, world/startup/DDS-writer readiness, eight persisted events, zero commands,
terminal `completed` and zero process residue; 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;
- `actuator_authority=false`; - `actuator_authority=false`;
- `navigation_or_safety_accepted=false`. - `navigation_or_safety_accepted=false`.
@ -579,8 +585,9 @@ 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 registry or `/proc` residue. Evidence is D-only and digest indexed under
`/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/lifecycle/`. `/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/lifecycle/`.
This accepts the S1B target start/health/stop boundary and unlocks UI-0. It does This accepts the S1B target start/health/stop boundary. UI-0 now consumes the
not accept restart-safe PID/start-token reconciliation, live 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 pause/resume/step/reset, canonical VehicleState, frame conversion, rover command
mapping, watchdog/failsafe cases, repeatability or S1 as a whole. mapping, watchdog/failsafe cases, repeatability or S1 as a whole.
@ -624,8 +631,8 @@ Two UI gates are intentionally distinct.
### UI-0 — early read-only run view ### UI-0 — early read-only run view
Target S1B now proves persisted start/health/stop history. A direct internal run Target S1B proves persisted start/health/stop history. UI-0 is implemented as a
view may expose: direct internal run view and exposes:
- run identity, lifecycle and terminal reason; - run identity, lifecycle and terminal reason;
- authoritative clock and current pause/running state; - authoritative clock and current pause/running state;
@ -633,9 +640,26 @@ view may expose:
- qualification events and admitted artifact links; - qualification events and admitted artifact links;
- explicit limitations and gate status. - explicit limitations and gate status.
UI-0 has no PX4 transport, no control commands and no top-level Polygon The backend reads the configured repository from
navigation claim. It is a development/acceptance surface backed only by the `MISSIONCORE_POLYGON_RUNS_ROOT` using
server-owned run repository. `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 ### UI-1 — top-level Polygon

View File

@ -2,9 +2,11 @@
## Status ## Status
Accepted for architecture; SIM S0 qualification returned `GO` and the S1B Accepted for architecture; SIM S0 qualification returned `GO`, the S1B
real-provider start/health/stop boundary passed on 2026-07-24. Navigation real-provider start/health/stop boundary passed on 2026-07-24, and the direct
behavior, safety behavior and real actuator authority are not accepted. 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 ## Context
@ -51,9 +53,10 @@ reports easy to misuse.
backend is C-backed and currently serves unrelated co-tenants. backend is C-backed and currently serves unrelated co-tenants.
13. S1 uses exact `px4_msgs` directly. The experimental 13. S1 uses exact `px4_msgs` directly. The experimental
`px4-ros2-interface-lib` is not a mandatory S1 dependency. `px4-ros2-interface-lib` is not a mandatory S1 dependency.
14. A read-only direct UI-0 run view may follow proven target S1B lifecycle and 14. The read-only direct UI-0 run view consumes proven target S1B persisted
persisted history. Top-level Polygon UI and control remain gated on accepted history through GET-only server routes. It is hidden from navigation and
S1 target command/lifecycle behavior. 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. 15. Real actuator authority requires a new decision and physical safety gate.
16. S0 processes run inside an ephemeral loopback-only Linux network namespace. 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 Micro XRCE-DDS Agent's UDP wildcard bind is acceptable only inside that
@ -113,10 +116,14 @@ 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 command was sent. Stop produced `completed`, eight events, zero commands and no
process residue. process residue.
This decision accepts UI-0 implementation against persisted run history. It UI-0 now implements that decision. `QualificationRunStore(read_only=True)`
does not widen command authority or accept pause/step/reset, canonical opens an existing repository without creating or chmodding it and rejects every
telemetry, frame conversion, watchdog/failsafe behavior, navigation or physical mutating method. The web process exposes only run catalog/detail GET routes,
control. 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 ## S1A implementation
@ -159,14 +166,16 @@ the accepted S0 dependency graph. `k1link.simulation.worker` binds that
primitive to the exact accepted S0 profile digest and canonical D-only S1 run primitive to the exact accepted S0 profile digest and canonical D-only S1 run
artifact and process-runtime layouts. artifact and process-runtime layouts.
This is not target-worker acceptance. Provider specifications, loopback network The later exact generation `6cb1495` supplied the target provider
namespace creation, live readiness probes, restart-safe PID/start-token specifications, loopback namespace and live readiness probes and passed the
reconciliation and Gazebo world-control/PX4 command adapters remain open. The real-provider start/health/stop boundary described above. Restart-safe
worker port deliberately fixes ownership semantics without prematurely fixing PID/start-token reconciliation after orchestrator loss and Gazebo
the eventual transport. 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: Commit `630d1ae` subsequently passed a D-only `MissionCore-Sim` bootstrap:
the exact Git archive SHA was verified, both S1 test modules returned 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 `20 passed`, and the test process-residue record was empty. This accepts target
executability of the foundation only; it does not change the open real-provider executability of the foundation only; the later `6cb1495` evidence, not that
boundaries above. bootstrap, closes the real-provider lifecycle boundary.

View File

@ -107,4 +107,19 @@ Open:
- Ackermann and Differential command mapping; - Ackermann and Differential command mapping;
- TTL/heartbeat/watchdog and provider-loss cases; - TTL/heartbeat/watchdog and provider-loss cases;
- repeatability verdict and complete S1 QualificationReport; - repeatability verdict and complete S1 QualificationReport;
- UI-0 implementation, S1C/S1D and S2. - S1C/S1D and S2.
## UI-0 follow-on
The direct internal read-only UI-0 surface is implemented after this acceptance
without changing the accepted S1B evidence. It reads the same
`artifacts/s1/runs` journal through GET-only backend routes and a
non-navigable Control Station workspace. Visual QA loaded the copied accepted
run and showed terminal `completed`, `operator-stop-clean`, four provider pins,
eight events, zero commands and the virtual-only authority boundary. The
browser received neither the configured D root nor artifact bytes or command
payloads.
This follow-on is interface/read-model acceptance only. It does not promote the
S1B run to S1 acceptance and does not close any remaining control, telemetry,
frame, failsafe, repeatability, navigation or physical-safety gate.

View File

@ -78,14 +78,23 @@ class QualificationRunStore:
exclusive filesystem semantics and fsynced before acknowledgement. exclusive filesystem semantics and fsynced before acknowledgement.
""" """
def __init__(self, root: Path) -> None: def __init__(self, root: Path, *, read_only: bool = False) -> None:
self.root = root.expanduser().resolve() 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) self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
_reject_symlink(self.root, "qualification repository") _reject_symlink(self.root, "qualification repository")
_chmod_private(self.root) _chmod_private(self.root)
self.read_only = read_only
self._lock = threading.RLock() self._lock = threading.RLock()
def create(self, run: QualificationRun) -> QualificationRun: def create(self, run: QualificationRun) -> QualificationRun:
self._require_writable()
if ( if (
run.state is not RunState.ADMITTED run.state is not RunState.ADMITTED
or run.revision != 0 or run.revision != 0
@ -152,6 +161,7 @@ class QualificationRunStore:
sim_time_ns: int | None = None, sim_time_ns: int | None = None,
reason: str | None = None, reason: str | None = None,
) -> QualificationRun: ) -> QualificationRun:
self._require_writable()
with self._lock: with self._lock:
current = self.load(run_id) current = self.load(run_id)
if current.revision != expected_revision: if current.revision != expected_revision:
@ -193,6 +203,7 @@ class QualificationRunStore:
sim_time_ns: int | None = None, sim_time_ns: int | None = None,
expected_revision: int | None = None, expected_revision: int | None = None,
) -> QualificationEvent: ) -> QualificationEvent:
self._require_writable()
if event_type == "lifecycle.state-changed": if event_type == "lifecycle.state-changed":
raise QualificationRunTransitionError("lifecycle events must use transition()") raise QualificationRunTransitionError("lifecycle events must use transition()")
with self._lock: with self._lock:
@ -215,6 +226,7 @@ class QualificationRunStore:
) )
def submit_command(self, command: ControlSetpoint) -> ControlSetpoint: def submit_command(self, command: ControlSetpoint) -> ControlSetpoint:
self._require_writable()
with self._lock: with self._lock:
run = self.load(command.run_id) run = self.load(command.run_id)
if run.kind not in COMMANDABLE_RUN_KINDS: if run.kind not in COMMANDABLE_RUN_KINDS:
@ -267,6 +279,7 @@ class QualificationRunStore:
run_id: str, run_id: str,
artifact: QualificationArtifact, artifact: QualificationArtifact,
) -> QualificationArtifact: ) -> QualificationArtifact:
self._require_writable()
with self._lock: with self._lock:
run = self.load(run_id) run = self.load(run_id)
if run.state.terminal: if run.state.terminal:
@ -304,6 +317,7 @@ class QualificationRunStore:
episode_id: str, episode_id: str,
created_at_utc: str, created_at_utc: str,
) -> QualificationRun: ) -> QualificationRun:
self._require_writable()
previous = self.load(previous_run_id) previous = self.load(previous_run_id)
if not previous.state.terminal: if not previous.state.terminal:
raise QualificationRunTransitionError( raise QualificationRunTransitionError(
@ -338,6 +352,7 @@ class QualificationRunStore:
observed_at_utc: str, observed_at_utc: str,
host_monotonic_ns: int, host_monotonic_ns: int,
) -> QualificationRun: ) -> QualificationRun:
self._require_writable()
run = self.load(run_id) run = self.load(run_id)
if run.state not in ACTIVE_RECOVERY_STATES: if run.state not in ACTIVE_RECOVERY_STATES:
return run return run
@ -446,6 +461,12 @@ class QualificationRunStore:
_reject_symlink(directory, f"run {name} journal") _reject_symlink(directory, f"run {name} journal")
return path return path
def _require_writable(self) -> None:
if self.read_only:
raise QualificationRunTransitionError(
"the qualification repository is open read-only"
)
def _replay_runtime( def _replay_runtime(
manifest: QualificationRun, manifest: QualificationRun,

View File

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

View File

@ -0,0 +1,157 @@
from __future__ import annotations
import os
from collections.abc import Callable
from pathlib import Path
from typing import Annotated, Any, Final
from fastapi import APIRouter, HTTPException, Query
from fastapi import Path as PathParameter
from k1link.simulation import (
QualificationRun,
QualificationRunIntegrityError,
QualificationRunNotFoundError,
QualificationRunStore,
)
POLYGON_RUNS_ROOT_ENV: Final = "MISSIONCORE_POLYGON_RUNS_ROOT"
CATALOG_SCHEMA: Final = "missioncore.polygon-run-catalog/v1"
DETAIL_SCHEMA: Final = "missioncore.polygon-run-detail/v1"
READ_ONLY_LIMITATIONS: Final[tuple[str, ...]] = (
"UI-0 публикует только квалификационные доказательства; "
"lifecycle-операции и команды отсутствуют.",
"Артефакты представлены относительными метаданными; их содержимое не публикуется этим API.",
"Терминальное состояние прогона не означает приёмку навигации, "
"восприятия или физической безопасности.",
)
RootProvider = Callable[[], Path | None]
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 build_polygon_router(
*,
root_provider: RootProvider = configured_polygon_runs_root,
) -> 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),
}
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),
}

185
tests/test_polygon_api.py Normal file
View File

@ -0,0 +1,185 @@
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 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)]
assert all(route.methods <= {"GET", "HEAD"} for route in 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

View File

@ -106,6 +106,40 @@ def test_run_manifest_is_immutable_and_runtime_replays_from_events(tmp_path: Pat
assert len(tuple((tmp_path / "runs/run-s1-001/events").iterdir())) == 2 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: def test_lifecycle_rejects_stale_illegal_and_post_terminal_changes(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path) store = QualificationRunStore(tmp_path)
store.create(_run()) store.create(_run())