From b790f29d5973503ce3157d187bf34829353931df Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 24 Jul 2026 19:02:10 +0300 Subject: [PATCH] feat: add Polygon UI-0 run view --- README.md | 34 +- apps/control-station/src/App.tsx | 19 +- .../src/core/polygon/runArchive.ts | 661 ++++++++++++++++++ apps/control-station/src/productModel.ts | 20 +- .../control-station/src/styles/responsive.css | 28 +- .../control-station/src/styles/workspaces.css | 393 +++++++++++ .../src/workspaces/PolygonRunWorkspace.tsx | 322 +++++++++ .../src/workspaces/Workspaces.tsx | 5 + .../test/polygonRunArchive.test.mjs | 230 ++++++ docs/01_IMPLEMENTATION_PLAN.md | 15 +- docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md | 40 +- ...mulation-polygon-qualification-boundary.md | 43 +- ...L_PROVIDER_ACCEPTANCE_20260724.redacted.md | 17 +- src/k1link/simulation/run_store.py | 31 +- src/k1link/web/app.py | 2 + src/k1link/web/polygon_api.py | 157 +++++ tests/test_polygon_api.py | 185 +++++ tests/test_simulation_s1_run_store.py | 34 + 18 files changed, 2193 insertions(+), 43 deletions(-) create mode 100644 apps/control-station/src/core/polygon/runArchive.ts create mode 100644 apps/control-station/src/workspaces/PolygonRunWorkspace.tsx create mode 100644 apps/control-station/test/polygonRunArchive.test.mjs create mode 100644 src/k1link/web/polygon_api.py create mode 100644 tests/test_polygon_api.py diff --git a/README.md b/README.md index 99b62b4..ecdb490 100644 --- a/README.md +++ b/README.md @@ -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, and stopped both owned PGIDs with zero residue. The persisted run reached `completed`, contains eight lifecycle events and zero control commands. This -opens UI-0 implementation; live pause/resume/step/reset, canonical telemetry, -PX4 command mapping, watchdog/failsafe evidence and the top-level Polygon -control surface remain gated. +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. + +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= +``` + +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), [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. 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, -but is not yet present in the current shell. Top-level Polygon navigation and -control remain gated on accepted clock/frame/command/safety behavior. +implemented inside the current shell but intentionally omitted from all +top-level and System navigation. It opens only through +`?workspace=polygon-run[&run=]` 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 diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index a8d1e8d..95ae3ed 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -46,6 +46,7 @@ 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 { OBSERVATION_WORKSPACE_ID, OBSERVATION_WORKSPACE_LAYOUT_VERSION, @@ -149,12 +150,18 @@ function mergeViewerSettings( export default function App() { const runtime = useMissionRuntime(); const { selection } = useDevicePluginHost(); + const polygonRunRoute = useMemo( + () => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search), + [], + ); const workspace = useApplicationWorkspace({ navigationOpen: false, contentExpanded: true, }); - const [activeRoot, setActiveRoot] = useState(null); + const [activeRoot, setActiveRoot] = useState( + polygonRunRoute.active ? "system" : null, + ); const [sourceUrl, setSourceUrl] = useState(""); const [recordedReplay, setRecordedReplay] = useState(null); const [recordedReplayLabel, setRecordedReplayLabel] = useState(null); @@ -187,6 +194,7 @@ export default function App() { const replayActiveRef = useRef(false); const sceneSettingsCommitterActiveRef = useRef(true); const sceneSettingsCommitterRef = useRef | null>(null); + const polygonRunRouteOpenedRef = useRef(false); const currentRoot = rootById(activeRoot); const activeDefinition = workspaceById(workspace.activeView); @@ -204,6 +212,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-internal"); + }, [polygonRunRoute.active, workspace]); + if (!sceneSettingsCommitterRef.current) { sceneSettingsCommitterRef.current = createLatestAsyncCommitter({ commit: (settings) => replayActiveRef.current @@ -718,6 +732,8 @@ export default function App() { ) : null} + ) : activeDefinition.kind === "polygon-run" ? ( + Только чтение ) : ( Интерфейс готов ) @@ -746,6 +762,7 @@ export default function App() { livePerceptionLayers={livePerceptionLayers} onLivePerceptionLayersChange={changeLivePerceptionLayers} observationLayout={observationLayout} + polygonRunRoute={polygonRunRoute} spatialControls={selection?.SpatialControlsView ? { View: selection.SpatialControlsView, diff --git a/apps/control-station/src/core/polygon/runArchive.ts b/apps/control-station/src/core/polygon/runArchive.ts new file mode 100644 index 0000000..f6b2eb4 --- /dev/null +++ b/apps/control-station/src/core/polygon/runArchive.ts @@ -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; +} + +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; + +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([ + "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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertExactKeys( + value: Record, + expected: ReadonlySet, + 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 = 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/apps/control-station/src/productModel.ts b/apps/control-station/src/productModel.ts index e22fda3..90d42e9 100644 --- a/apps/control-station/src/productModel.ts +++ b/apps/control-station/src/productModel.ts @@ -10,7 +10,8 @@ export type WorkspaceKind = | "map" | "timeline" | "missions" - | "catalog"; + | "catalog" + | "polygon-run"; export type CapabilityStatus = "active" | "ready" | "contract" | "later"; @@ -35,6 +36,7 @@ export interface WorkspaceDefinition { description: string; icon: IconName; kind: WorkspaceKind; + internalOnly?: boolean; groups: CapabilityGroup[]; } @@ -130,6 +132,18 @@ export const roots: RootDefinition[] = [ ]; 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", root: "center", @@ -747,7 +761,9 @@ export function workspaceById(id: string | null): WorkspaceDefinition | 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 = { diff --git a/apps/control-station/src/styles/responsive.css b/apps/control-station/src/styles/responsive.css index d5dc8a9..a49ca92 100644 --- a/apps/control-station/src/styles/responsive.css +++ b/apps/control-station/src/styles/responsive.css @@ -10,10 +10,16 @@ @media (max-width: 1280px) { .overview-grid, - .mission-layout { + .mission-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,6 +73,10 @@ 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; @@ -131,10 +141,24 @@ .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-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)); } diff --git a/apps/control-station/src/styles/workspaces.css b/apps/control-station/src/styles/workspaces.css index 3e8d568..b73cc7e 100644 --- a/apps/control-station/src/styles/workspaces.css +++ b/apps/control-station/src/styles/workspaces.css @@ -90,6 +90,399 @@ 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 { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); diff --git a/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx b/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx new file mode 100644 index 0000000..7c2c4a1 --- /dev/null +++ b/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx @@ -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 = { + 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(null); + const [detail, setDetail] = useState(null); + const [selectedRunId, setSelectedRunId] = useState(route.runId); + const [loading, setLoading] = useState(route.error === null); + const [error, setError] = useState(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 ( +
+ + Только чтение +

Проверяем журнал прогона

+

Mission Core читает манифест, события и индекс артефактов без запуска провайдеров.

+
+
+ ); + } + + if (error) { + return ( +
+ + Данные недоступны +

UI-0 не может открыть прогон

+

{error}

+ +
+
+ ); + } + + if (!detail) { + return ( +
+ + Журнал пуст +

Квалификационных прогонов пока нет

+

Экран появится автоматически после публикации первого журнала в read-only источник.

+
+
+ ); + } + + const { run } = detail; + return ( +
+
+
+ ПОЛИГОН / КВАЛИФИКАЦИОННЫЙ ПРОГОН +

{run.runId}

+

+ Канонический журнал Mission Core. Экран не содержит lifecycle-операций, + команд управления или доступа к физическим актуаторам. +

+
+
+ {stateLabels[run.state]} + read-only · {run.reproducibilityTier} +
+
+ +
+
+ Состояние + {stateLabels[run.state]} + {run.terminalReason ?? "терминальная причина отсутствует"} +
+
+ Провайдеры + {run.providers.length} + {run.providerIds.join(" · ")} +
+
+ События + {detail.eventsTotal} + {detail.eventsTruncated ? "показан последний фрагмент" : "журнал целиком"} +
+
+ Команды + {detail.commandCount} + содержимое не публикуется UI-0 +
+
+ +
+ +
+
+ ПОСЛЕДНИЕ ПРОГОНЫ +

{catalog?.total ?? 0} в источнике

+
+ +
+
+ {catalog?.items.map((item) => ( +