feat(observatory): admit canonical recorded replay
This commit is contained in:
@@ -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<MediaMode, SpatialMode>({
|
||||
initialMediaMode: "video",
|
||||
initialSpatialMode: "3d",
|
||||
});
|
||||
const [semanticLayer, setSemanticLayer] = useState<SemanticLayer>("vegetation");
|
||||
const [showSemantics, setShowSemantics] = useState(true);
|
||||
const [spatialLayer, setSpatialLayer] = useState<SpatialLayer>("source");
|
||||
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(0);
|
||||
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
|
||||
const [playbackController, setPlaybackController] =
|
||||
useState<RerunPlaybackController | null>(null);
|
||||
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
|
||||
const [launchError, setLaunchError] = useState<string | null>(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 = (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-layer-controls"
|
||||
role="group"
|
||||
aria-label="Слои камеры и видео"
|
||||
>
|
||||
<Button
|
||||
size="dense"
|
||||
shape="pill"
|
||||
variant={showSemantics ? "primary" : "secondary"}
|
||||
aria-pressed={showSemantics}
|
||||
onClick={() => setShowSemantics((visible) => !visible)}
|
||||
>
|
||||
СЕМАНТИКА
|
||||
</Button>
|
||||
<SegmentedControl
|
||||
value={semanticLayer}
|
||||
items={[
|
||||
{ value: "city", label: "ГОРОД · EoMT" },
|
||||
{ value: "vegetation", label: "ПРИРОДА · DDRNet" },
|
||||
]}
|
||||
label="Источник семантики"
|
||||
size="dense"
|
||||
onChange={(value) => {
|
||||
setSemanticLayer(value);
|
||||
setShowSemantics(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const spatialLayerControls = (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-layer-controls"
|
||||
role="group"
|
||||
aria-label="Пространственные слои RAV004"
|
||||
>
|
||||
<SegmentedControl
|
||||
value={spatialLayer}
|
||||
items={[
|
||||
{ value: "source", label: "ИСХ. ТОЧКИ" },
|
||||
{ value: "local", label: "ЛОК. SLAM" },
|
||||
{ value: "tgs", label: "TGS", disabled: true },
|
||||
{ value: "semantic", label: "СЕМАНТИКА", disabled: true },
|
||||
]}
|
||||
label="Пространственные слои"
|
||||
size="dense"
|
||||
onChange={setSpatialLayer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const resetSpatialView = (
|
||||
<Button
|
||||
size="dense"
|
||||
variant="ghost"
|
||||
icon={<Icon name="refresh" size={14} />}
|
||||
aria-label="Сбросить положение 3D камеры"
|
||||
title="Сбросить положение 3D камеры"
|
||||
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}
|
||||
>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const transport = playback && playbackController ? (
|
||||
<ObservationTimeline
|
||||
className="m4-replay-threat-visual__timeline"
|
||||
active
|
||||
sourceCount={3}
|
||||
mode="recorded"
|
||||
seekable
|
||||
synchronization="shared-clock"
|
||||
rangeNs={playback.rangeNs}
|
||||
currentNs={playback.currentNs}
|
||||
playing={playback.playing}
|
||||
onSeek={playbackController.seek}
|
||||
onPlayingChange={playbackController.setPlaying}
|
||||
showJumpToEnd={false}
|
||||
/>
|
||||
) : undefined;
|
||||
return (
|
||||
<CanonicalRecordedLabReplay
|
||||
label="RAVNOVES004TREE · канонический повтор Rerun"
|
||||
mediaMode={mediaMode ?? "none"}
|
||||
mediaModes={[
|
||||
{ value: "video", label: "ВИДЕО" },
|
||||
{ value: "camera", label: "КАМЕРА" },
|
||||
]}
|
||||
spatialMode={spatialMode ?? "none"}
|
||||
spatialModes={[
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "ПЛАН" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
splitPrimarySize={splitPrimarySize}
|
||||
splitOrientation={splitOrientation}
|
||||
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
|
||||
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
|
||||
mediaLayerControls={mediaLayerControls}
|
||||
spatialLayerControls={spatialLayerControls}
|
||||
spatialLeadingControl={resetSpatialView}
|
||||
mediaMultiLayer
|
||||
unifiedContent={profile ? (
|
||||
<RerunViewport
|
||||
profile={profile}
|
||||
sceneSettings={sceneSettings}
|
||||
onPlaybackChange={setPlayback}
|
||||
onPlaybackControllerChange={setPlaybackController}
|
||||
/>
|
||||
) : (
|
||||
<div className="l3-visual-audit__state" role={launchError ? "alert" : "status"}>
|
||||
{launchError ?? "Готовим единый кэш канонического повтора RAV004…"}
|
||||
</div>
|
||||
)}
|
||||
emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
|
||||
transport={transport}
|
||||
onMediaModeChange={onMediaModeChange}
|
||||
onSpatialModeChange={onSpatialModeChange}
|
||||
onExpandedChange={onExpandedChange}
|
||||
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<VegetationShadowResult> {
|
||||
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`,
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function assertExactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: ReadonlySet<string>,
|
||||
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<Record<string, unknown>>,
|
||||
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<Record<string, unknown>>,
|
||||
sessionId: string,
|
||||
resultId: string,
|
||||
): ObservationLabReplayCapability | null {
|
||||
if (!Object.prototype.hasOwnProperty.call(provenance, "replay_capability")) return null;
|
||||
return decodeCanonicalLabReplayCapabilityProvenance(provenance, sessionId, resultId);
|
||||
}
|
||||
@@ -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.
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<boolean>;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<VegetationFullRouteReview> {
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<MediaMode, SpatialMode>({
|
||||
initialMediaMode: "video",
|
||||
initialSpatialMode: "3d",
|
||||
});
|
||||
const [semanticLayer, setSemanticLayer] = useState<SemanticLayer>("vegetation");
|
||||
const [showSemantics, setShowSemantics] = useState(true);
|
||||
const [spatialLayer, setSpatialLayer] = useState<SpatialLayer>("source");
|
||||
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(0);
|
||||
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
|
||||
const [playbackController, setPlaybackController] =
|
||||
useState<RerunPlaybackController | null>(null);
|
||||
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
|
||||
const [launchError, setLaunchError] = useState<string | null>(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 = (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-layer-controls"
|
||||
role="group"
|
||||
aria-label="Слои камеры и видео"
|
||||
>
|
||||
<Button
|
||||
size="dense"
|
||||
shape="pill"
|
||||
variant={showSemantics ? "primary" : "secondary"}
|
||||
aria-pressed={showSemantics}
|
||||
onClick={() => setShowSemantics((visible) => !visible)}
|
||||
>
|
||||
СЕМАНТИКА
|
||||
</Button>
|
||||
<SegmentedControl
|
||||
value={semanticLayer}
|
||||
items={[
|
||||
{ value: "city", label: "ГОРОД · EoMT" },
|
||||
{ value: "vegetation", label: "ПРИРОДА · DDRNet" },
|
||||
]}
|
||||
label="Источник семантики"
|
||||
size="dense"
|
||||
onChange={(value) => {
|
||||
setSemanticLayer(value);
|
||||
setShowSemantics(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const spatialLayerControls = (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-layer-controls"
|
||||
role="group"
|
||||
aria-label="Пространственные слои RAV004"
|
||||
>
|
||||
<SegmentedControl
|
||||
value={spatialLayer}
|
||||
items={[
|
||||
{ value: "source", label: "ИСХ. ТОЧКИ" },
|
||||
{ value: "local", label: "ЛОК. SLAM" },
|
||||
{ value: "tgs", label: "TGS", disabled: true },
|
||||
{ value: "semantic", label: "СЕМАНТИКА", disabled: true },
|
||||
]}
|
||||
label="Пространственные слои"
|
||||
size="dense"
|
||||
onChange={setSpatialLayer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const resetSpatialView = (
|
||||
<Button
|
||||
size="dense"
|
||||
variant="ghost"
|
||||
icon={<Icon name="refresh" size={14} />}
|
||||
aria-label="Сбросить положение 3D камеры"
|
||||
title="Сбросить положение 3D камеры"
|
||||
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}
|
||||
>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const transport = playback && playbackController ? (
|
||||
<ObservationTimeline
|
||||
className="m4-replay-threat-visual__timeline"
|
||||
active
|
||||
sourceCount={3}
|
||||
mode="recorded"
|
||||
seekable
|
||||
synchronization="shared-clock"
|
||||
rangeNs={playback.rangeNs}
|
||||
currentNs={playback.currentNs}
|
||||
playing={playback.playing}
|
||||
onSeek={playbackController.seek}
|
||||
onPlayingChange={playbackController.setPlaying}
|
||||
showJumpToEnd={false}
|
||||
/>
|
||||
) : undefined;
|
||||
return (
|
||||
<CanonicalRecordedLabReplay
|
||||
label="RAVNOVES004TREE · канонический повтор Rerun"
|
||||
mediaMode={mediaMode ?? "none"}
|
||||
mediaModes={[
|
||||
{ value: "video", label: "ВИДЕО" },
|
||||
{ value: "camera", label: "КАМЕРА" },
|
||||
]}
|
||||
spatialMode={spatialMode ?? "none"}
|
||||
spatialModes={[
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "ПЛАН" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
splitPrimarySize={splitPrimarySize}
|
||||
splitOrientation={splitOrientation}
|
||||
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
|
||||
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
|
||||
mediaLayerControls={mediaLayerControls}
|
||||
spatialLayerControls={spatialLayerControls}
|
||||
spatialLeadingControl={resetSpatialView}
|
||||
mediaMultiLayer
|
||||
unifiedContent={profile ? (
|
||||
<RerunViewport
|
||||
profile={profile}
|
||||
sceneSettings={sceneSettings}
|
||||
onPlaybackChange={setPlayback}
|
||||
onPlaybackControllerChange={setPlaybackController}
|
||||
/>
|
||||
) : (
|
||||
<div className="l3-visual-audit__state" role={launchError ? "alert" : "status"}>
|
||||
{launchError ?? "Готовим единый кэш канонического повтора RAV004…"}
|
||||
</div>
|
||||
)}
|
||||
emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
|
||||
transport={transport}
|
||||
onMediaModeChange={onMediaModeChange}
|
||||
onSpatialModeChange={onSpatialModeChange}
|
||||
onExpandedChange={onExpandedChange}
|
||||
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
|
||||
|
||||
@@ -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)
|
||||
)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof fetchObservatoryRecordedRunReview>
|
||||
>;
|
||||
|
||||
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<ObservationSessionStatus, string> = {
|
||||
recording: "Запись идёт",
|
||||
@@ -74,12 +101,22 @@ export function ObservatoryWorkspace({
|
||||
}) {
|
||||
const controller = useObservatoryCatalog();
|
||||
const [selectedSessionId, setSelectedSessionId] = useState("");
|
||||
const items = controller.catalog?.items ?? [];
|
||||
const [replay, setReplay] = useState<ObservatoryReplayState>({ 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 (
|
||||
<div
|
||||
className="observatory-workspace"
|
||||
data-observatory-authority="observation-only"
|
||||
data-observatory-viewer="detached"
|
||||
data-observatory-viewer={replay.kind === "ready" ? "attached" : "detached"}
|
||||
>
|
||||
<section className="observatory-lead">
|
||||
<div>
|
||||
@@ -123,7 +199,7 @@ export function ObservatoryWorkspace({
|
||||
<div className="observatory-catalog-bar__copy">
|
||||
<span className="section-eyebrow">ИСТОЧНИК ДОКАЗАТЕЛЬСТВ</span>
|
||||
<h3>Сохранённая сессия</h3>
|
||||
<p>Выбор меняет только читаемую карточку и не готовит Rerun-запись в фоне.</p>
|
||||
<p>Выбор меняет только читаемую карточку и не готовит визуальный разбор в фоне.</p>
|
||||
</div>
|
||||
<div className="observatory-catalog-bar__controls">
|
||||
<Select
|
||||
@@ -136,7 +212,7 @@ export function ObservatoryWorkspace({
|
||||
emptyLabel="Сессия не найдена"
|
||||
minMenuWidth={360}
|
||||
menuWidth={460}
|
||||
onChange={setSelectedSessionId}
|
||||
onChange={selectSession}
|
||||
/>
|
||||
<Button
|
||||
size="compact"
|
||||
@@ -241,9 +317,14 @@ export function ObservatoryWorkspace({
|
||||
{presentedEvidence.map((evidence) => (
|
||||
<li key={evidence.sessionId}>
|
||||
<GlassSurface className="observatory-evidence-card" padding="md" tone="soft">
|
||||
<div>
|
||||
<strong>{evidence.lab.labId}</strong>
|
||||
<span>{evidence.label}</span>
|
||||
<div className="observatory-evidence-card__heading">
|
||||
<div>
|
||||
<strong>{evidence.lab.labId}</strong>
|
||||
<span>{evidence.label}</span>
|
||||
</div>
|
||||
{evidence.recordedRun ? (
|
||||
<StatusBadge tone="accent">Записанный разбор</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>Тип результата</dt><dd>{evidence.lab.resultKind}</dd></div>
|
||||
@@ -253,6 +334,34 @@ export function ObservatoryWorkspace({
|
||||
</div>
|
||||
<div><dt>Опубликован</dt><dd>{formatTimestamp(evidence.publishedAtUtc)}</dd></div>
|
||||
</dl>
|
||||
{evidence.recordedRun ? (
|
||||
<div className="observatory-evidence-card__action">
|
||||
<span>
|
||||
Записанный маршрут · единая временная шкала · только наблюдение
|
||||
</span>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="primary"
|
||||
icon={<Icon name="play" size={14} />}
|
||||
disabled={
|
||||
replay.kind === "loading"
|
||||
&& replay.binding.evidenceSessionId === evidence.sessionId
|
||||
}
|
||||
onClick={() => openReplay(evidence.recordedRun!)}
|
||||
>
|
||||
{replay.kind === "loading"
|
||||
&& replay.binding.evidenceSessionId === evidence.sessionId
|
||||
? "Проверяем результат"
|
||||
: replay.kind === "ready"
|
||||
&& replay.binding.evidenceSessionId === evidence.sessionId
|
||||
? "Открыть заново"
|
||||
: replay.kind === "error"
|
||||
&& replay.binding.evidenceSessionId === evidence.sessionId
|
||||
? "Повторить открытие"
|
||||
: "Открыть визуальный разбор"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</GlassSurface>
|
||||
</li>
|
||||
))}
|
||||
@@ -277,6 +386,57 @@ export function ObservatoryWorkspace({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{replay.kind === "loading" ? (
|
||||
<GlassSurface className="observatory-replay-state" padding="lg" role="status">
|
||||
<ActivityIndicator label="Проверяем запечатанный результат" />
|
||||
<div>
|
||||
<h3>Проверяем точную связь результата с исходной сессией</h3>
|
||||
<p>Визуализатор и данные маршрута ещё не запущены.</p>
|
||||
</div>
|
||||
<Button size="compact" variant="ghost" onClick={closeReplay}>Отменить</Button>
|
||||
</GlassSurface>
|
||||
) : replay.kind === "error" ? (
|
||||
<GlassSurface className="observatory-replay-state" padding="lg" role="alert">
|
||||
<Icon name="alert" size={20} />
|
||||
<div>
|
||||
<StatusBadge tone="danger">Просмотр недоступен</StatusBadge>
|
||||
<h3>{replay.message}</h3>
|
||||
</div>
|
||||
<div className="observatory-replay-state__actions">
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
onClick={() => openReplay(replay.binding)}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
<Button size="compact" variant="ghost" onClick={closeReplay}>Закрыть</Button>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
) : replay.kind === "ready" ? (
|
||||
<section className="observatory-replay" aria-label="Канонический визуальный разбор">
|
||||
<header className="observatory-replay__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ РАЗБОР / ЗАПИСАННАЯ СЕССИЯ</span>
|
||||
<h3>RAVNOVES004TREE · полный маршрут восприятия</h3>
|
||||
<p>Записанный маршрут синхронизирован по общей временной шкале.</p>
|
||||
</div>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
icon={<Icon name="close" size={14} />}
|
||||
onClick={closeReplay}
|
||||
>
|
||||
Закрыть разбор
|
||||
</Button>
|
||||
</header>
|
||||
<CanonicalVegetationRerunReplay
|
||||
resultId={replay.binding.resultId}
|
||||
review={replay.review}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{(controller.catalog?.unresolvedEvidence.length ?? 0) > 0 ? (
|
||||
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="status">
|
||||
<StatusBadge tone="warning">Вне среза</StatusBadge>
|
||||
|
||||
@@ -11,6 +11,7 @@ let fetchM49TgsAnchorSpatial;
|
||||
let AdvancedLaboratoryContractError;
|
||||
let buildLaboratoryCatalog;
|
||||
let buildLaboratoryProfiles;
|
||||
let isLegacyPublishedLaboratoryWork;
|
||||
let experimentOptionsForProfile;
|
||||
let freshestLaboratorySelection;
|
||||
let workOptionsForExperiment;
|
||||
@@ -928,6 +929,7 @@ before(async () => {
|
||||
({
|
||||
buildLaboratoryCatalog,
|
||||
buildLaboratoryProfiles,
|
||||
isLegacyPublishedLaboratoryWork,
|
||||
experimentOptionsForProfile,
|
||||
freshestLaboratorySelection,
|
||||
workOptionsForExperiment,
|
||||
@@ -962,6 +964,31 @@ after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("capability-owned projections never enter the legacy LAB catalog", () => {
|
||||
const legacy = {
|
||||
status: "ready",
|
||||
replayable: true,
|
||||
modalities: ["point-cloud", "trajectory"],
|
||||
lab: { replayCapability: null },
|
||||
};
|
||||
const capabilityProjection = {
|
||||
...legacy,
|
||||
lab: {
|
||||
replayCapability: {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
|
||||
kind: "canonical-recorded-rerun",
|
||||
viewerProfile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
commandsEnabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(isLegacyPublishedLaboratoryWork(legacy), true);
|
||||
assert.equal(isLegacyPublishedLaboratoryWork(capabilityProjection), false);
|
||||
});
|
||||
|
||||
test("LAB catalog is pipeline-scoped and ordered by real run time", () => {
|
||||
const catalog = buildLaboratoryCatalog({
|
||||
rigLabel: "K1",
|
||||
|
||||
@@ -105,10 +105,14 @@ test("laboratory UI is a bounded feature slice, not a central workspace branch",
|
||||
assert.match(e30HumanReviewCss, /\.e30-human-review/);
|
||||
});
|
||||
|
||||
test("Observatory is a bounded read-only slice outside legacy LAB and viewer lifecycles", async () => {
|
||||
test("Observatory owns a bounded explicit-open lifecycle around the shared recorded viewer", async () => {
|
||||
const workspaceHub = await read("workspaces/Workspaces.tsx");
|
||||
const observatory = await read("workspaces/observatory/ObservatoryWorkspace.tsx");
|
||||
const observatoryCore = await read("core/observatory/catalog.ts");
|
||||
const recordedRun = await read("core/observatory/recordedRun.ts");
|
||||
const sharedReplay = await read(
|
||||
"components/laboratory/CanonicalVegetationRerunReplay.tsx",
|
||||
);
|
||||
const workspaceCss = await read("styles/workspaces.css");
|
||||
const observatoryCss = await read("styles/observatory.css");
|
||||
|
||||
@@ -119,12 +123,27 @@ test("Observatory is a bounded read-only slice outside legacy LAB and viewer lif
|
||||
assert.match(observatory, /export function ObservatoryWorkspace/);
|
||||
assert.match(observatory, /useObservatoryCatalog/);
|
||||
assert.match(observatory, /data-observatory-authority="observation-only"/);
|
||||
assert.match(observatory, /data-observatory-viewer="detached"/);
|
||||
assert.match(
|
||||
observatory,
|
||||
/data-observatory-viewer={replay\.kind === "ready" \? "attached" : "detached"}/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
`${observatory}\n${observatoryCore}`,
|
||||
/(?:core|components|workspaces)\/laboratory|\/api\/v1\/laboratory|RerunViewport|ObservationSessionSelect/,
|
||||
/(?:core|workspaces)\/laboratory|\/api\/v1\/laboratory|RerunViewport|ObservationSessionSelect/,
|
||||
);
|
||||
assert.doesNotMatch(observatory, /replayObservationSession|deleteObservationSession/);
|
||||
assert.match(observatory, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
|
||||
assert.match(observatory, /closeReplay\(\);[\s\S]*setSelectedSessionId/);
|
||||
assert.doesNotMatch(
|
||||
observatory,
|
||||
/replayObservationSession|deleteObservationSession|useObservationSessions|useAdvancedLaboratoryCatalog/,
|
||||
);
|
||||
assert.match(recordedRun, /fetchVegetationShadowResultMetadata/);
|
||||
assert.doesNotMatch(
|
||||
recordedRun,
|
||||
/advanced-index|resolveObservationSessionReplay|resolveCanonicalLabReplay|RerunViewport/,
|
||||
);
|
||||
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
|
||||
assert.match(sharedReplay, /recordedSessionRerunProfile/);
|
||||
assert.doesNotMatch(workspaceCss, /\.observatory-/);
|
||||
assert.match(observatoryCss, /\.observatory-workspace/);
|
||||
});
|
||||
|
||||
@@ -77,6 +77,57 @@ function session(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalLab(overrides = {}) {
|
||||
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||
return {
|
||||
lab_id: "LAB V1",
|
||||
source_session_id: "20260828T130511Z_viewer_live",
|
||||
result_kind: "recorded-perception-qualification",
|
||||
result_id: resultId,
|
||||
source_result_id: `lab-v1-vegetation-shadow-${"9".repeat(64)}`,
|
||||
config_sha256: null,
|
||||
run_created_at_utc: "2026-08-29T18:05:11.329061+00:00",
|
||||
published_at_utc: "2026-08-30T12:00:00.000Z",
|
||||
replay_capability: {
|
||||
schema_version: "missioncore.observation-lab-replay-capability/v1",
|
||||
kind: "canonical-recorded-rerun",
|
||||
viewer_profile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
commands_enabled: false,
|
||||
},
|
||||
provenance: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalProjectionProvenance(resultId, replayCapability) {
|
||||
return {
|
||||
schema_version: "missioncore.canonical-recorded-lab-projection/v1",
|
||||
evidence_identity_sha256: resultId.slice("lab-v1-vegetation-shadow-".length),
|
||||
result_document_sha256: "a".repeat(64),
|
||||
replay_capability: replayCapability,
|
||||
authority: {
|
||||
commands_enabled: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
actuation_accepted: false,
|
||||
},
|
||||
method: {
|
||||
schema_version: "missioncore.laboratory-method/v1",
|
||||
completeness: "legacy-partial",
|
||||
execution_class: "ai-inference",
|
||||
pipeline_id: "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1",
|
||||
components: [{
|
||||
kind: "source",
|
||||
name: "sealed full-route LAB result",
|
||||
version: "missioncore.lab-v1-vegetation-shadow/v1",
|
||||
role: "immutable Session catalog projection",
|
||||
identity_sha256: resultId.slice("lab-v1-vegetation-shadow-".length),
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function replay(overrides = {}) {
|
||||
const sessionId = overrides.session_id ?? "session-20260716T205632Z";
|
||||
const sourceUrl = overrides.source_url ??
|
||||
@@ -141,6 +192,74 @@ test("session catalog decodes canonical snake_case into a path-free camelCase mo
|
||||
assert.equal("path" in catalog.items[0], false);
|
||||
});
|
||||
|
||||
test("session catalog strictly decodes the explicit recorded LAB replay capability", () => {
|
||||
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||
const decoded = decodeObservationSessionCatalog({
|
||||
items: [session({ id: resultId, lab: canonicalLab() })],
|
||||
}).items[0].lab;
|
||||
|
||||
assert.deepEqual(decoded.replayCapability, {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
|
||||
kind: "canonical-recorded-rerun",
|
||||
viewerProfile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
commandsEnabled: false,
|
||||
});
|
||||
const rollingUpgradeLab = canonicalLab();
|
||||
rollingUpgradeLab.provenance = canonicalProjectionProvenance(
|
||||
resultId,
|
||||
rollingUpgradeLab.replay_capability,
|
||||
);
|
||||
delete rollingUpgradeLab.replay_capability;
|
||||
assert.deepEqual(
|
||||
decodeObservationSessionCatalog({
|
||||
items: [session({ id: resultId, lab: rollingUpgradeLab })],
|
||||
}).items[0].lab.replayCapability,
|
||||
decoded.replayCapability,
|
||||
);
|
||||
const invalidComponents = [
|
||||
{ kind: "tool" },
|
||||
{ name: "unsealed result" },
|
||||
{ version: "missioncore.lab-v1-vegetation-shadow/v0" },
|
||||
{ role: "mutable projection" },
|
||||
{ identity_sha256: "0".repeat(64) },
|
||||
{ unexpected: true },
|
||||
];
|
||||
for (const componentOverride of invalidComponents) {
|
||||
const invalidRollingUpgradeLab = canonicalLab();
|
||||
invalidRollingUpgradeLab.provenance = canonicalProjectionProvenance(
|
||||
resultId,
|
||||
invalidRollingUpgradeLab.replay_capability,
|
||||
);
|
||||
invalidRollingUpgradeLab.provenance.method.components = [{
|
||||
...invalidRollingUpgradeLab.provenance.method.components[0],
|
||||
...componentOverride,
|
||||
}];
|
||||
delete invalidRollingUpgradeLab.replay_capability;
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({ id: resultId, lab: invalidRollingUpgradeLab })],
|
||||
}),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
id: resultId,
|
||||
lab: canonicalLab({
|
||||
replay_capability: {
|
||||
...canonicalLab().replay_capability,
|
||||
commands_enabled: true,
|
||||
},
|
||||
}),
|
||||
})],
|
||||
}),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("opened archive is named in the scene header and trash hover has no pill", async () => {
|
||||
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
|
||||
const styles = await readFile(
|
||||
@@ -246,7 +365,7 @@ test("source and laboratory catalogs are requested as disjoint backend projectio
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"/api/v1/observation-sessions?limit=100&scope=source",
|
||||
"/api/v1/observation-sessions?limit=100&scope=laboratory",
|
||||
"/api/v1/observation-sessions?limit=100&scope=laboratory&lab_contract=v2",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -60,11 +60,47 @@ function evidence(id, sourceSessionId, publishedAtUtc) {
|
||||
configSha256: "a".repeat(64),
|
||||
runCreatedAtUtc: publishedAtUtc,
|
||||
publishedAtUtc,
|
||||
replayCapability: null,
|
||||
provenance: { verdict: "must-not-be-inferred" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalProvenance(resultId) {
|
||||
const identitySha256 = resultId.slice("lab-v1-vegetation-shadow-".length);
|
||||
return {
|
||||
schema_version: "missioncore.canonical-recorded-lab-projection/v1",
|
||||
evidence_identity_sha256: identitySha256,
|
||||
result_document_sha256: "a".repeat(64),
|
||||
replay_capability: {
|
||||
schema_version: "missioncore.observation-lab-replay-capability/v1",
|
||||
kind: "canonical-recorded-rerun",
|
||||
viewer_profile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
commands_enabled: false,
|
||||
},
|
||||
authority: {
|
||||
commands_enabled: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
actuation_accepted: false,
|
||||
},
|
||||
method: {
|
||||
schema_version: "missioncore.laboratory-method/v1",
|
||||
completeness: "legacy-partial",
|
||||
execution_class: "ai-inference",
|
||||
pipeline_id: "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1",
|
||||
components: [{
|
||||
kind: "source",
|
||||
name: "sealed full-route LAB result",
|
||||
version: "missioncore.lab-v1-vegetation-shadow/v1",
|
||||
role: "immutable Session catalog projection",
|
||||
identity_sha256: identitySha256,
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("Observatory joins evidence only by sourceSessionId and keeps deterministic order", () => {
|
||||
const catalog = buildObservatoryCatalog(
|
||||
[
|
||||
@@ -130,7 +166,7 @@ test("Observatory fetches disjoint read-only source and laboratory projections",
|
||||
assert.deepEqual(
|
||||
calls.map(({ input }) => input).sort(),
|
||||
[
|
||||
"/api/v1/observation-sessions?limit=50&scope=laboratory",
|
||||
"/api/v1/observation-sessions?limit=50&scope=laboratory&lab_contract=v2",
|
||||
"/api/v1/observation-sessions?limit=50&scope=source",
|
||||
],
|
||||
);
|
||||
@@ -157,3 +193,43 @@ test("Observatory exposes bounded-window uncertainty without inventing a broken
|
||||
["outside-window"],
|
||||
);
|
||||
});
|
||||
|
||||
test("Observatory projects a typed canonical run only through its exact sourceSessionId", () => {
|
||||
const canonicalResultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||
const canonical = evidence(
|
||||
canonicalResultId,
|
||||
"20260828T130511Z_viewer_live",
|
||||
"2026-08-29T18:05:11Z",
|
||||
);
|
||||
canonical.lab = {
|
||||
...canonical.lab,
|
||||
labId: "LAB V1",
|
||||
resultKind: "recorded-perception-qualification",
|
||||
resultId: canonicalResultId,
|
||||
sourceResultId: `lab-v1-vegetation-shadow-${"9".repeat(64)}`,
|
||||
configSha256: null,
|
||||
provenance: canonicalProvenance(canonicalResultId),
|
||||
replayCapability: {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
|
||||
kind: "canonical-recorded-rerun",
|
||||
viewerProfile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
commandsEnabled: false,
|
||||
},
|
||||
};
|
||||
const catalog = buildObservatoryCatalog(
|
||||
[source("20260828T130511Z_viewer_live", "2026-08-28T13:05:11Z")],
|
||||
[canonical],
|
||||
);
|
||||
|
||||
assert.deepEqual(catalog.items[0].evidence[0].recordedRun, {
|
||||
kind: "canonical-recorded-rerun",
|
||||
evidenceSessionId: canonicalResultId,
|
||||
sourceSessionId: "20260828T130511Z_viewer_live",
|
||||
resultId: canonicalResultId,
|
||||
viewerProfile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let admitObservatoryRecordedRunReview;
|
||||
let observatoryRecordedRunBinding;
|
||||
let ObservatoryRecordedRunContractError;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
admitObservatoryRecordedRunReview,
|
||||
observatoryRecordedRunBinding,
|
||||
ObservatoryRecordedRunContractError,
|
||||
} = await server.ssrLoadModule("/src/core/observatory/recordedRun.ts"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
const sourceSessionId = "20260828T130511Z_viewer_live";
|
||||
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||
|
||||
function canonicalProvenance() {
|
||||
const identitySha256 = resultId.slice("lab-v1-vegetation-shadow-".length);
|
||||
return {
|
||||
schema_version: "missioncore.canonical-recorded-lab-projection/v1",
|
||||
evidence_identity_sha256: identitySha256,
|
||||
result_document_sha256: "a".repeat(64),
|
||||
replay_capability: {
|
||||
schema_version: "missioncore.observation-lab-replay-capability/v1",
|
||||
kind: "canonical-recorded-rerun",
|
||||
viewer_profile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
commands_enabled: false,
|
||||
},
|
||||
authority: {
|
||||
commands_enabled: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
actuation_accepted: false,
|
||||
},
|
||||
method: {
|
||||
schema_version: "missioncore.laboratory-method/v1",
|
||||
completeness: "legacy-partial",
|
||||
execution_class: "ai-inference",
|
||||
pipeline_id: "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1",
|
||||
components: [{
|
||||
kind: "source",
|
||||
name: "sealed full-route LAB result",
|
||||
version: "missioncore.lab-v1-vegetation-shadow/v1",
|
||||
role: "immutable Session catalog projection",
|
||||
identity_sha256: identitySha256,
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function lab(overrides = {}) {
|
||||
return {
|
||||
labId: "LAB V1",
|
||||
sourceSessionId,
|
||||
resultKind: "recorded-perception-qualification",
|
||||
resultId,
|
||||
sourceResultId: `lab-v1-vegetation-shadow-${"9".repeat(64)}`,
|
||||
configSha256: null,
|
||||
runCreatedAtUtc: "2026-08-29T18:05:11.329061+00:00",
|
||||
publishedAtUtc: "2026-08-30T12:00:00.000Z",
|
||||
replayCapability: {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
|
||||
kind: "canonical-recorded-rerun",
|
||||
viewerProfile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
commandsEnabled: false,
|
||||
},
|
||||
provenance: canonicalProvenance(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("Observatory admits only the exact typed canonical recorded run", () => {
|
||||
const binding = observatoryRecordedRunBinding(resultId, lab());
|
||||
assert.deepEqual(binding, {
|
||||
kind: "canonical-recorded-rerun",
|
||||
evidenceSessionId: resultId,
|
||||
sourceSessionId,
|
||||
resultId,
|
||||
viewerProfile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
});
|
||||
assert.equal(
|
||||
observatoryRecordedRunBinding(resultId, lab({ replayCapability: null })),
|
||||
null,
|
||||
);
|
||||
assert.throws(
|
||||
() => observatoryRecordedRunBinding("different-session", lab()),
|
||||
ObservatoryRecordedRunContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => observatoryRecordedRunBinding(
|
||||
resultId,
|
||||
lab({ sourceSessionId: "RAVNOVES004TREE" }),
|
||||
),
|
||||
ObservatoryRecordedRunContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => observatoryRecordedRunBinding(
|
||||
resultId,
|
||||
lab({ configSha256: "a".repeat(64) }),
|
||||
),
|
||||
ObservatoryRecordedRunContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => observatoryRecordedRunBinding(
|
||||
resultId,
|
||||
lab({ sourceResultId: "legacy-result" }),
|
||||
),
|
||||
ObservatoryRecordedRunContractError,
|
||||
);
|
||||
const invalidProvenance = canonicalProvenance();
|
||||
invalidProvenance.method.components[0].identity_sha256 = "0".repeat(64);
|
||||
assert.throws(
|
||||
() => observatoryRecordedRunBinding(
|
||||
resultId,
|
||||
lab({ provenance: invalidProvenance }),
|
||||
),
|
||||
ObservatoryRecordedRunContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("Observatory rechecks the selected source and sealed review before mounting replay", () => {
|
||||
const binding = observatoryRecordedRunBinding(resultId, lab());
|
||||
const review = { sessionId: sourceSessionId, frameCount: 6830 };
|
||||
assert.equal(
|
||||
admitObservatoryRecordedRunReview(
|
||||
binding,
|
||||
sourceSessionId,
|
||||
{ resultId, routeFullReview: review },
|
||||
),
|
||||
review,
|
||||
);
|
||||
assert.throws(
|
||||
() => admitObservatoryRecordedRunReview(
|
||||
binding,
|
||||
"another-session",
|
||||
{ resultId, routeFullReview: review },
|
||||
),
|
||||
ObservatoryRecordedRunContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => admitObservatoryRecordedRunReview(
|
||||
binding,
|
||||
sourceSessionId,
|
||||
{ resultId, routeFullReview: { ...review, sessionId: "another-session" } },
|
||||
),
|
||||
ObservatoryRecordedRunContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("Observatory run admission remains metadata-only until explicit UI activation", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/core/observatory/recordedRun.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /fetchVegetationShadowResultMetadata/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/fetchVegetationShadowResult\(|advanced-index|resolveObservationSessionReplay|resolveCanonicalLabReplay|RerunViewport|recording\.rrd/,
|
||||
);
|
||||
});
|
||||
@@ -46,12 +46,25 @@ test("Observatory is the third independent Polygon workspace", () => {
|
||||
assert.equal(productModel.workspaceById("lab-archive").kind, "lab-archive");
|
||||
});
|
||||
|
||||
test("Observatory reads catalog evidence without inheriting replay or LAB composition", async () => {
|
||||
const [app, workspaceHub, workspace, hook, viewerProfiles, styles] = await Promise.all([
|
||||
test("Observatory mounts the one shared canonical replay only after explicit admission", async () => {
|
||||
const [
|
||||
app,
|
||||
workspaceHub,
|
||||
workspace,
|
||||
hook,
|
||||
recordedRun,
|
||||
sharedReplay,
|
||||
legacyReplayWrapper,
|
||||
viewerProfiles,
|
||||
styles,
|
||||
] = await Promise.all([
|
||||
read("App.tsx"),
|
||||
read("workspaces/Workspaces.tsx"),
|
||||
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
|
||||
read("core/observatory/useObservatoryCatalog.ts"),
|
||||
read("core/observatory/recordedRun.ts"),
|
||||
read("components/laboratory/CanonicalVegetationRerunReplay.tsx"),
|
||||
read("workspaces/laboratory/CanonicalVegetationRerunReplay.tsx"),
|
||||
read("core/observation/viewerProfile.ts"),
|
||||
read("styles/observatory.css"),
|
||||
]);
|
||||
@@ -68,11 +81,30 @@ test("Observatory reads catalog evidence without inheriting replay or LAB compos
|
||||
assert.match(workspace, /вне текущего загруженного среза/);
|
||||
assert.match(workspace, /observatory-notice__copy/);
|
||||
assert.match(workspace, /observatory-evidence-card/);
|
||||
assert.match(workspace, /Открыть визуальный разбор/);
|
||||
assert.match(workspace, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
|
||||
assert.match(workspace, /Проверяем точную связь результата/);
|
||||
assert.match(workspace, /role="alert"/);
|
||||
assert.match(workspace, /Повторить/);
|
||||
assert.match(workspace, /Закрыть разбор/);
|
||||
assert.match(workspace, /const selectSession[\s\S]*closeReplay\(\);[\s\S]*setSelectedSessionId/);
|
||||
assert.match(workspace, /data-observatory-authority="observation-only"/);
|
||||
assert.doesNotMatch(workspace, /Нарушена связь|compactIdentity/);
|
||||
assert.doesNotMatch(
|
||||
`${workspace}\n${hook}`,
|
||||
/RerunViewport|ObservationSessionSelect|resolveObservationSessionReplay|deleteObservationSession|setInterval|setTimeout/,
|
||||
/RerunViewport|ObservationSessionSelect|resolveObservationSessionReplay|resolveCanonicalLabReplay|deleteObservationSession|setInterval|setTimeout|useObservationSessions|useAdvancedLaboratoryCatalog|advanced-index|prefetch|preload/,
|
||||
);
|
||||
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
|
||||
assert.match(sharedReplay, /recordedSessionRerunProfile/);
|
||||
assert.match(sharedReplay, /timelineStartSeconds/);
|
||||
assert.doesNotMatch(sharedReplay, /live-acquisition|lab-recorded-evidence/);
|
||||
assert.match(
|
||||
legacyReplayWrapper,
|
||||
/export \{ CanonicalVegetationRerunReplay \} from "\.\.\/\.\.\/components\/laboratory\/CanonicalVegetationRerunReplay"/,
|
||||
);
|
||||
assert.match(recordedRun, /fetchVegetationShadowResultMetadata/);
|
||||
assert.doesNotMatch(recordedRun, /advanced-index|recording\.rrd|canonical-replay\.rrd/);
|
||||
assert.doesNotMatch(workspace, /workspaces\/laboratory|LaboratoryRecordedClipPlayer|SimulationViewport/);
|
||||
assert.match(viewerProfiles, /kind: "live-acquisition"/);
|
||||
assert.match(viewerProfiles, /kind: "recorded-session"/);
|
||||
assert.match(viewerProfiles, /kind: "lab-recorded-evidence"/);
|
||||
|
||||
@@ -470,7 +470,7 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
|
||||
"utf8",
|
||||
),
|
||||
readFile(
|
||||
new URL("../src/workspaces/laboratory/CanonicalVegetationRerunReplay.tsx", import.meta.url),
|
||||
new URL("../src/components/laboratory/CanonicalVegetationRerunReplay.tsx", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user