feat: add Polygon UI-0 run view
This commit is contained in:
parent
a78c8c83f5
commit
b790f29d59
34
README.md
34
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=<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),
|
||||
[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=<id>]` and reads the server-owned qualification
|
||||
repository. Top-level Polygon navigation and control remain gated on accepted
|
||||
clock/frame/command/safety behavior.
|
||||
|
||||
The Observation spatial workspace embeds the open-source Rerun Web Viewer
|
||||
inside the Mission Core shell. It can open a compatible RRD over same-origin
|
||||
|
|
|
|||
|
|
@ -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<string>({
|
||||
navigationOpen: false,
|
||||
contentExpanded: true,
|
||||
});
|
||||
|
||||
const [activeRoot, setActiveRoot] = useState<RootId | null>(null);
|
||||
const [activeRoot, setActiveRoot] = useState<RootId | null>(
|
||||
polygonRunRoute.active ? "system" : null,
|
||||
);
|
||||
const [sourceUrl, setSourceUrl] = useState("");
|
||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
||||
|
|
@ -187,6 +194,7 @@ export default function App() {
|
|||
const replayActiveRef = useRef(false);
|
||||
const sceneSettingsCommitterActiveRef = useRef(true);
|
||||
const sceneSettingsCommitterRef = useRef<LatestAsyncCommitter<SceneSettings> | null>(null);
|
||||
const polygonRunRouteOpenedRef = useRef(false);
|
||||
|
||||
const currentRoot = rootById(activeRoot);
|
||||
const 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<SceneSettings>({
|
||||
commit: (settings) => replayActiveRef.current
|
||||
|
|
@ -718,6 +732,8 @@ export default function App() {
|
|||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : activeDefinition.kind === "polygon-run" ? (
|
||||
<StatusBadge tone="accent">Только чтение</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||
)
|
||||
|
|
@ -746,6 +762,7 @@ export default function App() {
|
|||
livePerceptionLayers={livePerceptionLayers}
|
||||
onLivePerceptionLayersChange={changeLivePerceptionLayers}
|
||||
observationLayout={observationLayout}
|
||||
polygonRunRoute={polygonRunRoute}
|
||||
spatialControls={selection?.SpatialControlsView
|
||||
? {
|
||||
View: selection.SpatialControlsView,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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<CapabilityStatus, string> = {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
import { ObservationTimeline } from "../components/ObservationTimeline";
|
||||
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
|
||||
import type { ObservationSessionReplayLaunch } from "../core/observation/sessionArchive";
|
||||
import type { PolygonRunRoute } from "../core/polygon/runArchive";
|
||||
import type { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission";
|
||||
import type {
|
||||
RecordedAdmissionPhase,
|
||||
|
|
@ -55,6 +56,7 @@ import {
|
|||
} from "../productModel";
|
||||
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import { PolygonRunWorkspace } from "./PolygonRunWorkspace";
|
||||
|
||||
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
|
||||
if (status === "active") return "success";
|
||||
|
|
@ -136,6 +138,7 @@ export interface WorkspaceRendererProps {
|
|||
cuboids3d: boolean;
|
||||
}) => void;
|
||||
observationLayout: ObservationLayoutController;
|
||||
polygonRunRoute: PolygonRunRoute;
|
||||
navigation: WorkspaceNavigation;
|
||||
spatialControls: {
|
||||
View: ComponentType<DevicePluginConnectionProps>;
|
||||
|
|
@ -1303,6 +1306,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
|||
return <MissionWorkspace {...props} />;
|
||||
case "catalog":
|
||||
return <CatalogWorkspace {...props} />;
|
||||
case "polygon-run":
|
||||
return <PolygonRunWorkspace route={props.polygonRunRoute} />;
|
||||
case "device":
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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"));
|
||||
});
|
||||
|
|
@ -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 |
|
||||
| K1 application control | GO (physical staged cycle) — after fixing the PCAP-proven `sint64` time field, one explicit UI launch completed all 14 canonical operations on one control session, reached live `SCANNING + project + init_ready`, displayed real points, then one explicit STOP returned K1 to unbound `READY`. No retry or fallback command was sent. Native-project reuse through LixelGO/USB remains an independent verification |
|
||||
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
|
||||
| Simulation Polygon | SIM S0 GO; S1A complete; S1B 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
|
||||
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
|
||||
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
|
||||
|
||||
Deliverables:
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ Ops source of truth:
|
|||
- [MISSIONCOR-39](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-39) is the
|
||||
canonical, changeable architecture plan.
|
||||
- [MISSIONCOR-40](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-40) is the
|
||||
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
|
||||
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
|
||||
world/startup/DDS-writer readiness, eight persisted events, zero commands,
|
||||
terminal `completed` and zero process residue;
|
||||
- UI-0 is implemented as a hidden direct read-only Control Station workspace
|
||||
over GET-only Polygon API routes. Browser QA loaded the real accepted S1B
|
||||
journal and displayed its four provider pins, eight events, zero commands and
|
||||
unchanged virtual-only authority boundary;
|
||||
- `actuator_authority=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
|
||||
`/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/lifecycle/`.
|
||||
|
||||
This accepts the S1B target start/health/stop boundary and unlocks UI-0. It does
|
||||
not accept restart-safe PID/start-token reconciliation, live
|
||||
This accepts the S1B target start/health/stop boundary. UI-0 now consumes the
|
||||
accepted persisted history without widening authority. It does not accept
|
||||
restart-safe PID/start-token reconciliation, live
|
||||
pause/resume/step/reset, canonical VehicleState, frame conversion, rover command
|
||||
mapping, watchdog/failsafe cases, repeatability or S1 as a whole.
|
||||
|
||||
|
|
@ -624,8 +631,8 @@ Two UI gates are intentionally distinct.
|
|||
|
||||
### UI-0 — early read-only run view
|
||||
|
||||
Target S1B now proves persisted start/health/stop history. A direct internal run
|
||||
view may expose:
|
||||
Target S1B proves persisted start/health/stop history. UI-0 is implemented as a
|
||||
direct internal run view and exposes:
|
||||
|
||||
- run identity, lifecycle and terminal reason;
|
||||
- authoritative clock and current pause/running state;
|
||||
|
|
@ -633,9 +640,26 @@ view may expose:
|
|||
- qualification events and admitted artifact links;
|
||||
- explicit limitations and gate status.
|
||||
|
||||
UI-0 has no PX4 transport, no control commands and no top-level Polygon
|
||||
navigation claim. It is a development/acceptance surface backed only by the
|
||||
server-owned run repository.
|
||||
The backend reads the configured repository from
|
||||
`MISSIONCORE_POLYGON_RUNS_ROOT` using
|
||||
`QualificationRunStore(read_only=True)`. It exposes only:
|
||||
|
||||
```text
|
||||
GET /api/v1/polygon/runs
|
||||
GET /api/v1/polygon/runs/{run-id}
|
||||
```
|
||||
|
||||
Missing/malformed configuration returns `503`; invalid or corrupt evidence
|
||||
fails closed. Responses never contain the configured root, artifact bytes or
|
||||
command payloads. The UI opens only through
|
||||
`?workspace=polygon-run[&run=<run-id>]`; its hidden workspace is filtered from
|
||||
System navigation and no seventh top-level section is added.
|
||||
|
||||
UI-0 has no PX4 transport, no lifecycle/command operations and no top-level
|
||||
Polygon navigation claim. It is a development/acceptance surface backed only
|
||||
by the server-owned run repository. A shared review deployment requires a
|
||||
reviewed read-only D mount or a co-located backend; SSH is not a product data
|
||||
plane.
|
||||
|
||||
### UI-1 — top-level Polygon
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
## Status
|
||||
|
||||
Accepted for architecture; SIM S0 qualification returned `GO` and the S1B
|
||||
real-provider start/health/stop boundary passed on 2026-07-24. Navigation
|
||||
behavior, safety behavior and real actuator authority are not accepted.
|
||||
Accepted for architecture; SIM S0 qualification returned `GO`, the S1B
|
||||
real-provider start/health/stop boundary passed on 2026-07-24, and the direct
|
||||
read-only UI-0 view is implemented and visually verified against that accepted
|
||||
journal. Navigation behavior, safety behavior and real actuator authority are
|
||||
not accepted.
|
||||
|
||||
## Context
|
||||
|
||||
|
|
@ -51,9 +53,10 @@ reports easy to misuse.
|
|||
backend is C-backed and currently serves unrelated co-tenants.
|
||||
13. S1 uses exact `px4_msgs` directly. The experimental
|
||||
`px4-ros2-interface-lib` is not a mandatory S1 dependency.
|
||||
14. A read-only direct UI-0 run view may follow proven target S1B lifecycle and
|
||||
persisted history. Top-level Polygon UI and control remain gated on accepted
|
||||
S1 target command/lifecycle behavior.
|
||||
14. The read-only direct UI-0 run view consumes proven target S1B persisted
|
||||
history through GET-only server routes. It is hidden from navigation and
|
||||
cannot mutate a run. Top-level Polygon UI and control remain gated on
|
||||
accepted S1 target command/lifecycle behavior.
|
||||
15. Real actuator authority requires a new decision and physical safety gate.
|
||||
16. S0 processes run inside an ephemeral loopback-only Linux network namespace.
|
||||
Micro XRCE-DDS Agent's UDP wildcard bind is acceptable only inside that
|
||||
|
|
@ -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
|
||||
process residue.
|
||||
|
||||
This decision accepts UI-0 implementation against persisted run history. It
|
||||
does not widen command authority or accept pause/step/reset, canonical
|
||||
telemetry, frame conversion, watchdog/failsafe behavior, navigation or physical
|
||||
control.
|
||||
UI-0 now implements that decision. `QualificationRunStore(read_only=True)`
|
||||
opens an existing repository without creating or chmodding it and rejects every
|
||||
mutating method. The web process exposes only run catalog/detail GET routes,
|
||||
and the hidden Control Station workspace opens only from
|
||||
`?workspace=polygon-run[&run=<id>]`. Browser QA loaded the accepted
|
||||
`s1b-6cb1495-20260724t1535z` journal. This does not widen command authority or
|
||||
accept pause/step/reset, canonical telemetry, frame conversion,
|
||||
watchdog/failsafe behavior, navigation or physical control.
|
||||
|
||||
## S1A implementation
|
||||
|
||||
|
|
@ -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
|
||||
artifact and process-runtime layouts.
|
||||
|
||||
This is not target-worker acceptance. Provider specifications, loopback network
|
||||
namespace creation, live readiness probes, restart-safe PID/start-token
|
||||
reconciliation and Gazebo world-control/PX4 command adapters remain open. The
|
||||
worker port deliberately fixes ownership semantics without prematurely fixing
|
||||
the eventual transport.
|
||||
The later exact generation `6cb1495` supplied the target provider
|
||||
specifications, loopback namespace and live readiness probes and passed the
|
||||
real-provider start/health/stop boundary described above. Restart-safe
|
||||
PID/start-token reconciliation after orchestrator loss and Gazebo
|
||||
world-control/PX4 command adapters remain open. The worker port deliberately
|
||||
fixes ownership semantics without making SSH or the browser a provider
|
||||
transport.
|
||||
|
||||
Commit `630d1ae` subsequently passed a D-only `MissionCore-Sim` bootstrap:
|
||||
the exact Git archive SHA was verified, both S1 test modules returned
|
||||
`20 passed`, and the test process-residue record was empty. This accepts target
|
||||
executability of the foundation only; it does not change the open real-provider
|
||||
boundaries above.
|
||||
executability of the foundation only; the later `6cb1495` evidence, not that
|
||||
bootstrap, closes the real-provider lifecycle boundary.
|
||||
|
|
|
|||
|
|
@ -107,4 +107,19 @@ Open:
|
|||
- Ackermann and Differential command mapping;
|
||||
- TTL/heartbeat/watchdog and provider-loss cases;
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -78,14 +78,23 @@ class QualificationRunStore:
|
|||
exclusive filesystem semantics and fsynced before acknowledgement.
|
||||
"""
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.root = root.expanduser().resolve()
|
||||
def __init__(self, root: Path, *, read_only: bool = False) -> None:
|
||||
configured_root = root.expanduser()
|
||||
if read_only:
|
||||
if not configured_root.is_dir():
|
||||
raise QualificationRunIntegrityError("qualification repository is missing")
|
||||
_reject_symlink(configured_root, "qualification repository")
|
||||
self.root = configured_root.resolve()
|
||||
else:
|
||||
self.root = configured_root.resolve()
|
||||
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
_reject_symlink(self.root, "qualification repository")
|
||||
_chmod_private(self.root)
|
||||
self.read_only = read_only
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def create(self, run: QualificationRun) -> QualificationRun:
|
||||
self._require_writable()
|
||||
if (
|
||||
run.state is not RunState.ADMITTED
|
||||
or run.revision != 0
|
||||
|
|
@ -152,6 +161,7 @@ class QualificationRunStore:
|
|||
sim_time_ns: int | None = None,
|
||||
reason: str | None = None,
|
||||
) -> QualificationRun:
|
||||
self._require_writable()
|
||||
with self._lock:
|
||||
current = self.load(run_id)
|
||||
if current.revision != expected_revision:
|
||||
|
|
@ -193,6 +203,7 @@ class QualificationRunStore:
|
|||
sim_time_ns: int | None = None,
|
||||
expected_revision: int | None = None,
|
||||
) -> QualificationEvent:
|
||||
self._require_writable()
|
||||
if event_type == "lifecycle.state-changed":
|
||||
raise QualificationRunTransitionError("lifecycle events must use transition()")
|
||||
with self._lock:
|
||||
|
|
@ -215,6 +226,7 @@ class QualificationRunStore:
|
|||
)
|
||||
|
||||
def submit_command(self, command: ControlSetpoint) -> ControlSetpoint:
|
||||
self._require_writable()
|
||||
with self._lock:
|
||||
run = self.load(command.run_id)
|
||||
if run.kind not in COMMANDABLE_RUN_KINDS:
|
||||
|
|
@ -267,6 +279,7 @@ class QualificationRunStore:
|
|||
run_id: str,
|
||||
artifact: QualificationArtifact,
|
||||
) -> QualificationArtifact:
|
||||
self._require_writable()
|
||||
with self._lock:
|
||||
run = self.load(run_id)
|
||||
if run.state.terminal:
|
||||
|
|
@ -304,6 +317,7 @@ class QualificationRunStore:
|
|||
episode_id: str,
|
||||
created_at_utc: str,
|
||||
) -> QualificationRun:
|
||||
self._require_writable()
|
||||
previous = self.load(previous_run_id)
|
||||
if not previous.state.terminal:
|
||||
raise QualificationRunTransitionError(
|
||||
|
|
@ -338,6 +352,7 @@ class QualificationRunStore:
|
|||
observed_at_utc: str,
|
||||
host_monotonic_ns: int,
|
||||
) -> QualificationRun:
|
||||
self._require_writable()
|
||||
run = self.load(run_id)
|
||||
if run.state not in ACTIVE_RECOVERY_STATES:
|
||||
return run
|
||||
|
|
@ -446,6 +461,12 @@ class QualificationRunStore:
|
|||
_reject_symlink(directory, f"run {name} journal")
|
||||
return path
|
||||
|
||||
def _require_writable(self) -> None:
|
||||
if self.read_only:
|
||||
raise QualificationRunTransitionError(
|
||||
"the qualification repository is open read-only"
|
||||
)
|
||||
|
||||
|
||||
def _replay_runtime(
|
||||
manifest: QualificationRun,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ from k1link.web.plugin_runtime import (
|
|||
PluginNotFoundError,
|
||||
PluginRuntimeUnavailableError,
|
||||
)
|
||||
from k1link.web.polygon_api import build_polygon_router
|
||||
from k1link.web.session_api import build_session_router
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
|
@ -396,6 +397,7 @@ app.include_router(
|
|||
point_color_renderers=plugin_environment.point_color_renderers,
|
||||
)
|
||||
)
|
||||
app.include_router(build_polygon_router())
|
||||
|
||||
|
||||
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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),
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
def test_read_only_store_replays_evidence_without_mutating_repository(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = tmp_path / "runs"
|
||||
writer = QualificationRunStore(root)
|
||||
writer.create(_run())
|
||||
running = _start(writer)
|
||||
mode_before = root.stat().st_mode
|
||||
|
||||
reader = QualificationRunStore(root, read_only=True)
|
||||
|
||||
assert reader.load(running.run_id).state is RunState.RUNNING
|
||||
assert reader.list_runs()[0].run_id == running.run_id
|
||||
assert root.stat().st_mode == mode_before
|
||||
with pytest.raises(QualificationRunTransitionError, match="read-only"):
|
||||
reader.transition(
|
||||
running.run_id,
|
||||
RunState.PAUSED,
|
||||
expected_revision=running.revision,
|
||||
observed_at_utc="2026-07-24T17:00:03Z",
|
||||
host_monotonic_ns=3,
|
||||
)
|
||||
|
||||
|
||||
def test_read_only_store_requires_an_existing_regular_repository(tmp_path: Path) -> None:
|
||||
with pytest.raises(QualificationRunIntegrityError, match="missing"):
|
||||
QualificationRunStore(tmp_path / "missing", read_only=True)
|
||||
|
||||
linked = tmp_path / "linked"
|
||||
linked.symlink_to(tmp_path, target_is_directory=True)
|
||||
with pytest.raises(QualificationRunIntegrityError, match="symlink"):
|
||||
QualificationRunStore(linked, read_only=True)
|
||||
|
||||
|
||||
def test_lifecycle_rejects_stale_illegal_and_post_terminal_changes(tmp_path: Path) -> None:
|
||||
store = QualificationRunStore(tmp_path)
|
||||
store.create(_run())
|
||||
|
|
|
|||
Loading…
Reference in New Issue