From 023151c1864486d5e6a8a1866edf2272b90dc3ac Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 30 Aug 2026 19:21:27 +0300 Subject: [PATCH] feat(observatory): admit canonical recorded replay --- .../CanonicalVegetationRerunReplay.tsx | 249 +++++++ .../src/core/laboratory/vegetationShadow.ts | 13 +- .../core/observation/labReplayCapability.ts | 195 +++++ .../src/core/observation/replayCoordinator.ts | 43 ++ .../src/core/observation/sessionArchive.ts | 35 +- .../observation/useObservationSessions.ts | 56 +- .../src/core/observatory/catalog.ts | 6 + .../src/core/observatory/recordedRun.ts | 129 ++++ .../src/styles/observatory.css | 67 ++ .../CanonicalVegetationRerunReplay.tsx | 250 +------ .../laboratory/LaboratoryArchiveWorkspace.tsx | 8 +- .../laboratory/laboratoryArchiveProfiles.ts | 11 + .../observatory/ObservatoryWorkspace.tsx | 178 ++++- .../test/advancedLaboratoryResults.test.mjs | 27 + .../test/applicationArchitecture.test.mjs | 27 +- .../test/observationSessions.test.mjs | 121 +++- .../test/observatoryCatalog.test.mjs | 78 +- .../test/observatoryRecordedRun.test.mjs | 180 +++++ .../test/observatoryWorkspace.test.mjs | 38 +- .../test/vegetationShadow.test.mjs | 2 +- docs/24_M5_1_OBSERVATORY_SURFACE_BRIEF.md | 61 +- ...blish_canonical_recorded_lab_projection.py | 64 ++ .../laboratory/canonical_recorded_catalog.py | 670 ++++++++++++++++++ src/k1link/sessions/__init__.py | 2 + src/k1link/sessions/models.py | 56 +- src/k1link/sessions/store.py | 618 +++++++++++++++- src/k1link/web/app.py | 6 +- src/k1link/web/session_api.py | 18 +- tests/test_canonical_recorded_catalog.py | 546 ++++++++++++++ tests/test_session_api.py | 77 +- tests/test_session_catalog_lifecycle.py | 55 ++ tests/test_session_store.py | 525 ++++++++++++++ 32 files changed, 4069 insertions(+), 342 deletions(-) create mode 100644 apps/control-station/src/components/laboratory/CanonicalVegetationRerunReplay.tsx create mode 100644 apps/control-station/src/core/observation/labReplayCapability.ts create mode 100644 apps/control-station/src/core/observation/replayCoordinator.ts create mode 100644 apps/control-station/src/core/observatory/recordedRun.ts create mode 100644 apps/control-station/test/observatoryRecordedRun.test.mjs create mode 100644 scripts/publish_canonical_recorded_lab_projection.py create mode 100644 src/k1link/laboratory/canonical_recorded_catalog.py create mode 100644 tests/test_canonical_recorded_catalog.py diff --git a/apps/control-station/src/components/laboratory/CanonicalVegetationRerunReplay.tsx b/apps/control-station/src/components/laboratory/CanonicalVegetationRerunReplay.tsx new file mode 100644 index 0000000..24aaf52 --- /dev/null +++ b/apps/control-station/src/components/laboratory/CanonicalVegetationRerunReplay.tsx @@ -0,0 +1,249 @@ +import { useEffect, useMemo, useState } from "react"; +import { Button, Icon, SegmentedControl } from "@nodedc/ui-react"; + +import { ObservationTimeline } from "../ObservationTimeline"; +import { + RerunViewport, + type RerunPlaybackController, + type RerunPlaybackState, +} from "../RerunViewport"; +import { + CanonicalRecordedLabReplay, + useCanonicalRecordedLabReplayState, +} from "../laboratory/CanonicalRecordedLabReplay"; +import { + resolveCanonicalLabReplay, + type CanonicalLabReplayDescriptor, +} from "../../core/laboratory/canonicalLabReplay"; +import type { VegetationFullRouteReview } from "../../core/laboratory/vegetationShadow"; +import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive"; +import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile"; +import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions"; +import { defaultSceneSettings } from "../../sceneSettings"; + +type MediaMode = "video" | "camera"; +type SpatialMode = "3d" | "plan"; +type SpatialLayer = "source" | "local" | "tgs" | "semantic"; +type SemanticLayer = "city" | "vegetation"; + +interface CanonicalReplayLaunch { + base: ObservationSessionReplayLaunch; + replay: CanonicalLabReplayDescriptor; +} + +export function CanonicalVegetationRerunReplay({ + resultId, + review, +}: { + resultId: string; + review: VegetationFullRouteReview; +}) { + const { + mediaMode, + spatialMode, + splitPrimarySize, + splitOrientation, + expanded, + onMediaModeChange, + onSpatialModeChange, + onSplitPrimarySizeChange, + onExpandedChange, + } = useCanonicalRecordedLabReplayState({ + initialMediaMode: "video", + initialSpatialMode: "3d", + }); + const [semanticLayer, setSemanticLayer] = useState("vegetation"); + const [showSemantics, setShowSemantics] = useState(true); + const [spatialLayer, setSpatialLayer] = useState("source"); + const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(0); + const [playback, setPlayback] = useState(null); + const [playbackController, setPlaybackController] = + useState(null); + const [launch, setLaunch] = useState(null); + const [launchError, setLaunchError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + setLaunch(null); + setLaunchError(null); + void resolveObservationSessionReplay(review.sessionId, { + signal: controller.signal, + maximumWaitMs: 30 * 60 * 1000, + onUpdate: () => undefined, + }).then(async (value) => ({ + base: value, + replay: await resolveCanonicalLabReplay(resultId, value, { + signal: controller.signal, + }), + })).then((value) => { + if (!controller.signal.aborted) setLaunch(value); + }).catch((caught: unknown) => { + if (!controller.signal.aborted) { + setLaunchError( + caught instanceof Error ? caught.message : "Каноническая запись RAV004 недоступна.", + ); + } + }); + return () => controller.abort(); + }, [resultId, review.sessionId]); + + const splitView = mediaMode !== null && spatialMode !== null; + const sceneSettings = useMemo(() => ({ + ...defaultSceneSettings, + accumulationSeconds: spatialLayer === "local" ? 5 : 0, + showPoints: spatialMode !== null, + showTrajectory: spatialMode !== null, + showGrid: spatialMode !== null, + pointSize: 3.8, + }), [spatialLayer, spatialMode]); + const profile = launch ? recordedSessionRerunProfile({ + sourceUrl: launch.replay.sourceUrl, + artifact: { + sourceUrl: launch.replay.sourceUrl, + viewerSourceUrl: launch.replay.viewerSourceUrl, + byteLength: launch.replay.byteLength, + sha256: launch.replay.sha256, + }, + blueprintSourceUrl: launch.replay.blueprintSourceUrl, + autoplayWhenReady: false, + presentationGate: "ready", + expectedTimelineStartSeconds: launch.base.timelineStartSeconds, + expectedTimelineEndSeconds: launch.base.timelineEndSeconds, + initialPlaybackStartSeconds: review.timelineStartSeconds, + view: mediaMode !== null ? "perception" : "spatial", + viewResetGeneration, + followTrajectory: true, + semanticLayer, + unifiedPerception: splitView, + planView: spatialMode === "plan", + perceptionLayers: { + enabled: mediaMode !== null, + detections2d: mediaMode === "video", + segmentation: mediaMode === "video" && showSemantics, + cuboids3d: false, + }, + perceptionRetryGeneration: 0, + lockPerceptionCameraInteraction: false, + }) : null; + + const mediaLayerControls = ( +
+ + { + setSemanticLayer(value); + setShowSemantics(true); + }} + /> +
+ ); + const spatialLayerControls = ( +
+ +
+ ); + const resetSpatialView = ( + + ); + + const transport = playback && playbackController ? ( + + ) : undefined; + return ( + + ) : ( +
+ {launchError ?? "Готовим единый кэш канонического повтора RAV004…"} +
+ )} + emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте." + transport={transport} + onMediaModeChange={onMediaModeChange} + onSpatialModeChange={onSpatialModeChange} + onExpandedChange={onExpandedChange} + onSplitPrimarySizeChange={onSplitPrimarySizeChange} + /> + ); +} diff --git a/apps/control-station/src/core/laboratory/vegetationShadow.ts b/apps/control-station/src/core/laboratory/vegetationShadow.ts index 9ed26b8..d77d0ec 100644 --- a/apps/control-station/src/core/laboratory/vegetationShadow.ts +++ b/apps/control-station/src/core/laboratory/vegetationShadow.ts @@ -1026,7 +1026,7 @@ export async function fetchVegetationRouteTgsAnchor( }; } -export async function fetchVegetationShadowResult( +export async function fetchVegetationShadowResultMetadata( resultId: string, { fetcher = fetch, @@ -1048,6 +1048,17 @@ export async function fetchVegetationShadowResult( resultId, "/api/v1/laboratory/vegetation-shadow", ); + return result; +} + +export async function fetchVegetationShadowResult( + resultId: string, + { + fetcher = fetch, + signal, + }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}, +): Promise { + const result = await fetchVegetationShadowResultMetadata(resultId, { fetcher, signal }); if (!result.routeFullReview) return result; const timelineResponse = await fetcher( `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/route-timeline`, diff --git a/apps/control-station/src/core/observation/labReplayCapability.ts b/apps/control-station/src/core/observation/labReplayCapability.ts new file mode 100644 index 0000000..3ff4c2c --- /dev/null +++ b/apps/control-station/src/core/observation/labReplayCapability.ts @@ -0,0 +1,195 @@ +export interface ObservationLabReplayCapability { + schemaVersion: "missioncore.observation-lab-replay-capability/v1"; + kind: "canonical-recorded-rerun"; + viewerProfile: "recorded-session"; + timeline: "session_time"; + activation: "explicit"; + commandsEnabled: false; +} + +export class ObservationLabReplayCapabilityContractError extends Error { + constructor(message: string) { + super(message); + this.name = "ObservationLabReplayCapabilityContractError"; + } +} + +const SHA256 = /^[a-f0-9]{64}$/; +const CAPABILITY_KEYS = new Set([ + "schema_version", + "kind", + "viewer_profile", + "timeline", + "activation", + "commands_enabled", +]); +const CANONICAL_PROVENANCE_KEYS = new Set([ + "schema_version", + "evidence_identity_sha256", + "result_document_sha256", + "replay_capability", + "authority", + "method", +]); +const AUTHORITY_KEYS = new Set([ + "commands_enabled", + "navigation_or_safety_accepted", + "actuation_accepted", +]); +const METHOD_KEYS = new Set([ + "schema_version", + "completeness", + "execution_class", + "pipeline_id", + "components", +]); +const METHOD_COMPONENT_KEYS = new Set([ + "kind", + "name", + "version", + "role", + "identity_sha256", +]); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function assertExactKeys( + value: Record, + expected: ReadonlySet, + label: string, +): void { + const keys = Object.keys(value); + if (keys.length !== expected.size || keys.some((key) => !expected.has(key))) { + throw new ObservationLabReplayCapabilityContractError( + `${label} содержит неизвестные или пропущенные поля.`, + ); + } +} + +export function decodeLabReplayCapability( + value: unknown, + sessionId: string, +): ObservationLabReplayCapability | null { + if (value === null || value === undefined) return null; + if (!isRecord(value)) { + throw new ObservationLabReplayCapabilityContractError( + `Replay-возможность LAB-сессии ${sessionId} должна быть объектом или null.`, + ); + } + assertExactKeys(value, CAPABILITY_KEYS, `Replay-возможность LAB-сессии ${sessionId}`); + if ( + value.schema_version !== "missioncore.observation-lab-replay-capability/v1" + || value.kind !== "canonical-recorded-rerun" + || value.viewer_profile !== "recorded-session" + || value.timeline !== "session_time" + || value.activation !== "explicit" + || value.commands_enabled !== false + ) { + throw new ObservationLabReplayCapabilityContractError( + `Replay-возможность LAB-сессии ${sessionId} нарушает observation-only контракт.`, + ); + } + return { + schemaVersion: "missioncore.observation-lab-replay-capability/v1", + kind: "canonical-recorded-rerun", + viewerProfile: "recorded-session", + timeline: "session_time", + activation: "explicit", + commandsEnabled: false, + }; +} + +export function decodeCanonicalLabReplayCapabilityProvenance( + provenance: Readonly>, + sessionId: string, + resultId: string, +): ObservationLabReplayCapability { + assertExactKeys(provenance, CANONICAL_PROVENANCE_KEYS, `Canonical provenance LAB-сессии ${sessionId}`); + if ( + provenance.schema_version !== "missioncore.canonical-recorded-lab-projection/v1" + || typeof provenance.evidence_identity_sha256 !== "string" + || !SHA256.test(provenance.evidence_identity_sha256) + || resultId !== `lab-v1-vegetation-shadow-${provenance.evidence_identity_sha256}` + || typeof provenance.result_document_sha256 !== "string" + || !SHA256.test(provenance.result_document_sha256) + ) { + throw new ObservationLabReplayCapabilityContractError( + `Canonical provenance LAB-сессии ${sessionId} потеряла immutable identity.`, + ); + } + if (!isRecord(provenance.authority)) { + throw new ObservationLabReplayCapabilityContractError( + `Canonical provenance LAB-сессии ${sessionId} не содержит authority.`, + ); + } + assertExactKeys(provenance.authority, AUTHORITY_KEYS, `Canonical authority LAB-сессии ${sessionId}`); + if ( + provenance.authority.commands_enabled !== false + || provenance.authority.navigation_or_safety_accepted !== false + || provenance.authority.actuation_accepted !== false + ) { + throw new ObservationLabReplayCapabilityContractError( + `Canonical provenance LAB-сессии ${sessionId} нарушает observation-only authority.`, + ); + } + if (!isRecord(provenance.method)) { + throw new ObservationLabReplayCapabilityContractError( + `Canonical provenance LAB-сессии ${sessionId} не содержит method manifest.`, + ); + } + assertExactKeys(provenance.method, METHOD_KEYS, `Canonical method LAB-сессии ${sessionId}`); + if ( + provenance.method.schema_version !== "missioncore.laboratory-method/v1" + || provenance.method.completeness !== "legacy-partial" + || provenance.method.execution_class !== "ai-inference" + || provenance.method.pipeline_id + !== "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1" + || !Array.isArray(provenance.method.components) + || provenance.method.components.length !== 1 + ) { + throw new ObservationLabReplayCapabilityContractError( + `Canonical method LAB-сессии ${sessionId} не соответствует записанному прогону.`, + ); + } + const component = provenance.method.components[0]; + if (!isRecord(component)) { + throw new ObservationLabReplayCapabilityContractError( + `Canonical method LAB-сессии ${sessionId} не содержит source component.`, + ); + } + assertExactKeys( + component, + METHOD_COMPONENT_KEYS, + `Canonical source component LAB-сессии ${sessionId}`, + ); + if ( + component.kind !== "source" + || component.name !== "sealed full-route LAB result" + || component.version !== "missioncore.lab-v1-vegetation-shadow/v1" + || component.role !== "immutable Session catalog projection" + || component.identity_sha256 !== provenance.evidence_identity_sha256 + ) { + throw new ObservationLabReplayCapabilityContractError( + `Canonical source component LAB-сессии ${sessionId} потерял immutable identity.`, + ); + } + const capability = decodeLabReplayCapability(provenance.replay_capability, sessionId); + if (capability === null) { + throw new ObservationLabReplayCapabilityContractError( + `Canonical provenance LAB-сессии ${sessionId} не содержит replay capability.`, + ); + } + return capability; +} + +/** Rolling bridge for a new frontend against the pre-v2 catalog endpoint. */ +export function decodeRollingCanonicalLabReplayCapability( + provenance: Readonly>, + sessionId: string, + resultId: string, +): ObservationLabReplayCapability | null { + if (!Object.prototype.hasOwnProperty.call(provenance, "replay_capability")) return null; + return decodeCanonicalLabReplayCapabilityProvenance(provenance, sessionId, resultId); +} diff --git a/apps/control-station/src/core/observation/replayCoordinator.ts b/apps/control-station/src/core/observation/replayCoordinator.ts new file mode 100644 index 0000000..32eafb8 --- /dev/null +++ b/apps/control-station/src/core/observation/replayCoordinator.ts @@ -0,0 +1,43 @@ +export interface ObservationReplayAttempt { + readonly signal: AbortSignal; + isCurrent: () => boolean; + finish: () => boolean; +} + +export interface ObservationReplayCoordinator { + begin: () => ObservationReplayAttempt; + cancel: () => void; +} + +/** Latest explicit admission owns the viewer, even if an older promise settles later. */ +export function createObservationReplayCoordinator(): ObservationReplayCoordinator { + let sequence = 0; + let active: AbortController | null = null; + + return { + begin() { + active?.abort(); + const controller = new AbortController(); + const attemptSequence = ++sequence; + active = controller; + return { + signal: controller.signal, + isCurrent: () => ( + !controller.signal.aborted + && active === controller + && sequence === attemptSequence + ), + finish: () => { + if (active !== controller || sequence !== attemptSequence) return false; + active = null; + return true; + }, + }; + }, + cancel() { + active?.abort(); + // Keep settlement ownership until finish(). isCurrent() already fails, + // while begin() can replace this attempt immediately. + }, + }; +} diff --git a/apps/control-station/src/core/observation/sessionArchive.ts b/apps/control-station/src/core/observation/sessionArchive.ts index dcfd441..1d7466c 100644 --- a/apps/control-station/src/core/observation/sessionArchive.ts +++ b/apps/control-station/src/core/observation/sessionArchive.ts @@ -1,6 +1,14 @@ import { MAX_RECORDED_CAMERA_SOURCES, } from "./recordedSessionAdmission"; +import { + decodeLabReplayCapability, + decodeRollingCanonicalLabReplayCapability, + ObservationLabReplayCapabilityContractError, + type ObservationLabReplayCapability, +} from "./labReplayCapability"; + +export type { ObservationLabReplayCapability } from "./labReplayCapability"; export type ObservationSessionStatus = | "recording" @@ -20,6 +28,7 @@ export interface ObservationLabInstance { configSha256: string | null; runCreatedAtUtc: string; publishedAtUtc: string; + replayCapability: ObservationLabReplayCapability | null; provenance: Readonly>; } @@ -155,7 +164,7 @@ const ITEM_KEYS = new Set([ "preparation", "lab", ]); -const LAB_KEYS = new Set([ +const LEGACY_LAB_KEYS = new Set([ "lab_id", "source_session_id", "result_kind", @@ -166,6 +175,7 @@ const LAB_KEYS = new Set([ "published_at_utc", "provenance", ]); +const LAB_KEYS = new Set([...LEGACY_LAB_KEYS, "replay_capability"]); const CATALOG_PREPARATION_KEYS = new Set([ "preparation_id", "state", @@ -394,7 +404,15 @@ function decodeLabInstance( `LAB-привязка сессии ${sessionId} должна быть объектом или null.`, ); } - assertExactKeys(value, LAB_KEYS, `LAB-привязка сессии ${sessionId}`); + const hasTypedCapability = Object.prototype.hasOwnProperty.call( + value, + "replay_capability", + ); + assertExactKeys( + value, + hasTypedCapability ? LAB_KEYS : LEGACY_LAB_KEYS, + `LAB-привязка сессии ${sessionId}`, + ); const labId = requireString(value.lab_id, `lab(${sessionId}).lab_id`, 36); if (!/^LAB [A-Z][A-Z0-9._-]{0,31}$/.test(labId)) { throw new ObservationSessionContractError("Каталог содержит некорректный LAB-маркер."); @@ -430,6 +448,17 @@ function decodeLabInstance( if (!isRecord(value.provenance)) { throw new ObservationSessionContractError("LAB provenance должен быть JSON-объектом."); } + let replayCapability: ObservationLabReplayCapability | null; + try { + replayCapability = hasTypedCapability + ? decodeLabReplayCapability(value.replay_capability, sessionId) + : decodeRollingCanonicalLabReplayCapability(value.provenance, sessionId, resultId); + } catch (error) { + if (error instanceof ObservationLabReplayCapabilityContractError) { + throw new ObservationSessionContractError(error.message); + } + throw error; + } return { labId, sourceSessionId, @@ -445,6 +474,7 @@ function decodeLabInstance( value.published_at_utc, `lab(${sessionId}).published_at_utc`, ), + replayCapability, provenance: value.provenance, }; } @@ -1181,6 +1211,7 @@ export async function fetchObservationSessionCatalog({ queryParameters.set("limit", String(Number(limit))); } if (scope !== "all") queryParameters.set("scope", scope); + if (scope === "laboratory") queryParameters.set("lab_contract", "v2"); const serializedQuery = queryParameters.toString(); const query = serializedQuery ? `?${serializedQuery}` : ""; let response: Response; diff --git a/apps/control-station/src/core/observation/useObservationSessions.ts b/apps/control-station/src/core/observation/useObservationSessions.ts index 62bc612..87983d9 100644 --- a/apps/control-station/src/core/observation/useObservationSessions.ts +++ b/apps/control-station/src/core/observation/useObservationSessions.ts @@ -12,6 +12,16 @@ import { type ObservationSessionScope, type ObservationSessionSummary, } from "./sessionArchive"; +import { + createObservationReplayCoordinator, + type ObservationReplayCoordinator, +} from "./replayCoordinator"; + +export { + createObservationReplayCoordinator, + type ObservationReplayAttempt, + type ObservationReplayCoordinator, +} from "./replayCoordinator"; export type ObservationSessionsLoadState = "idle" | "loading" | "ready" | "error"; export type ObservationReplayOutcome = "accepted" | "error" | "cancelled"; @@ -41,17 +51,6 @@ export interface ObservationSessionsController { remove: (sessionId: string) => Promise; } -export interface ObservationReplayAttempt { - readonly signal: AbortSignal; - isCurrent: () => boolean; - finish: () => boolean; -} - -export interface ObservationReplayCoordinator { - begin: () => ObservationReplayAttempt; - cancel: () => void; -} - export async function deleteObservationSessionAfterTeardown( sessionId: string, { @@ -93,41 +92,6 @@ export class ObservationPreparationStalledError extends Error { } } -/** Latest selection wins, even if an obsolete server job finishes later. */ -export function createObservationReplayCoordinator(): ObservationReplayCoordinator { - let sequence = 0; - let active: AbortController | null = null; - - return { - begin() { - active?.abort(); - const controller = new AbortController(); - const attemptSequence = ++sequence; - active = controller; - return { - signal: controller.signal, - isCurrent: () => ( - !controller.signal.aborted && - active === controller && - sequence === attemptSequence - ), - finish: () => { - if (active !== controller || sequence !== attemptSequence) return false; - active = null; - return true; - }, - }; - }, - cancel() { - active?.abort(); - // Keep ownership until the cancelled attempt reaches `finish()`. This - // lets its finally block settle a replacement that already passed - // onReplayBegin, while `isCurrent()` still fails immediately because the - // signal is aborted. A later `begin()` replaces and invalidates it. - }, - }; -} - function errorMessage(error: unknown): string { return error instanceof Error && error.message.trim() ? error.message diff --git a/apps/control-station/src/core/observatory/catalog.ts b/apps/control-station/src/core/observatory/catalog.ts index 880e5ae..71735bb 100644 --- a/apps/control-station/src/core/observatory/catalog.ts +++ b/apps/control-station/src/core/observatory/catalog.ts @@ -4,6 +4,10 @@ import { type ObservationSessionFetch, type ObservationSessionSummary, } from "../observation/sessionArchive"; +import { + observatoryRecordedRunBinding, + type ObservatoryRecordedRunBinding, +} from "./recordedRun"; export interface ObservatoryEvidence { readonly sessionId: string; @@ -11,6 +15,7 @@ export interface ObservatoryEvidence { readonly status: ObservationSessionSummary["status"]; readonly publishedAtUtc: string; readonly lab: ObservationLabInstance; + readonly recordedRun: ObservatoryRecordedRunBinding | null; } export interface ObservatorySession { @@ -61,6 +66,7 @@ function evidenceFromSession( status: session.status, publishedAtUtc: session.lab.publishedAtUtc, lab: session.lab, + recordedRun: observatoryRecordedRunBinding(session.id, session.lab), }; } diff --git a/apps/control-station/src/core/observatory/recordedRun.ts b/apps/control-station/src/core/observatory/recordedRun.ts new file mode 100644 index 0000000..e93fa1e --- /dev/null +++ b/apps/control-station/src/core/observatory/recordedRun.ts @@ -0,0 +1,129 @@ +import type { LaboratoryFetch } from "../laboratory/advancedResults"; +import { + fetchVegetationShadowResultMetadata, + type VegetationFullRouteReview, + type VegetationShadowResult, +} from "../laboratory/vegetationShadow"; +import type { ObservationLabInstance } from "../observation/sessionArchive"; +import { + decodeCanonicalLabReplayCapabilityProvenance, + ObservationLabReplayCapabilityContractError, +} from "../observation/labReplayCapability"; + +const CANONICAL_RESULT_ID = /^lab-v1-vegetation-shadow-[a-f0-9]{64}$/; +const CANONICAL_SOURCE_SESSION_ID = "20260828T130511Z_viewer_live"; + +export interface ObservatoryRecordedRunBinding { + readonly kind: "canonical-recorded-rerun"; + readonly evidenceSessionId: string; + readonly sourceSessionId: string; + readonly resultId: string; + readonly viewerProfile: "recorded-session"; + readonly timeline: "session_time"; + readonly activation: "explicit"; +} + +export class ObservatoryRecordedRunContractError extends Error { + constructor(message: string) { + super(message); + this.name = "ObservatoryRecordedRunContractError"; + } +} + +export function observatoryRecordedRunBinding( + evidenceSessionId: string, + lab: ObservationLabInstance, +): ObservatoryRecordedRunBinding | null { + const capability = lab.replayCapability; + if (!capability) return null; + try { + decodeCanonicalLabReplayCapabilityProvenance( + lab.provenance, + evidenceSessionId, + lab.resultId, + ); + } catch (error) { + if (!(error instanceof ObservationLabReplayCapabilityContractError)) throw error; + throw new ObservatoryRecordedRunContractError(error.message); + } + if ( + evidenceSessionId !== lab.resultId + || lab.sourceSessionId !== CANONICAL_SOURCE_SESSION_ID + || lab.labId !== "LAB V1" + || lab.resultKind !== "recorded-perception-qualification" + || !CANONICAL_RESULT_ID.test(lab.resultId) + || lab.configSha256 !== null + || lab.sourceResultId === null + || !CANONICAL_RESULT_ID.test(lab.sourceResultId) + || capability.kind !== "canonical-recorded-rerun" + || capability.viewerProfile !== "recorded-session" + || capability.timeline !== "session_time" + || capability.activation !== "explicit" + || capability.commandsEnabled !== false + ) { + throw new ObservatoryRecordedRunContractError( + `LAB-результат ${lab.resultId} не допущен к каноническому recorded replay.`, + ); + } + return { + kind: "canonical-recorded-rerun", + evidenceSessionId, + sourceSessionId: lab.sourceSessionId, + resultId: lab.resultId, + viewerProfile: "recorded-session", + timeline: "session_time", + activation: "explicit", + }; +} + +export async function fetchObservatoryRecordedRunReview( + binding: ObservatoryRecordedRunBinding, + { + selectedSourceSessionId, + fetcher = fetch, + signal, + }: { + selectedSourceSessionId: string; + fetcher?: LaboratoryFetch; + signal?: AbortSignal; + }, +): Promise { + if (binding.sourceSessionId !== selectedSourceSessionId) { + throw new ObservatoryRecordedRunContractError( + "Запуск не связан с выбранной исходной сессией.", + ); + } + const result = await fetchVegetationShadowResultMetadata(binding.resultId, { + fetcher, + signal, + }); + return admitObservatoryRecordedRunReview( + binding, + selectedSourceSessionId, + result, + ); +} + +export function admitObservatoryRecordedRunReview( + binding: ObservatoryRecordedRunBinding, + selectedSourceSessionId: string, + result: VegetationShadowResult, +): VegetationFullRouteReview { + if (binding.sourceSessionId !== selectedSourceSessionId) { + throw new ObservatoryRecordedRunContractError( + "Запуск не связан с выбранной исходной сессией.", + ); + } + if (result.resultId !== binding.resultId) { + throw new ObservatoryRecordedRunContractError( + "Запечатанный результат не совпадает с выбранным запуском.", + ); + } + const review = result.routeFullReview; + if (!review || review.sessionId !== binding.sourceSessionId) { + throw new ObservatoryRecordedRunContractError( + "Запечатанный результат потерял точную связь с исходной сессией.", + ); + } + return review; +} diff --git a/apps/control-station/src/styles/observatory.css b/apps/control-station/src/styles/observatory.css index f145b78..687fae6 100644 --- a/apps/control-station/src/styles/observatory.css +++ b/apps/control-station/src/styles/observatory.css @@ -145,6 +145,66 @@ gap: 0.85rem; } +.observatory-evidence-card__heading, +.observatory-evidence-card__action, +.observatory-replay__header, +.observatory-replay-state, +.observatory-replay-state__actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.85rem; +} + +.observatory-evidence-card__heading, +.observatory-evidence-card__action, +.observatory-replay__header, +.observatory-replay-state { + flex-wrap: wrap; +} + +.observatory-evidence-card__heading > div, +.observatory-evidence-card__action > span, +.observatory-replay__header > div, +.observatory-replay-state > div { + min-width: 0; +} + +.observatory-evidence-card__action { + padding-top: 0.85rem; + border-top: 1px solid rgba(var(--nodedc-accent-rgb), 0.18); +} + +.observatory-evidence-card__action > span { + color: var(--nodedc-text-muted); + font-size: var(--nodedc-font-size-xs); +} + +.observatory-replay, +.observatory-replay-state { + min-width: 0; +} + +.observatory-replay { + display: grid; + gap: 0.85rem; +} + +.observatory-replay__header { + padding: 0.25rem 0.25rem 0; +} + +.observatory-replay__header h3, +.observatory-replay-state h3 { + margin: 0.3rem 0 0; +} + +.observatory-replay__header p, +.observatory-replay-state p { + margin: 0.35rem 0 0; + color: var(--nodedc-text-muted); +} + .observatory-evidence-list strong, .observatory-evidence-list span { display: block; @@ -191,6 +251,13 @@ flex-direction: column; } + .observatory-evidence-card__action, + .observatory-replay__header, + .observatory-replay-state { + align-items: stretch; + flex-direction: column; + } + .observatory-catalog-bar__controls { flex-basis: auto; } diff --git a/apps/control-station/src/workspaces/laboratory/CanonicalVegetationRerunReplay.tsx b/apps/control-station/src/workspaces/laboratory/CanonicalVegetationRerunReplay.tsx index 41c43de..3da6e9b 100644 --- a/apps/control-station/src/workspaces/laboratory/CanonicalVegetationRerunReplay.tsx +++ b/apps/control-station/src/workspaces/laboratory/CanonicalVegetationRerunReplay.tsx @@ -1,249 +1 @@ -import { useEffect, useMemo, useState } from "react"; -import { Button, Icon, SegmentedControl } from "@nodedc/ui-react"; - -import { ObservationTimeline } from "../../components/ObservationTimeline"; -import { - RerunViewport, - type RerunPlaybackController, - type RerunPlaybackState, -} from "../../components/RerunViewport"; -import { - CanonicalRecordedLabReplay, - useCanonicalRecordedLabReplayState, -} from "../../components/laboratory/CanonicalRecordedLabReplay"; -import { - resolveCanonicalLabReplay, - type CanonicalLabReplayDescriptor, -} from "../../core/laboratory/canonicalLabReplay"; -import type { VegetationFullRouteReview } from "../../core/laboratory/vegetationShadow"; -import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive"; -import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile"; -import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions"; -import { defaultSceneSettings } from "../../sceneSettings"; - -type MediaMode = "video" | "camera"; -type SpatialMode = "3d" | "plan"; -type SpatialLayer = "source" | "local" | "tgs" | "semantic"; -type SemanticLayer = "city" | "vegetation"; - -interface CanonicalReplayLaunch { - base: ObservationSessionReplayLaunch; - replay: CanonicalLabReplayDescriptor; -} - -export function CanonicalVegetationRerunReplay({ - resultId, - review, -}: { - resultId: string; - review: VegetationFullRouteReview; -}) { - const { - mediaMode, - spatialMode, - splitPrimarySize, - splitOrientation, - expanded, - onMediaModeChange, - onSpatialModeChange, - onSplitPrimarySizeChange, - onExpandedChange, - } = useCanonicalRecordedLabReplayState({ - initialMediaMode: "video", - initialSpatialMode: "3d", - }); - const [semanticLayer, setSemanticLayer] = useState("vegetation"); - const [showSemantics, setShowSemantics] = useState(true); - const [spatialLayer, setSpatialLayer] = useState("source"); - const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(0); - const [playback, setPlayback] = useState(null); - const [playbackController, setPlaybackController] = - useState(null); - const [launch, setLaunch] = useState(null); - const [launchError, setLaunchError] = useState(null); - - useEffect(() => { - const controller = new AbortController(); - setLaunch(null); - setLaunchError(null); - void resolveObservationSessionReplay(review.sessionId, { - signal: controller.signal, - maximumWaitMs: 30 * 60 * 1000, - onUpdate: () => undefined, - }).then(async (value) => ({ - base: value, - replay: await resolveCanonicalLabReplay(resultId, value, { - signal: controller.signal, - }), - })).then((value) => { - if (!controller.signal.aborted) setLaunch(value); - }).catch((caught: unknown) => { - if (!controller.signal.aborted) { - setLaunchError( - caught instanceof Error ? caught.message : "Каноническая запись RAV004 недоступна.", - ); - } - }); - return () => controller.abort(); - }, [resultId, review.sessionId]); - - const splitView = mediaMode !== null && spatialMode !== null; - const sceneSettings = useMemo(() => ({ - ...defaultSceneSettings, - accumulationSeconds: spatialLayer === "local" ? 5 : 0, - showPoints: spatialMode !== null, - showTrajectory: spatialMode !== null, - showGrid: spatialMode !== null, - pointSize: 3.8, - }), [spatialLayer, spatialMode]); - const profile = launch ? recordedSessionRerunProfile({ - sourceUrl: launch.replay.sourceUrl, - artifact: { - sourceUrl: launch.replay.sourceUrl, - viewerSourceUrl: launch.replay.viewerSourceUrl, - byteLength: launch.replay.byteLength, - sha256: launch.replay.sha256, - }, - blueprintSourceUrl: launch.replay.blueprintSourceUrl, - autoplayWhenReady: false, - presentationGate: "ready", - expectedTimelineStartSeconds: launch.base.timelineStartSeconds, - expectedTimelineEndSeconds: launch.base.timelineEndSeconds, - initialPlaybackStartSeconds: review.timelineStartSeconds, - view: mediaMode !== null ? "perception" : "spatial", - viewResetGeneration, - followTrajectory: true, - semanticLayer, - unifiedPerception: splitView, - planView: spatialMode === "plan", - perceptionLayers: { - enabled: mediaMode !== null, - detections2d: mediaMode === "video", - segmentation: mediaMode === "video" && showSemantics, - cuboids3d: false, - }, - perceptionRetryGeneration: 0, - lockPerceptionCameraInteraction: false, - }) : null; - - const mediaLayerControls = ( -
- - { - setSemanticLayer(value); - setShowSemantics(true); - }} - /> -
- ); - const spatialLayerControls = ( -
- -
- ); - const resetSpatialView = ( - - ); - - const transport = playback && playbackController ? ( - - ) : undefined; - return ( - - ) : ( -
- {launchError ?? "Готовим единый кэш канонического повтора RAV004…"} -
- )} - emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте." - transport={transport} - onMediaModeChange={onMediaModeChange} - onSpatialModeChange={onSpatialModeChange} - onExpandedChange={onExpandedChange} - onSplitPrimarySizeChange={onSplitPrimarySizeChange} - /> - ); -} +export { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay"; diff --git a/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx b/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx index f21935d..3aae018 100644 --- a/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx +++ b/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx @@ -54,6 +54,7 @@ import { buildLaboratoryProfiles, experimentOptionsForProfile, freshestLaboratorySelection, + isLegacyPublishedLaboratoryWork, workOptionsForExperiment, } from "./laboratoryArchiveProfiles"; import { @@ -499,12 +500,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) { onDeleteBegin: props.sessionArchive.onDeleteBegin, }); const publishedWorks = useMemo( - () => sessions.items.filter((session) => ( - session.lab !== null - && session.status === "ready" - && session.replayable - && session.modalities.includes("point-cloud") - )).sort((left, right) => ( + () => sessions.items.filter(isLegacyPublishedLaboratoryWork).sort((left, right) => ( Date.parse(right.lab?.runCreatedAtUtc ?? right.startedAtUtc) - Date.parse(left.lab?.runCreatedAtUtc ?? left.startedAtUtc) )), diff --git a/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts b/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts index e04b8bb..43b85b8 100644 --- a/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts +++ b/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts @@ -459,6 +459,17 @@ function pipelineIdForSession(session: ObservationSessionSummary): string { return session.lab?.resultKind ?? "legacy-perception"; } +/** Keep capability projections owned by Observatory out of the legacy LAB surface. */ +export function isLegacyPublishedLaboratoryWork( + session: ObservationSessionSummary, +): boolean { + return session.lab !== null + && session.lab.replayCapability === null + && session.status === "ready" + && session.replayable + && session.modalities.includes("point-cloud"); +} + export function buildLaboratoryCatalog({ rigLabel, knownWorks, diff --git a/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx b/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx index 0d23d4a..bd4acb6 100644 --- a/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx +++ b/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Button, @@ -8,11 +8,38 @@ import { StatusBadge, } from "@nodedc/ui-react"; +import { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay"; import type { ObservationSessionStatus } from "../../core/observation/sessionArchive"; +import { createObservationReplayCoordinator } from "../../core/observation/replayCoordinator"; +import { + fetchObservatoryRecordedRunReview, + type ObservatoryRecordedRunBinding, +} from "../../core/observatory/recordedRun"; import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog"; import type { WorkspaceDefinition } from "../../productModel"; const MAX_PRESENTED_EVIDENCE = 6; +const EMPTY_OBSERVATORY_ITEMS = [] as const; +type ObservatoryRecordedRunReview = Awaited< + ReturnType +>; + +type ObservatoryReplayState = + | { readonly kind: "closed" } + | { + readonly kind: "loading"; + readonly binding: ObservatoryRecordedRunBinding; + } + | { + readonly kind: "ready"; + readonly binding: ObservatoryRecordedRunBinding; + readonly review: ObservatoryRecordedRunReview; + } + | { + readonly kind: "error"; + readonly binding: ObservatoryRecordedRunBinding; + readonly message: string; + }; const statusLabel: Record = { recording: "Запись идёт", @@ -74,12 +101,22 @@ export function ObservatoryWorkspace({ }) { const controller = useObservatoryCatalog(); const [selectedSessionId, setSelectedSessionId] = useState(""); - const items = controller.catalog?.items ?? []; + const [replay, setReplay] = useState({ kind: "closed" }); + const replayCoordinatorRef = useRef(createObservationReplayCoordinator()); + const items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS; + + const closeReplay = useCallback(() => { + replayCoordinatorRef.current.cancel(); + setReplay((current) => current.kind === "closed" ? current : { kind: "closed" }); + }, []); useEffect(() => { if (items.some((item) => item.source.id === selectedSessionId)) return; + closeReplay(); setSelectedSessionId(items[0]?.source.id ?? ""); - }, [items, selectedSessionId]); + }, [closeReplay, items, selectedSessionId]); + + useEffect(() => () => replayCoordinatorRef.current.cancel(), []); const selectedSession = items.find( (item) => item.source.id === selectedSessionId, @@ -96,12 +133,51 @@ export function ObservatoryWorkspace({ const initialLoading = !controller.catalog && ["idle", "loading"].includes(controller.state); const unavailable = !controller.catalog && controller.state === "error"; + const replayEvidenceId = replay.kind === "closed" + ? null + : replay.binding.evidenceSessionId; + + useEffect(() => { + if ( + replayEvidenceId === null + || selectedSession?.evidence.some( + (evidence) => evidence.sessionId === replayEvidenceId, + ) + ) return; + closeReplay(); + }, [closeReplay, replayEvidenceId, selectedSession]); + + const openReplay = useCallback((binding: ObservatoryRecordedRunBinding) => { + const attempt = replayCoordinatorRef.current.begin(); + setReplay({ kind: "loading", binding }); + void fetchObservatoryRecordedRunReview(binding, { + selectedSourceSessionId: selectedSessionId, + signal: attempt.signal, + }).then((review) => { + if (!attempt.isCurrent() || !attempt.finish()) return; + setReplay({ kind: "ready", binding, review }); + }).catch((caught: unknown) => { + if (!attempt.isCurrent() || !attempt.finish()) return; + setReplay({ + kind: "error", + binding, + message: caught instanceof Error && caught.message.trim() + ? caught.message + : "Канонический визуальный разбор недоступен.", + }); + }); + }, [selectedSessionId]); + + const selectSession = useCallback((sessionId: string) => { + closeReplay(); + setSelectedSessionId(sessionId); + }, [closeReplay]); return (
@@ -123,7 +199,7 @@ export function ObservatoryWorkspace({
ИСТОЧНИК ДОКАЗАТЕЛЬСТВ

Сохранённая сессия

-

Выбор меняет только читаемую карточку и не готовит Rerun-запись в фоне.

+

Выбор меняет только читаемую карточку и не готовит визуальный разбор в фоне.