From be83283ee03ba9aa286bce3f0d214d14a8b612db Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 30 Aug 2026 20:36:12 +0300 Subject: [PATCH] fix(observatory): refine catalog and replay UX --- .../laboratory/CanonicalRecordedLabReplay.tsx | 2 +- .../CanonicalVegetationRerunReplay.tsx | 226 ++++++++-- .../src/core/observatory/catalog.ts | 110 +++++ .../src/core/observatory/catalogMutations.ts | 203 +++++++++ .../core/observatory/useObservatoryCatalog.ts | 125 +++++- .../src/styles/m4-replay-threat.css | 51 +++ .../src/styles/observatory.css | 199 ++++++--- .../observatory/ObservatoryWorkspace.tsx | 396 ++++++++++++++---- .../test/m4ReplayThreat.test.mjs | 4 +- .../test/observatoryCatalog.test.mjs | 139 ++++++ .../test/observatoryCatalogMutations.test.mjs | 123 ++++++ .../test/observatoryWorkspace.test.mjs | 75 ++++ .../test/vegetationShadow.test.mjs | 35 +- src/k1link/sessions/store.py | 151 ++++++- src/k1link/web/app.py | 2 + src/k1link/web/observatory_api.py | 98 +++++ tests/test_session_api.py | 136 ++++++ tests/test_session_store.py | 263 ++++++++++++ 18 files changed, 2137 insertions(+), 201 deletions(-) create mode 100644 apps/control-station/src/core/observatory/catalogMutations.ts create mode 100644 apps/control-station/test/observatoryCatalogMutations.test.mjs create mode 100644 src/k1link/web/observatory_api.py diff --git a/apps/control-station/src/components/laboratory/CanonicalRecordedLabReplay.tsx b/apps/control-station/src/components/laboratory/CanonicalRecordedLabReplay.tsx index 740de90..814da58 100644 --- a/apps/control-station/src/components/laboratory/CanonicalRecordedLabReplay.tsx +++ b/apps/control-station/src/components/laboratory/CanonicalRecordedLabReplay.tsx @@ -241,7 +241,7 @@ export function CanonicalRecordedLabReplay< orientation={splitOrientation} minPrimarySize={splitView ? 24 : 0} minSecondarySize={splitView ? 24 : 0} - resizable={splitView} + resizable={splitView && !unifiedContent} separatorLabel="Изменить размер видео/камеры и 3D/плана" /> {mediaMode === "none" && spatialMode === "none" ? ( diff --git a/apps/control-station/src/components/laboratory/CanonicalVegetationRerunReplay.tsx b/apps/control-station/src/components/laboratory/CanonicalVegetationRerunReplay.tsx index 24aaf52..7b0cb15 100644 --- a/apps/control-station/src/components/laboratory/CanonicalVegetationRerunReplay.tsx +++ b/apps/control-station/src/components/laboratory/CanonicalVegetationRerunReplay.tsx @@ -1,11 +1,26 @@ -import { useEffect, useMemo, useState } from "react"; -import { Button, Icon, SegmentedControl } from "@nodedc/ui-react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type PointerEvent as ReactPointerEvent, +} from "react"; +import { + ActivityIndicator, + Button, + Icon, + SegmentedControl, +} from "@nodedc/ui-react"; import { ObservationTimeline } from "../ObservationTimeline"; import { RerunViewport, + isRecordedPlaybackPresentationReady, type RerunPlaybackController, type RerunPlaybackState, + type RerunViewportStatus, } from "../RerunViewport"; import { CanonicalRecordedLabReplay, @@ -31,6 +46,9 @@ interface CanonicalReplayLaunch { replay: CanonicalLabReplayDescriptor; } +const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46; +const RERUN_NATIVE_DIVIDER_HIT_SLOP_PX = 10; + export function CanonicalVegetationRerunReplay({ resultId, review, @@ -52,6 +70,7 @@ export function CanonicalVegetationRerunReplay({ initialMediaMode: "video", initialSpatialMode: "3d", }); + const splitView = mediaMode !== null && spatialMode !== null; const [semanticLayer, setSemanticLayer] = useState("vegetation"); const [showSemantics, setShowSemantics] = useState(true); const [spatialLayer, setSpatialLayer] = useState("source"); @@ -59,13 +78,92 @@ export function CanonicalVegetationRerunReplay({ const [playback, setPlayback] = useState(null); const [playbackController, setPlaybackController] = useState(null); + const [viewerStatus, setViewerStatus] = useState("idle"); const [launch, setLaunch] = useState(null); const [launchError, setLaunchError] = useState(null); + const viewerFrameRef = useRef(null); + const nativeSplitPercentRef = useRef(RERUN_UNIFIED_CAMERA_SHARE_PERCENT); + const nativeSplitTrackingCleanupRef = useRef<(() => void) | null>(null); + const previousSplitViewRef = useRef(splitView); + const trackedLaunchSha256Ref = useRef(null); + + const stopNativeSplitTracking = useCallback(() => { + nativeSplitTrackingCleanupRef.current?.(); + nativeSplitTrackingCleanupRef.current = null; + }, []); + + const trackNativeSplit = useCallback((event: ReactPointerEvent) => { + if ( + !splitView + || event.button !== 0 + || !(event.target instanceof HTMLCanvasElement) + ) return; + const frame = viewerFrameRef.current; + if (!frame) return; + const bounds = frame.getBoundingClientRect(); + if (bounds.width <= 0) return; + const dividerX = bounds.left + + bounds.width * nativeSplitPercentRef.current / 100; + if (Math.abs(event.clientX - dividerX) > RERUN_NATIVE_DIVIDER_HIT_SLOP_PX) return; + + stopNativeSplitTracking(); + const pointerId = event.pointerId; + const update = (pointerEvent: PointerEvent) => { + if (pointerEvent.pointerId !== pointerId) return; + const currentBounds = frame.getBoundingClientRect(); + if (currentBounds.width <= 0) return; + const next = Math.min(90, Math.max( + 10, + (pointerEvent.clientX - currentBounds.left) / currentBounds.width * 100, + )); + nativeSplitPercentRef.current = next; + frame.style.setProperty("--canonical-rerun-camera-pane", `${next}%`); + }; + const stop = (pointerEvent: PointerEvent) => { + if (pointerEvent.pointerId !== pointerId) return; + stopNativeSplitTracking(); + }; + window.addEventListener("pointermove", update, true); + window.addEventListener("pointerup", stop, true); + window.addEventListener("pointercancel", stop, true); + nativeSplitTrackingCleanupRef.current = () => { + window.removeEventListener("pointermove", update, true); + window.removeEventListener("pointerup", stop, true); + window.removeEventListener("pointercancel", stop, true); + }; + }, [splitView, stopNativeSplitTracking]); + + useEffect(() => stopNativeSplitTracking, [stopNativeSplitTracking]); + + useEffect(() => { + if (!splitView) stopNativeSplitTracking(); + const launchSha256 = launch?.replay.sha256 ?? null; + if ( + trackedLaunchSha256Ref.current !== launchSha256 + || (splitView && !previousSplitViewRef.current) + ) { + nativeSplitPercentRef.current = RERUN_UNIFIED_CAMERA_SHARE_PERCENT; + } + trackedLaunchSha256Ref.current = launchSha256; + previousSplitViewRef.current = splitView; + const cameraPanePercent = mediaMode === null + ? 0 + : splitView + ? nativeSplitPercentRef.current + : 100; + viewerFrameRef.current?.style.setProperty( + "--canonical-rerun-camera-pane", + `${cameraPanePercent}%`, + ); + }, [launch?.replay.sha256, mediaMode, splitView, stopNativeSplitTracking]); useEffect(() => { const controller = new AbortController(); setLaunch(null); setLaunchError(null); + setPlayback(null); + setPlaybackController(null); + setViewerStatus("idle"); void resolveObservationSessionReplay(review.sessionId, { signal: controller.signal, maximumWaitMs: 30 * 60 * 1000, @@ -87,7 +185,13 @@ export function CanonicalVegetationRerunReplay({ return () => controller.abort(); }, [resultId, review.sessionId]); - const splitView = mediaMode !== null && spatialMode !== null; + const presentationReady = playbackController !== null + && isRecordedPlaybackPresentationReady(viewerStatus, playback); + const presentationState = launchError || viewerStatus === "error" + ? "error" + : presentationReady + ? "ready" + : "loading"; const sceneSettings = useMemo(() => ({ ...defaultSceneSettings, accumulationSeconds: spatialLayer === "local" ? 5 : 0, @@ -123,7 +227,7 @@ export function CanonicalVegetationRerunReplay({ cuboids3d: false, }, perceptionRetryGeneration: 0, - lockPerceptionCameraInteraction: false, + lockPerceptionCameraInteraction: mediaMode !== null, }) : null; const mediaLayerControls = ( @@ -188,9 +292,9 @@ export function CanonicalVegetationRerunReplay({ ); - const transport = playback && playbackController ? ( + const transport = presentationReady && playback && playbackController ? ( ) : undefined; return ( - - ) : ( -
- {launchError ?? "Готовим единый кэш канонического повтора RAV004…"} -
- )} - emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте." - transport={transport} - onMediaModeChange={onMediaModeChange} - onSpatialModeChange={onSpatialModeChange} - onExpandedChange={onExpandedChange} - onSplitPrimarySizeChange={onSplitPrimarySizeChange} - /> +
+ + +
+ ) : launchError ? ( +
+ {launchError} +
+ ) : ( + ); } diff --git a/apps/control-station/src/core/observatory/catalog.ts b/apps/control-station/src/core/observatory/catalog.ts index 71735bb..4aacc02 100644 --- a/apps/control-station/src/core/observatory/catalog.ts +++ b/apps/control-station/src/core/observatory/catalog.ts @@ -35,6 +35,27 @@ export interface ObservatoryCatalog { }; } +export type ObservatoryCatalogMutation = + | { + readonly kind: "rename"; + readonly displayName: string; + readonly revision: number; + } + | { + readonly kind: "delete"; + readonly revision: number; + }; + +export type ObservatoryCatalogMutationOverlay = ReadonlyMap< + string, + ObservatoryCatalogMutation +>; + +export interface ObservatoryCatalogReconciliation { + readonly catalog: ObservatoryCatalog; + readonly overlay: ObservatoryCatalogMutationOverlay; +} + export class ObservatoryCatalogContractError extends Error { constructor(message: string) { super(message); @@ -42,6 +63,95 @@ export class ObservatoryCatalogContractError extends Error { } } +export function findObservatoryEvidence( + catalog: ObservatoryCatalog, + sessionId: string, +): ObservatoryEvidence | null { + for (const item of catalog.items) { + const evidence = item.evidence.find( + (candidate) => candidate.sessionId === sessionId, + ); + if (evidence) return evidence; + } + return catalog.unresolvedEvidence.find( + (candidate) => candidate.sessionId === sessionId, + ) ?? null; +} + +export function observatoryCatalogConfirmsEvidenceDeletion( + catalog: ObservatoryCatalog, + sessionId: string, +): boolean { + return !catalog.window.laboratoryLimitReached + && findObservatoryEvidence(catalog, sessionId) === null; +} + +export function applyObservatoryCatalogMutationOverlay( + catalog: ObservatoryCatalog, + overlay: ObservatoryCatalogMutationOverlay, +): ObservatoryCatalog { + if (overlay.size === 0) return catalog; + + const removedSessionIds = new Set(); + const projectEvidence = ( + evidence: ObservatoryEvidence, + ): ObservatoryEvidence | null => { + const mutation = overlay.get(evidence.sessionId); + if (!mutation) return evidence; + if (mutation.kind === "delete") { + removedSessionIds.add(evidence.sessionId); + return null; + } + return evidence.label === mutation.displayName + ? evidence + : { ...evidence, label: mutation.displayName }; + }; + const projectEvidenceList = ( + evidence: readonly ObservatoryEvidence[], + ): ObservatoryEvidence[] => evidence.flatMap((candidate) => { + const projected = projectEvidence(candidate); + return projected ? [projected] : []; + }); + + const items = catalog.items.map((item) => ({ + ...item, + evidence: projectEvidenceList(item.evidence), + })); + const unresolvedEvidence = projectEvidenceList(catalog.unresolvedEvidence); + return { + ...catalog, + items, + unresolvedEvidence, + window: { + ...catalog.window, + laboratoryCount: Math.max( + 0, + catalog.window.laboratoryCount - removedSessionIds.size, + ), + }, + }; +} + +export function reconcileObservatoryCatalogMutationOverlay( + serverCatalog: ObservatoryCatalog, + overlay: ObservatoryCatalogMutationOverlay, + requestMutationRevision: number, +): ObservatoryCatalogReconciliation { + const remaining = new Map(overlay); + for (const [sessionId, mutation] of overlay) { + if (mutation.revision > requestMutationRevision) continue; + const evidence = findObservatoryEvidence(serverCatalog, sessionId); + const confirmed = mutation.kind === "rename" + ? evidence?.label === mutation.displayName + : observatoryCatalogConfirmsEvidenceDeletion(serverCatalog, sessionId); + if (confirmed) remaining.delete(sessionId); + } + return { + catalog: applyObservatoryCatalogMutationOverlay(serverCatalog, remaining), + overlay: remaining, + }; +} + function newestFirst(left: string, right: string): number { return Date.parse(right) - Date.parse(left); } diff --git a/apps/control-station/src/core/observatory/catalogMutations.ts b/apps/control-station/src/core/observatory/catalogMutations.ts new file mode 100644 index 0000000..2d9ac64 --- /dev/null +++ b/apps/control-station/src/core/observatory/catalogMutations.ts @@ -0,0 +1,203 @@ +import type { ObservatoryRecordedRunBinding } from "./recordedRun"; + +const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const RENAME_SCHEMA = "missioncore.observatory-lab-projection-rename/v1"; +const PROJECTION_SCHEMA = "missioncore.observatory-lab-projection/v1"; + +export type ObservatoryCatalogMutationFetch = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +export interface ObservatoryLabProjectionMutationResult { + readonly schemaVersion: typeof PROJECTION_SCHEMA; + readonly sessionId: string; + readonly displayName: string; +} + +export class ObservatoryCatalogMutationError extends Error { + readonly status: number | null; + + constructor(message: string, status: number | null = null) { + super(message); + this.name = "ObservatoryCatalogMutationError"; + this.status = status; + } +} + +export async function renameObservatoryLabProjection( + binding: ObservatoryRecordedRunBinding, + displayName: string, + { + signal, + fetcher = globalThis.fetch, + }: { + signal?: AbortSignal; + fetcher?: ObservatoryCatalogMutationFetch; + } = {}, +): Promise { + const sessionId = admittedProjectionId(binding); + const normalizedName = displayName.trim(); + if (normalizedName.length < 1 || normalizedName.length > 160) { + throw new ObservatoryCatalogMutationError( + "Название лабораторного результата должно содержать от 1 до 160 символов.", + ); + } + const response = await request( + fetcher, + `/api/v1/observatory/lab-projections/${encodeURIComponent(sessionId)}`, + { + method: "PATCH", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + schema_version: RENAME_SCHEMA, + display_name: normalizedName, + }), + signal, + }, + "Не удалось переименовать лабораторный результат.", + ); + const body = await responseBody(response); + if (!response.ok || response.status !== 200) { + throw apiError( + body, + `Переименование лабораторного результата вернуло HTTP ${response.status}.`, + response.status, + ); + } + const decoded = decodeProjection(body); + if (decoded.sessionId !== sessionId || decoded.displayName !== normalizedName) { + throw new ObservatoryCatalogMutationError( + "Сервер не подтвердил точное переименование выбранного результата.", + ); + } + return decoded; +} + +export async function deleteObservatoryLabProjection( + binding: ObservatoryRecordedRunBinding, + { + signal, + fetcher = globalThis.fetch, + }: { + signal?: AbortSignal; + fetcher?: ObservatoryCatalogMutationFetch; + } = {}, +): Promise { + const sessionId = admittedProjectionId(binding); + const response = await request( + fetcher, + `/api/v1/observatory/lab-projections/${encodeURIComponent(sessionId)}`, + { + method: "DELETE", + headers: { Accept: "application/json" }, + signal, + }, + "Не удалось удалить лабораторный результат из Обсерватории.", + ); + const body = await responseBody(response); + if (!response.ok || response.status !== 204) { + throw apiError( + body, + `Удаление лабораторного результата вернуло HTTP ${response.status}.`, + response.status, + ); + } + if (body !== undefined) { + throw new ObservatoryCatalogMutationError( + "Сервер вернул данные после подтверждённого удаления лабораторного результата.", + ); + } +} + +function admittedProjectionId(binding: ObservatoryRecordedRunBinding): string { + const sessionId = binding.evidenceSessionId; + if ( + binding.kind !== "canonical-recorded-rerun" + || binding.activation !== "explicit" + || binding.viewerProfile !== "recorded-session" + || binding.timeline !== "session_time" + || binding.resultId !== sessionId + || binding.sourceSessionId === sessionId + || !SAFE_SESSION_ID.test(sessionId) + ) { + throw new ObservatoryCatalogMutationError( + "Выбранный результат не допущен к изменению каталога Обсерватории.", + ); + } + return sessionId; +} + +async function request( + fetcher: ObservatoryCatalogMutationFetch, + input: string, + init: RequestInit, + fallback: string, +): Promise { + try { + return await fetcher(input, init); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") throw error; + throw new ObservatoryCatalogMutationError(fallback); + } +} + +async function responseBody(response: Response): Promise { + const text = await response.text(); + if (!text) return undefined; + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } +} + +function apiError(body: unknown, fallback: string, status: number): Error { + if (isRecord(body) && typeof body.detail === "string" && body.detail.trim()) { + return new ObservatoryCatalogMutationError(body.detail.trim(), status); + } + if (typeof body === "string" && body.trim()) { + return new ObservatoryCatalogMutationError(body.trim(), status); + } + return new ObservatoryCatalogMutationError(fallback, status); +} + +function decodeProjection(value: unknown): ObservatoryLabProjectionMutationResult { + if (!isRecord(value)) { + throw new ObservatoryCatalogMutationError( + "Сервер вернул некорректное подтверждение лабораторного результата.", + ); + } + const keys = Object.keys(value).sort(); + const expected = ["display_name", "schema_version", "session_id"]; + if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) { + throw new ObservatoryCatalogMutationError( + "Подтверждение лабораторного результата содержит неизвестные поля.", + ); + } + if ( + value.schema_version !== PROJECTION_SCHEMA + || typeof value.session_id !== "string" + || !SAFE_SESSION_ID.test(value.session_id) + || typeof value.display_name !== "string" + || value.display_name.trim() !== value.display_name + || value.display_name.length < 1 + || value.display_name.length > 160 + ) { + throw new ObservatoryCatalogMutationError( + "Сервер вернул неподдерживаемый контракт лабораторного результата.", + ); + } + return { + schemaVersion: PROJECTION_SCHEMA, + sessionId: value.session_id, + displayName: value.display_name, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/control-station/src/core/observatory/useObservatoryCatalog.ts b/apps/control-station/src/core/observatory/useObservatoryCatalog.ts index 84d7c27..b79b551 100644 --- a/apps/control-station/src/core/observatory/useObservatoryCatalog.ts +++ b/apps/control-station/src/core/observatory/useObservatoryCatalog.ts @@ -1,8 +1,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { + applyObservatoryCatalogMutationOverlay, fetchObservatoryCatalog, + reconcileObservatoryCatalogMutationOverlay, type ObservatoryCatalog, + type ObservatoryCatalogMutationOverlay, } from "./catalog"; export type ObservatoryCatalogState = @@ -16,9 +19,23 @@ export interface ObservatoryCatalogController { readonly catalog: ObservatoryCatalog | null; readonly state: ObservatoryCatalogState; readonly error: string | null; - readonly refresh: () => void; + readonly refresh: () => Promise; + readonly applyEvidenceRename: ( + sessionId: string, + displayName: string, + ) => void; + readonly applyEvidenceDeletion: (sessionId: string) => void; } +interface ActiveCatalogRequest { + readonly id: number; + readonly controller: AbortController; +} + +type ObservatoryCatalogMutationDraft = + | { readonly kind: "rename"; readonly displayName: string } + | { readonly kind: "delete" }; + function errorMessage(error: unknown): string { return error instanceof Error && error.message.trim() ? error.message @@ -33,31 +50,99 @@ export function useObservatoryCatalog(): ObservatoryCatalogController { const [catalog, setCatalog] = useState(null); const [state, setState] = useState("idle"); const [error, setError] = useState(null); - const [generation, setGeneration] = useState(0); const catalogRef = useRef(null); + const overlayRef = useRef(new Map()); + const mutationRevisionRef = useRef(0); + const requestSequenceRef = useRef(0); + const activeRequestRef = useRef(null); - useEffect(() => { + const loadCatalog = useCallback(async (): Promise => { + const id = requestSequenceRef.current + 1; + requestSequenceRef.current = id; + activeRequestRef.current?.controller.abort(); const controller = new AbortController(); + activeRequestRef.current = { id, controller }; + const requestMutationRevision = mutationRevisionRef.current; + setState(catalogRef.current ? "refreshing" : "loading"); setError(null); - void fetchObservatoryCatalog({ signal: controller.signal }) - .then((next) => { - if (controller.signal.aborted) return; - catalogRef.current = next; - setCatalog(next); - setState("ready"); - }) - .catch((loadError: unknown) => { - if (controller.signal.aborted || isAbortError(loadError)) return; - setError(errorMessage(loadError)); - setState("error"); + try { + const serverCatalog = await fetchObservatoryCatalog({ + signal: controller.signal, }); - return () => controller.abort(); - }, [generation]); - - const refresh = useCallback(() => { - setGeneration((current) => current + 1); + if (controller.signal.aborted || requestSequenceRef.current !== id) { + return null; + } + const reconciled = reconcileObservatoryCatalogMutationOverlay( + serverCatalog, + overlayRef.current, + requestMutationRevision, + ); + overlayRef.current = reconciled.overlay; + catalogRef.current = reconciled.catalog; + activeRequestRef.current = null; + setCatalog(reconciled.catalog); + setState("ready"); + return reconciled.catalog; + } catch (loadError: unknown) { + if ( + controller.signal.aborted + || requestSequenceRef.current !== id + || isAbortError(loadError) + ) { + return null; + } + activeRequestRef.current = null; + setError(errorMessage(loadError)); + setState("error"); + return null; + } }, []); - return { catalog, state, error, refresh }; + useEffect(() => { + void loadCatalog(); + return () => { + requestSequenceRef.current += 1; + activeRequestRef.current?.controller.abort(); + activeRequestRef.current = null; + }; + }, [loadCatalog]); + + const applyMutation = useCallback(( + sessionId: string, + mutation: ObservatoryCatalogMutationDraft, + ) => { + const revision = mutationRevisionRef.current + 1; + mutationRevisionRef.current = revision; + const overlay = new Map(overlayRef.current); + overlay.set(sessionId, { ...mutation, revision }); + overlayRef.current = overlay; + if (!catalogRef.current) return; + const projected = applyObservatoryCatalogMutationOverlay( + catalogRef.current, + overlay, + ); + catalogRef.current = projected; + setCatalog(projected); + }, []); + + const applyEvidenceRename = useCallback(( + sessionId: string, + displayName: string, + ) => { + applyMutation(sessionId, { kind: "rename", displayName }); + }, [applyMutation]); + + const applyEvidenceDeletion = useCallback((sessionId: string) => { + applyMutation(sessionId, { kind: "delete" }); + }, [applyMutation]); + + return { + catalog, + state, + error, + refresh: loadCatalog, + applyEvidenceRename, + applyEvidenceDeletion, + }; } diff --git a/apps/control-station/src/styles/m4-replay-threat.css b/apps/control-station/src/styles/m4-replay-threat.css index bf01798..52cf700 100644 --- a/apps/control-station/src/styles/m4-replay-threat.css +++ b/apps/control-station/src/styles/m4-replay-threat.css @@ -62,6 +62,53 @@ pointer-events: auto; } +.canonical-vegetation-rerun-replay { + width: 100%; + min-width: 0; +} + +.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"]) + .m4-replay-threat-visual__pane-toolbar, +.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"]) + .laboratory-evidence-viewer__controls, +.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"]) + .laboratory-evidence-viewer__transport { + visibility: hidden; + pointer-events: none; +} + +.canonical-vegetation-rerun-replay[data-presentation-state="loading"] + .m4-replay-threat-visual__unified-content { + visibility: hidden; +} + +.canonical-vegetation-rerun-replay__viewport-lock { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +} + +.canonical-vegetation-rerun-replay__viewport-lock + .rerun-viewport__camera-lock { + width: var(--canonical-rerun-camera-pane, 100%); +} + +.canonical-vegetation-rerun-replay__viewport-lock[data-split-view="true"] + .rerun-viewport__camera-lock { + width: calc(var(--canonical-rerun-camera-pane, 46%) - 0.75rem); +} + +.canonical-vegetation-rerun-replay__loading { + position: absolute; + z-index: 6; + inset: 0; + display: grid; + place-items: center; + background: var(--nodedc-canvas); + pointer-events: none; +} + .m4-replay-threat-visual__deck[data-empty="true"] > .l3-visual-audit__state { position: absolute; z-index: 1; @@ -282,6 +329,10 @@ grid-template-columns: auto auto auto minmax(0, 1fr) auto; } +.canonical-vegetation-rerun-replay__timeline .observation-timeline__playback { + grid-template-columns: auto auto minmax(0, 1fr) auto; +} + .m4-replay-threat-visual__overlay { box-sizing: border-box; min-width: 0; diff --git a/apps/control-station/src/styles/observatory.css b/apps/control-station/src/styles/observatory.css index 687fae6..2cab185 100644 --- a/apps/control-station/src/styles/observatory.css +++ b/apps/control-station/src/styles/observatory.css @@ -10,7 +10,6 @@ .observatory-catalog-bar, .observatory-catalog-bar__controls, .observatory-notice, -.observatory-session-summary > header, .observatory-evidence > header { display: flex; align-items: center; @@ -27,7 +26,6 @@ .observatory-lead > div, .observatory-catalog-bar__copy, -.observatory-session-summary header > div, .observatory-evidence header > div { min-width: 0; } @@ -77,64 +75,91 @@ margin: 0; } -.observatory-session-grid { +.observatory-session-stack { display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(24rem, 100%), 1fr)); - gap: 1rem; - align-items: start; + gap: 0.9rem; + container-name: observatory-session; + container-type: inline-size; } -.observatory-session-summary, -.observatory-evidence { +.observatory-session-summary { display: grid; + grid-template-columns: minmax(14rem, 1.1fr) minmax(22rem, 1.6fr) minmax(14rem, auto); + align-items: center; gap: 1rem; } -.observatory-session-summary dl, -.observatory-evidence-list dl { +.observatory-session-summary__identity { display: grid; - gap: 0.75rem; + min-width: 0; + gap: 0.22rem; +} + +.observatory-session-summary__identity h3 { + margin: 0.1rem 0 0; +} + +.observatory-session-summary__identity code { + overflow: hidden; + color: var(--nodedc-text-muted); + font-size: var(--nodedc-font-size-xs); + text-overflow: ellipsis; + white-space: nowrap; +} + +.observatory-session-summary__facts { + display: grid; + grid-template-columns: repeat(3, minmax(6.5rem, 1fr)); + gap: 1rem; margin: 0; } -.observatory-session-summary dl > div, -.observatory-evidence-list dl > div { +.observatory-session-summary__facts > div { display: grid; - grid-template-columns: minmax(7.5rem, 0.42fr) minmax(0, 1fr); - gap: 0.85rem; - align-items: baseline; + min-width: 0; + gap: 0.24rem; } -.observatory-session-summary dt, -.observatory-evidence-list dt { +.observatory-session-summary__facts dt { color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); } -.observatory-session-summary dd, -.observatory-evidence-list dd { +.observatory-session-summary__facts dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--nodedc-text-secondary); } -.observatory-session-summary code, -.observatory-evidence-list code { - font-size: var(--nodedc-font-size-xs); +.observatory-session-summary__end { + display: grid; + min-width: 0; + justify-items: end; + gap: 0.6rem; } .observatory-modalities { display: flex; flex-wrap: wrap; + justify-content: flex-end; gap: 0.45rem; align-items: center; color: var(--nodedc-text-muted); } +.observatory-evidence { + display: grid; + gap: 0.65rem; +} + +.observatory-evidence > header { + padding: 0 0.2rem; +} + .observatory-evidence-list { display: grid; - gap: 0.75rem; + gap: 0.4rem; margin: 0; padding: 0; list-style: none; @@ -142,11 +167,51 @@ .observatory-evidence-card { display: grid; - gap: 0.85rem; + grid-template-columns: auto minmax(0, 1fr) auto auto; + align-items: center; + gap: 0.8rem; + min-height: 4.25rem; + padding: 0.7rem 0.85rem; + border-radius: var(--nodedc-radius-control-compact); + background: var(--nodedc-glass-control-bg); } -.observatory-evidence-card__heading, -.observatory-evidence-card__action, +.observatory-evidence-card__icon { + display: grid; + width: 2.15rem; + height: 2.15rem; + place-items: center; + border-radius: var(--nodedc-radius-circle); + background: var(--nodedc-panel-icon-bg); + color: var(--nodedc-text-secondary); +} + +.observatory-evidence-card__copy { + display: grid; + min-width: 0; + gap: 0.16rem; +} + +.observatory-evidence-card__copy strong, +.observatory-evidence-card__copy span, +.observatory-evidence-card__copy small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.observatory-evidence-card__copy span { + color: var(--nodedc-text-secondary); + font-size: var(--nodedc-font-size-sm); +} + +.observatory-evidence-card__copy small { + color: var(--nodedc-text-muted); + font-size: var(--nodedc-font-size-xs); +} + +.observatory-evidence-card__actions, .observatory-replay__header, .observatory-replay-state, .observatory-replay-state__actions { @@ -156,28 +221,17 @@ 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-evidence-card__actions:empty { + display: none; } .observatory-replay, @@ -205,17 +259,6 @@ color: var(--nodedc-text-muted); } -.observatory-evidence-list strong, -.observatory-evidence-list span { - display: block; -} - -.observatory-evidence-list span { - margin-top: 0.25rem; - color: var(--nodedc-text-muted); - font-size: var(--nodedc-font-size-sm); -} - .observatory-evidence-empty { display: grid; min-height: 13rem; @@ -242,6 +285,56 @@ flex: 1 1 auto; } +.observatory-rename-form, +.observatory-delete-confirmation { + display: grid; + gap: 0.75rem; +} + +.observatory-delete-confirmation p, +.observatory-mutation-error { + margin: 0; + line-height: 1.5; +} + +.observatory-mutation-error { + color: rgb(var(--nodedc-danger-rgb)); + font-size: var(--nodedc-font-size-sm); +} + +@container observatory-session (max-width: 56rem) { + .observatory-session-summary { + grid-template-columns: minmax(0, 1fr); + align-items: start; + } + + .observatory-session-summary__end, + .observatory-modalities { + justify-items: start; + justify-content: flex-start; + } +} + +@container observatory-session (max-width: 48rem) { + .observatory-evidence-card { + grid-template-columns: auto minmax(0, 1fr) auto; + } + + .observatory-evidence-card > .nodedc-status { + display: none; + } +} + +@container observatory-session (max-width: 38rem) { + .observatory-session-summary__facts { + grid-template-columns: minmax(0, 1fr); + } + + .observatory-evidence-card__copy small { + white-space: normal; + } +} + @media (max-width: 920px) { .observatory-lead, .observatory-catalog-bar, @@ -251,7 +344,6 @@ flex-direction: column; } - .observatory-evidence-card__action, .observatory-replay__header, .observatory-replay-state { align-items: stretch; @@ -268,7 +360,4 @@ min-width: 0; } - .observatory-session-grid { - grid-template-columns: minmax(0, 1fr); - } } diff --git a/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx b/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx index bd4acb6..5f87834 100644 --- a/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx +++ b/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx @@ -1,11 +1,17 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { flushSync } from "react-dom"; import { ActivityIndicator, Button, + ConfirmationModal, GlassSurface, Icon, + IconButton, Select, StatusBadge, + TextField, + Window, + WindowFooterActions, } from "@nodedc/ui-react"; import { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay"; @@ -15,6 +21,15 @@ import { fetchObservatoryRecordedRunReview, type ObservatoryRecordedRunBinding, } from "../../core/observatory/recordedRun"; +import { + findObservatoryEvidence, + observatoryCatalogConfirmsEvidenceDeletion, + type ObservatoryEvidence, +} from "../../core/observatory/catalog"; +import { + deleteObservatoryLabProjection, + renameObservatoryLabProjection, +} from "../../core/observatory/catalogMutations"; import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog"; import type { WorkspaceDefinition } from "../../productModel"; @@ -24,6 +39,17 @@ type ObservatoryRecordedRunReview = Awaited< ReturnType >; +type ObservatoryMutationReconciliation = + | { + readonly kind: "rename"; + readonly sessionId: string; + readonly displayName: string; + } + | { + readonly kind: "delete"; + readonly sessionId: string; + }; + type ObservatoryReplayState = | { readonly kind: "closed" } | { @@ -94,6 +120,12 @@ function catalogStateLabel( return "Читаем каталог"; } +function mutationErrorMessage(error: unknown): string { + return error instanceof Error && error.message.trim() + ? error.message + : "Не удалось изменить лабораторный результат в Обсерватории."; +} + export function ObservatoryWorkspace({ definition, }: { @@ -102,6 +134,14 @@ export function ObservatoryWorkspace({ const controller = useObservatoryCatalog(); const [selectedSessionId, setSelectedSessionId] = useState(""); const [replay, setReplay] = useState({ kind: "closed" }); + const [renameTarget, setRenameTarget] = useState(null); + const [renameValue, setRenameValue] = useState(""); + const [deleteTarget, setDeleteTarget] = useState(null); + const [mutationPending, setMutationPending] = useState<"rename" | "delete" | null>(null); + const [mutationError, setMutationError] = useState(null); + const [mutationReconciliation, setMutationReconciliation] = useState< + ObservatoryMutationReconciliation | null + >(null); const replayCoordinatorRef = useRef(createObservationReplayCoordinator()); const items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS; @@ -136,16 +176,42 @@ export function ObservatoryWorkspace({ const replayEvidenceId = replay.kind === "closed" ? null : replay.binding.evidenceSessionId; + const replayEvidence = replayEvidenceId === null + ? null + : selectedSession?.evidence.find( + (evidence) => evidence.sessionId === replayEvidenceId, + ) ?? null; useEffect(() => { if ( replayEvidenceId === null - || selectedSession?.evidence.some( - (evidence) => evidence.sessionId === replayEvidenceId, - ) + || replayEvidence ) return; closeReplay(); - }, [closeReplay, replayEvidenceId, selectedSession]); + }, [closeReplay, replayEvidence, replayEvidenceId]); + + useEffect(() => { + if ( + mutationPending !== null + || mutationReconciliation === null + || controller.catalog === null + ) return; + const evidence = findObservatoryEvidence( + controller.catalog, + mutationReconciliation.sessionId, + ); + const confirmed = mutationReconciliation.kind === "rename" + ? evidence?.label === mutationReconciliation.displayName + : observatoryCatalogConfirmsEvidenceDeletion( + controller.catalog, + mutationReconciliation.sessionId, + ); + if (!confirmed) return; + setRenameTarget(null); + setDeleteTarget(null); + setMutationError(null); + setMutationReconciliation(null); + }, [controller.catalog, mutationPending, mutationReconciliation]); const openReplay = useCallback((binding: ObservatoryRecordedRunBinding) => { const attempt = replayCoordinatorRef.current.begin(); @@ -173,6 +239,101 @@ export function ObservatoryWorkspace({ setSelectedSessionId(sessionId); }, [closeReplay]); + const openRename = useCallback((evidence: ObservatoryEvidence) => { + if (!evidence.recordedRun) return; + setDeleteTarget(null); + setMutationError(null); + setMutationReconciliation(null); + setRenameValue(evidence.label); + setRenameTarget(evidence); + }, []); + + const openDelete = useCallback((evidence: ObservatoryEvidence) => { + if (!evidence.recordedRun) return; + setRenameTarget(null); + setMutationError(null); + setMutationReconciliation(null); + setDeleteTarget(evidence); + }, []); + + const submitRename = useCallback(async () => { + if (!renameTarget?.recordedRun || mutationPending !== null) return; + const requestedDisplayName = renameValue.trim(); + const reconciliation: ObservatoryMutationReconciliation = { + kind: "rename", + sessionId: renameTarget.sessionId, + displayName: requestedDisplayName, + }; + setMutationPending("rename"); + setMutationError(null); + setMutationReconciliation(reconciliation); + try { + const result = await renameObservatoryLabProjection( + renameTarget.recordedRun, + renameValue, + ); + controller.applyEvidenceRename(result.sessionId, result.displayName); + setRenameTarget(null); + setMutationReconciliation(null); + void controller.refresh(); + } catch (error) { + const reconciled = await controller.refresh(); + const evidence = reconciled + ? findObservatoryEvidence(reconciled, reconciliation.sessionId) + : null; + if (reconciled && evidence?.label === reconciliation.displayName) { + setRenameTarget(null); + setMutationReconciliation(null); + } else { + setMutationError(mutationErrorMessage(error)); + } + } finally { + setMutationPending(null); + } + }, [controller, mutationPending, renameTarget, renameValue]); + + const confirmDelete = useCallback(async () => { + if (!deleteTarget?.recordedRun || mutationPending !== null) return; + const reconciliation: ObservatoryMutationReconciliation = { + kind: "delete", + sessionId: deleteTarget.sessionId, + }; + setMutationPending("delete"); + setMutationError(null); + setMutationReconciliation(reconciliation); + try { + if ( + replay.kind !== "closed" + && replay.binding.evidenceSessionId === deleteTarget.sessionId + ) { + flushSync(() => { + closeReplay(); + }); + } + await deleteObservatoryLabProjection(deleteTarget.recordedRun); + controller.applyEvidenceDeletion(deleteTarget.sessionId); + setDeleteTarget(null); + setMutationReconciliation(null); + void controller.refresh(); + } catch (error) { + const reconciled = await controller.refresh(); + if ( + reconciled + && observatoryCatalogConfirmsEvidenceDeletion( + reconciled, + reconciliation.sessionId, + ) + ) { + setDeleteTarget(null); + setMutationReconciliation(null); + } else { + setMutationError(mutationErrorMessage(error)); + } + } finally { + setMutationPending(null); + } + }, [closeReplay, controller, deleteTarget, mutationPending, replay]); + return (
После завершения записи источник появится здесь без создания демонстрационных данных.

) : selectedSession ? ( -
- -
-
- ИСХОДНАЯ СЕССИЯ -

{selectedSession.source.label}

-
- - {statusLabel[selectedSession.source.status]} - -
-
-
-
Идентификатор
-
{selectedSession.source.id}
-
+
+ +
+ ИСХОДНАЯ СЕССИЯ +

{selectedSession.source.label}

+ {selectedSession.source.id} +
+
Начало
{formatTimestamp(selectedSession.source.startedAtUtc)}
Длительность
{formatDuration(selectedSession.source.durationSeconds)}
@@ -291,18 +444,23 @@ export function ObservatoryWorkspace({
{selectedSession.source.replayable ? "Доступна" : "Не подготовлена"}
-
- {selectedSession.source.modalities.length > 0 - ? selectedSession.source.modalities.map((modality) => ( - - {modalityLabel[modality] ?? modality} - - )) - : Каналы не зафиксированы} +
+ + {statusLabel[selectedSession.source.status]} + +
+ {selectedSession.source.modalities.length > 0 + ? selectedSession.source.modalities.map((modality) => ( + + {modalityLabel[modality] ?? modality} + + )) + : Каналы не зафиксированы} +
- +
СВЯЗАННЫЕ РЕЗУЛЬТАТЫ @@ -316,53 +474,52 @@ export function ObservatoryWorkspace({
    {presentedEvidence.map((evidence) => (
  1. - -
    -
    - {evidence.lab.labId} - {evidence.label} -
    +
    + +
    + {evidence.lab.labId} + {evidence.label} + + {evidence.lab.resultKind} · {formatTimestamp(evidence.publishedAtUtc)} + +
    + {evidence.recordedRun ? ( + Записанный разбор + ) : null} +
    {evidence.recordedRun ? ( - Записанный разбор + <> + openDelete(evidence)} + > + + + openRename(evidence)} + > + + + openReplay(evidence.recordedRun!)} + > + {replay.kind === "loading" + && replay.binding.evidenceSessionId === evidence.sessionId + ? + : } + + ) : null}
    -
    -
    Тип результата
    {evidence.lab.resultKind}
    -
    -
    Result ID
    -
    {evidence.lab.resultId}
    -
    -
    Опубликован
    {formatTimestamp(evidence.publishedAtUtc)}
    -
    - {evidence.recordedRun ? ( -
    - - Записанный маршрут · единая временная шкала · только наблюдение - - -
    - ) : null} - +
  2. ))}
@@ -382,7 +539,7 @@ export function ObservatoryWorkspace({ {" "}связанных результатов. Полный архив остаётся в legacy LAB.

) : null} - +
) : null} @@ -418,7 +575,7 @@ export function ObservatoryWorkspace({
ВИЗУАЛЬНЫЙ РАЗБОР / ЗАПИСАННАЯ СЕССИЯ -

RAVNOVES004TREE · полный маршрут восприятия

+

{replayEvidence?.label ?? replay.binding.resultId}

Записанный маршрут синхронизирован по общей временной шкале.

+ + + )} + > +
{ + event.preventDefault(); + void submitRename(); + }} + > + setRenameValue(event.currentTarget.value)} + /> + {mutationError && renameTarget ? ( +

{mutationError}

+ ) : null} + + + + +

+ Будут удалены только каталожная проекция {deleteTarget.label} + {" "}и её отображаемое название в Обсерватории. +

+

+ Исходная сессия, запечатанный лабораторный результат и файлы доказательств + останутся неизменными. +

+ {mutationError ? ( +

{mutationError}

+ ) : null} +
+ ) : null} + confirmLabel="Удалить из Обсерватории" + pendingLabel="Удаляем…" + danger + onClose={() => { + if (mutationPending === "delete") return; + setDeleteTarget(null); + setMutationError(null); + setMutationReconciliation(null); + }} + onConfirm={confirmDelete} + /> ); } diff --git a/apps/control-station/test/m4ReplayThreat.test.mjs b/apps/control-station/test/m4ReplayThreat.test.mjs index d31aa8f..dd96a18 100644 --- a/apps/control-station/test/m4ReplayThreat.test.mjs +++ b/apps/control-station/test/m4ReplayThreat.test.mjs @@ -886,8 +886,8 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async () assert.match(canonical, /primary=\{mediaPane\}/); assert.match(canonical, /secondary=\{spatialPane/); assert.match(canonical, /primarySize=\{splitView \? splitPrimarySize : mediaMode !== "none" \? 100 : 0\}/); - assert.match(canonical, /resizable=\{splitView\}/); - assert.match(canonical, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/); + assert.match(canonical, /resizable=\{splitView && !unifiedContent\}/); + assert.match(canonical, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/); assert.match(canonical, /secondaryMode=\{\{/); assert.match(visual, /playback=\{playbackController\.playback\}/); assert.match(visual, /clock: "external"/); diff --git a/apps/control-station/test/observatoryCatalog.test.mjs b/apps/control-station/test/observatoryCatalog.test.mjs index b656312..aa03c0c 100644 --- a/apps/control-station/test/observatoryCatalog.test.mjs +++ b/apps/control-station/test/observatoryCatalog.test.mjs @@ -4,8 +4,12 @@ import { after, before, test } from "node:test"; import { createServer } from "vite"; let server; +let applyObservatoryCatalogMutationOverlay; let buildObservatoryCatalog; let fetchObservatoryCatalog; +let findObservatoryEvidence; +let observatoryCatalogConfirmsEvidenceDeletion; +let reconcileObservatoryCatalogMutationOverlay; let ObservatoryCatalogContractError; before(async () => { @@ -15,8 +19,12 @@ before(async () => { server: { middlewareMode: true }, }); ({ + applyObservatoryCatalogMutationOverlay, buildObservatoryCatalog, fetchObservatoryCatalog, + findObservatoryEvidence, + observatoryCatalogConfirmsEvidenceDeletion, + reconcileObservatoryCatalogMutationOverlay, ObservatoryCatalogContractError, } = await server.ssrLoadModule("/src/core/observatory/catalog.ts")); }); @@ -233,3 +241,134 @@ test("Observatory projects a typed canonical run only through its exact sourceSe activation: "explicit", }); }); + +test("Observatory applies an exact rename locally without mutating canonical identity", () => { + const catalog = buildObservatoryCatalog( + [source("source", "2026-08-29T10:00:00Z")], + [evidence("result", "source", "2026-08-29T11:00:00Z")], + ); + const original = catalog.items[0].evidence[0]; + const projected = applyObservatoryCatalogMutationOverlay( + catalog, + new Map([["result", { + kind: "rename", + displayName: "Операторское имя", + revision: 1, + }]]), + ); + const renamed = findObservatoryEvidence(projected, "result"); + + assert.equal(renamed.label, "Операторское имя"); + assert.equal(renamed.sessionId, original.sessionId); + assert.equal(renamed.lab, original.lab); + assert.equal(renamed.recordedRun, original.recordedRun); + assert.equal(projected.items[0].source, catalog.items[0].source); + assert.equal(projected.window.laboratoryCount, 1); +}); + +test("Observatory tombstone removes only its catalog evidence projection", () => { + const catalog = buildObservatoryCatalog( + [source("source", "2026-08-29T10:00:00Z")], + [ + evidence("remove", "source", "2026-08-29T12:00:00Z"), + evidence("keep", "source", "2026-08-29T11:00:00Z"), + ], + ); + const projected = applyObservatoryCatalogMutationOverlay( + catalog, + new Map([["remove", { kind: "delete", revision: 1 }]]), + ); + + assert.equal(projected.items.length, 1); + assert.equal(projected.items[0].source, catalog.items[0].source); + assert.deepEqual( + projected.items[0].evidence.map(({ sessionId }) => sessionId), + ["keep"], + ); + assert.equal(projected.window.sourceCount, 1); + assert.equal(projected.window.laboratoryCount, 1); +}); + +test("Observatory confirms an ambiguous delete only from a complete LAB window", () => { + const bounded = buildObservatoryCatalog( + [source("source", "2026-08-29T10:00:00Z")], + [evidence("other", "source", "2026-08-29T11:00:00Z")], + 1, + ); + const complete = buildObservatoryCatalog( + [source("source", "2026-08-29T10:00:00Z")], + [], + ); + + assert.equal(observatoryCatalogConfirmsEvidenceDeletion(bounded, "result"), false); + assert.equal(observatoryCatalogConfirmsEvidenceDeletion(complete, "result"), true); +}); + +test("Observatory reconciliation cannot let a stale in-flight fetch undo a local mutation", () => { + const oldCatalog = buildObservatoryCatalog( + [source("source", "2026-08-29T10:00:00Z")], + [evidence("result", "source", "2026-08-29T11:00:00Z")], + ); + const renameOverlay = new Map([["result", { + kind: "rename", + displayName: "Новое имя", + revision: 1, + }]]); + + const staleRename = reconcileObservatoryCatalogMutationOverlay( + oldCatalog, + renameOverlay, + 0, + ); + assert.equal(findObservatoryEvidence(staleRename.catalog, "result").label, "Новое имя"); + assert.equal(staleRename.overlay.has("result"), true); + + const reconciledOldRename = reconcileObservatoryCatalogMutationOverlay( + oldCatalog, + renameOverlay, + 1, + ); + assert.equal(findObservatoryEvidence(reconciledOldRename.catalog, "result").label, "Новое имя"); + assert.equal(reconciledOldRename.overlay.has("result"), true); + + const renamedEvidence = evidence("result", "source", "2026-08-29T11:00:00Z"); + renamedEvidence.label = "Новое имя"; + const confirmedRename = reconcileObservatoryCatalogMutationOverlay( + buildObservatoryCatalog( + [source("source", "2026-08-29T10:00:00Z")], + [renamedEvidence], + ), + staleRename.overlay, + 1, + ); + assert.equal(findObservatoryEvidence(confirmedRename.catalog, "result").label, "Новое имя"); + assert.equal(confirmedRename.overlay.size, 0); + + const deleteOverlay = new Map([["result", { kind: "delete", revision: 2 }]]); + const staleDelete = reconcileObservatoryCatalogMutationOverlay( + oldCatalog, + deleteOverlay, + 1, + ); + assert.equal(findObservatoryEvidence(staleDelete.catalog, "result"), null); + assert.equal(staleDelete.overlay.has("result"), true); + + const reconciledOldDelete = reconcileObservatoryCatalogMutationOverlay( + oldCatalog, + deleteOverlay, + 2, + ); + assert.equal(findObservatoryEvidence(reconciledOldDelete.catalog, "result"), null); + assert.equal(reconciledOldDelete.overlay.has("result"), true); + + const confirmedDelete = reconcileObservatoryCatalogMutationOverlay( + buildObservatoryCatalog( + [source("source", "2026-08-29T10:00:00Z")], + [], + ), + staleDelete.overlay, + 2, + ); + assert.equal(findObservatoryEvidence(confirmedDelete.catalog, "result"), null); + assert.equal(confirmedDelete.overlay.size, 0); +}); diff --git a/apps/control-station/test/observatoryCatalogMutations.test.mjs b/apps/control-station/test/observatoryCatalogMutations.test.mjs new file mode 100644 index 0000000..aeaf9e3 --- /dev/null +++ b/apps/control-station/test/observatoryCatalogMutations.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { createServer } from "vite"; + +let server; +let deleteObservatoryLabProjection; +let renameObservatoryLabProjection; +let ObservatoryCatalogMutationError; + +before(async () => { + server = await createServer({ + appType: "custom", + logLevel: "silent", + server: { middlewareMode: true }, + }); + ({ + deleteObservatoryLabProjection, + renameObservatoryLabProjection, + ObservatoryCatalogMutationError, + } = await server.ssrLoadModule("/src/core/observatory/catalogMutations.ts")); +}); + +after(async () => { + await server?.close(); +}); + +const sessionId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`; +const binding = { + kind: "canonical-recorded-rerun", + evidenceSessionId: sessionId, + sourceSessionId: "20260828T130511Z_viewer_live", + resultId: sessionId, + viewerProfile: "recorded-session", + timeline: "session_time", + activation: "explicit", +}; + +test("Observatory rename sends the closed projection-only contract", async () => { + const calls = []; + const result = await renameObservatoryLabProjection(binding, " Новый разбор ", { + fetcher: async (input, init) => { + calls.push({ input, init }); + return Response.json({ + schema_version: "missioncore.observatory-lab-projection/v1", + session_id: sessionId, + display_name: "Новый разбор", + }); + }, + }); + assert.deepEqual(result, { + schemaVersion: "missioncore.observatory-lab-projection/v1", + sessionId, + displayName: "Новый разбор", + }); + assert.equal(calls.length, 1); + assert.equal(calls[0].input, `/api/v1/observatory/lab-projections/${sessionId}`); + assert.equal(calls[0].init.method, "PATCH"); + assert.equal(calls[0].init.headers.Accept, "application/json"); + assert.equal(calls[0].init.headers["Content-Type"], "application/json"); + assert.deepEqual(JSON.parse(calls[0].init.body), { + schema_version: "missioncore.observatory-lab-projection-rename/v1", + display_name: "Новый разбор", + }); +}); + +test("Observatory delete accepts only an empty 204 response", async () => { + const calls = []; + await deleteObservatoryLabProjection(binding, { + fetcher: async (input, init) => { + calls.push({ input, init }); + return new Response(null, { status: 204 }); + }, + }); + assert.equal(calls.length, 1); + assert.equal(calls[0].input, `/api/v1/observatory/lab-projections/${sessionId}`); + assert.equal(calls[0].init.method, "DELETE"); + await assert.rejects( + deleteObservatoryLabProjection(binding, { + fetcher: async () => new Response("unexpected", { status: 200 }), + }), + ObservatoryCatalogMutationError, + ); +}); + +test("Observatory mutations fail closed before network access", async () => { + let calls = 0; + const fetcher = async () => { + calls += 1; + return new Response(null, { status: 204 }); + }; + await assert.rejects( + renameObservatoryLabProjection(binding, " ", { fetcher }), + /от 1 до 160/, + ); + await assert.rejects( + deleteObservatoryLabProjection({ ...binding, resultId: "different" }, { fetcher }), + /не допущен/, + ); + assert.equal(calls, 0); +}); + +test("Observatory mutations reject response drift and preserve API detail", async () => { + await assert.rejects( + renameObservatoryLabProjection(binding, "Разбор", { + fetcher: async () => Response.json({ + schema_version: "missioncore.observatory-lab-projection/v1", + session_id: sessionId, + display_name: "Разбор", + unexpected: true, + }), + }), + /неизвестные поля/, + ); + await assert.rejects( + deleteObservatoryLabProjection(binding, { + fetcher: async () => Response.json({ detail: "Проекция не принадлежит Обсерватории." }, { status: 409 }), + }), + (error) => error instanceof ObservatoryCatalogMutationError + && error.status === 409 + && error.message === "Проекция не принадлежит Обсерватории.", + ); +}); diff --git a/apps/control-station/test/observatoryWorkspace.test.mjs b/apps/control-station/test/observatoryWorkspace.test.mjs index e7dac44..e2ca2ea 100644 --- a/apps/control-station/test/observatoryWorkspace.test.mjs +++ b/apps/control-station/test/observatoryWorkspace.test.mjs @@ -81,12 +81,22 @@ test("Observatory mounts the one shared canonical replay only after explicit adm assert.match(workspace, /вне текущего загруженного среза/); assert.match(workspace, /observatory-notice__copy/); assert.match(workspace, /observatory-evidence-card/); + assert.match(workspace, /observatory-session-stack/); + assert.match(workspace, /observatory-session-summary__facts/); + assert.match(workspace, /observatory-evidence-card__copy/); + assert.match( + workspace, + /evidence\.recordedRun \? \([\s\S]*name="trash"[\s\S]*name="edit"[\s\S]*Открыть визуальный разбор:[\s\S]*name="eye"/, + ); + assert.doesNotMatch(workspace, /observatory-evidence-card[^>]*tone="soft"/); assert.match(workspace, /Открыть визуальный разбор/); assert.match(workspace, /replay\.kind === "ready"[\s\S]*\{replayEvidence\?\.label \?\? replay\.binding\.resultId\}<\/h3>/); + assert.doesNotMatch(workspace, /RAVNOVES004TREE · полный маршрут восприятия/); assert.match(workspace, /const selectSession[\s\S]*closeReplay\(\);[\s\S]*setSelectedSessionId/); assert.match(workspace, /data-observatory-authority="observation-only"/); assert.doesNotMatch(workspace, /Нарушена связь|compactIdentity/); @@ -112,5 +122,70 @@ test("Observatory mounts the one shared canonical replay only after explicit adm styles, /@media \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar__controls \.nodedc-select-anchor \{[\s\S]*flex: 0 0 auto;/, ); + assert.match( + styles, + /\.observatory-session-summary \{[\s\S]*grid-template-columns:[\s\S]*\.observatory-evidence-card \{[\s\S]*background: var\(--nodedc-glass-control-bg\);/, + ); + assert.match( + styles, + /\.observatory-session-stack \{[\s\S]*container-name: observatory-session;[\s\S]*container-type: inline-size;/, + ); + assert.match( + styles, + /@container observatory-session \(max-width: 56rem\) \{[\s\S]*\.observatory-session-summary \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/, + ); + assert.match( + styles, + /@container observatory-session \(max-width: 38rem\) \{[\s\S]*\.observatory-session-summary__facts \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/, + ); + assert.doesNotMatch(styles, /\.observatory-evidence-card[\s\S]*background:\s*(?:#0{3,6}|black|rgb\(0[ ,])/i); assert.doesNotMatch(styles, /nodedc-glass-surface|nodedc-status-badge/); }); + +test("Observatory rename and delete use admitted projection mutations and canonical windows", async () => { + const [workspace, hook] = await Promise.all([ + read("workspaces/observatory/ObservatoryWorkspace.tsx"), + read("core/observatory/useObservatoryCatalog.ts"), + ]); + + assert.match( + workspace, + /deleteObservatoryLabProjection,[\s\S]*renameObservatoryLabProjection,[\s\S]*from "\.\.\/\.\.\/core\/observatory\/catalogMutations"/, + ); + assert.match(workspace, /[\s\S]*Сохранить/); + assert.match(workspace, /= 0 && deleteFlowEnd > deleteFlowStart); + const deleteFlow = workspace.slice(deleteFlowStart, deleteFlowEnd); + const teardown = deleteFlow.indexOf("closeReplay();"); + const remove = deleteFlow.indexOf("await deleteObservatoryLabProjection"); + const tombstone = deleteFlow.indexOf("controller.applyEvidenceDeletion"); + const refresh = deleteFlow.indexOf("void controller.refresh();"); + assert.ok(teardown >= 0 && teardown < remove, "active replay must unmount before projection delete"); + assert.ok(remove < tombstone, "local tombstone must follow the exact empty 204"); + assert.ok(tombstone < refresh, "reconciliation refresh must follow the local tombstone"); + assert.match(deleteFlow, /flushSync\(\(\) => \{[\s\S]*closeReplay\(\);[\s\S]*\}\);/); + assert.match( + deleteFlow, + /catch \(error\) \{[\s\S]*const reconciled = await controller\.refresh\(\);[\s\S]*observatoryCatalogConfirmsEvidenceDeletion\([\s\S]*setDeleteTarget\(null\);/, + ); + assert.match(workspace, /mutationError[\s\S]*role="alert"/); + assert.match(hook, /activeRequestRef\.current\?\.controller\.abort\(\)/); + assert.match(hook, /requestSequenceRef\.current !== id/); + assert.match(hook, /const requestMutationRevision = mutationRevisionRef\.current/); + assert.match(hook, /reconcileObservatoryCatalogMutationOverlay\(/); + assert.match(hook, /applyObservatoryCatalogMutationOverlay\(/); +}); diff --git a/apps/control-station/test/vegetationShadow.test.mjs b/apps/control-station/test/vegetationShadow.test.mjs index 8ea884e..6188a70 100644 --- a/apps/control-station/test/vegetationShadow.test.mjs +++ b/apps/control-station/test/vegetationShadow.test.mjs @@ -452,7 +452,7 @@ test("canonical recorded LAB spatial frame keeps source, SLAM and body identity }); test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival review separate", async () => { - const [resultSource, benchmarkSource, m49Source, canonicalSource, rerunSource] = await Promise.all([ + const [resultSource, benchmarkSource, m49Source, canonicalSource, rerunSource, replayStyles] = await Promise.all([ readFile( new URL("../src/workspaces/laboratory/VegetationShadowResult.tsx", import.meta.url), "utf8", @@ -473,6 +473,10 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r new URL("../src/components/laboratory/CanonicalVegetationRerunReplay.tsx", import.meta.url), "utf8", ), + readFile( + new URL("../src/styles/m4-replay-threat.css", import.meta.url), + "utf8", + ), ]); assert.doesNotMatch(resultSource, /M48MaskComparisonVisual/); assert.match(resultSource, /M49TgsFullShadowEvidence/); @@ -493,6 +497,34 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r assert.match(rerunSource, /resolveCanonicalLabReplay/); assert.doesNotMatch(rerunSource, /canonical-overlay\.rrd/); assert.match(rerunSource, /unifiedPerception: splitView/); + assert.match(rerunSource, /lockPerceptionCameraInteraction: mediaMode !== null/); + assert.match(rerunSource, /!splitView[\s\S]*event\.button !== 0/); + assert.match(rerunSource, /if \(!splitView\) stopNativeSplitTracking\(\)/); + assert.match(rerunSource, /event\.target instanceof HTMLCanvasElement/); + assert.match(rerunSource, /--canonical-rerun-camera-pane/); + assert.match(rerunSource, /onPointerDownCapture=\{splitView \? trackNativeSplit : undefined\}/); + assert.match(rerunSource, /data-split-view=\{splitView \? "true" : undefined\}/); + assert.match(rerunSource, /mediaMode === null[\s\S]*\? 0[\s\S]*: splitView[\s\S]*\? nativeSplitPercentRef\.current[\s\S]*: 100/); + assert.match(rerunSource, /isRecordedPlaybackPresentationReady\(viewerStatus, playback\)/); + assert.match(rerunSource, /data-presentation-state=\{presentationState\}/); + assert.match(rerunSource, / str: + """Set only an operator-facing alias on one typed LAB projection. + + The immutable observation summary keeps the canonical publication name + so a strict publisher retry can still validate the exact sealed + projection. The alias affects only catalog presentation. + """ + + _validate_identifier(session_id, "LAB session id") + normalized_name = _normalize_operator_display_name(display_name) + with self._lock, self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + summary, _lab = _require_capability_owned_lab_projection( + connection, + session_id, + ) + override = ( + None + if normalized_name == summary["display_name"] + else normalized_name + ) + updated = connection.execute( + "UPDATE observation_lab_instances SET operator_display_name = ? " + "WHERE session_id = ?", + (override, session_id), + ).rowcount + if updated != 1: + connection.rollback() + raise SessionNotFoundError("observation session was not found") + connection.commit() + return normalized_name + + def delete_capability_lab_projection(self, session_id: str) -> None: + """Delete only one typed LAB catalog projection and its copied rows. + + No evidence locator, source session, prepared recording, or viewer + cache is inspected or removed by this operation. + """ + + _validate_identifier(session_id, "LAB session id") + with self._lock, self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + _require_capability_owned_lab_projection(connection, session_id) + deleted = connection.execute( + "DELETE FROM observation_sessions WHERE session_id = ?", + (session_id,), + ).rowcount + if deleted != 1: + connection.rollback() + raise SessionNotFoundError("observation session was not found") + connection.commit() + def delete_session(self, session_id: str) -> None: """Permanently delete one exact catalogued evidence directory and row.""" @@ -931,6 +1002,11 @@ class SessionStore: "ADD COLUMN include_recorded_media INTEGER " "CHECK (include_recorded_media IN (0, 1))" ) + if "operator_display_name" not in lab_columns: + connection.execute( + "ALTER TABLE observation_lab_instances " + "ADD COLUMN operator_display_name TEXT" + ) _migrate_canonical_replay_capabilities(connection) connection.commit() with _ignore_os_error(): @@ -1583,16 +1659,87 @@ def _canonical_rolling_capability( return None +def _normalize_operator_display_name(value: str) -> str: + if not isinstance(value, str): + raise ValueError("LAB display name must be text") + normalized = value.strip() + if not 1 <= len(normalized) <= 160 or any( + ord(character) < 32 for character in normalized + ): + raise ValueError("LAB display name must contain 1..160 printable characters") + return normalized + + +def _operator_display_name_from_row(value: object) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise SessionIntegrityError("stored LAB operator display name is invalid") + try: + return _normalize_operator_display_name(value) + except ValueError as exc: + raise SessionIntegrityError("stored LAB operator display name is invalid") from exc + + +def _require_capability_owned_lab_projection( + connection: sqlite3.Connection, + session_id: str, +) -> tuple[sqlite3.Row, sqlite3.Row]: + summary = connection.execute( + "SELECT * FROM observation_sessions WHERE session_id = ?", + (session_id,), + ).fetchone() + if summary is None: + raise SessionNotFoundError("observation session was not found") + lab = connection.execute( + "SELECT * FROM observation_lab_instances WHERE session_id = ?", + (session_id,), + ).fetchone() + if lab is None: + raise SessionIntegrityError( + "observation session is not a capability-owned LAB projection" + ) + binding = _lab_binding_from_row(lab) + if ( + summary["archive_id"] != LAB_ARCHIVE_ID + or summary["origin"] != LAB_ORIGIN + or binding.replay_capability is None + or binding.session_id != session_id + or binding.source_session_id == session_id + or lab["include_recorded_media"] not in {0, 1} + or connection.execute( + "SELECT 1 FROM observation_lab_instances WHERE session_id = ?", + (binding.source_session_id,), + ).fetchone() + is not None + ): + raise SessionIntegrityError( + "observation session is not a capability-owned LAB projection" + ) + _operator_display_name_from_row(lab["operator_display_name"]) + _validate_existing_lab_projection( + connection, + session_id=session_id, + source_session_id=binding.source_session_id, + display_name=summary["display_name"], + run_created_at_utc=binding.run_created_at_utc, + duration_seconds=summary["duration_seconds"], + include_recorded_media=bool(lab["include_recorded_media"]), + ) + return summary, lab + + def _summary_from_row( row: sqlite3.Row, *, lab: LabSessionBinding | None = None, + operator_display_name: str | None = None, ) -> SessionSummary: raw_modalities = json.loads(row["modalities_json"]) modalities = tuple(cast(SessionModality, value) for value in raw_modalities) return SessionSummary( session_id=row["session_id"], - display_name=row["display_name"], + display_name=operator_display_name or row["display_name"], status=cast(SessionStatus, row["status"]), started_at_utc=row["started_at_utc"], completed_at_utc=row["completed_at_utc"], @@ -1646,6 +1793,8 @@ def _serialize_replay_capability(value: LabReplayCapability | None) -> str | Non def _replay_capability_from_row(value: object) -> LabReplayCapability | None: if value is None: return None + if not isinstance(value, str | bytes | bytearray): + raise SessionIntegrityError("stored LAB replay capability is invalid") try: document = json.loads(value) except (TypeError, json.JSONDecodeError) as exc: diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py index b3fc939..53f5148 100644 --- a/src/k1link/web/app.py +++ b/src/k1link/web/app.py @@ -144,6 +144,7 @@ from k1link.web.map_api import ( build_map_router, ) from k1link.web.map_view_api import build_map_view_router +from k1link.web.observatory_api import build_observatory_router from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError from k1link.web.plugin_runtime import ( STATE_READ_ACTION_ID, @@ -691,6 +692,7 @@ app.include_router( point_color_renderers=plugin_environment.point_color_renderers, ) ) +app.include_router(build_observatory_router(session_store)) app.include_router( build_environment_router(root_provider=lambda: session_store.data_dir / "ui-environment") ) diff --git a/src/k1link/web/observatory_api.py b/src/k1link/web/observatory_api.py new file mode 100644 index 0000000..65dfd4f --- /dev/null +++ b/src/k1link/web/observatory_api.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from typing import Literal + +from fastapi import APIRouter, HTTPException, Response +from pydantic import BaseModel, ConfigDict, Field + +from k1link.sessions import SessionIntegrityError, SessionNotFoundError, SessionStore + +OBSERVATORY_PROJECTION_SCHEMA: Literal[ + "missioncore.observatory-lab-projection/v1" +] = "missioncore.observatory-lab-projection/v1" +OBSERVATORY_RENAME_SCHEMA: Literal[ + "missioncore.observatory-lab-projection-rename/v1" +] = "missioncore.observatory-lab-projection-rename/v1" + + +class _StrictApiModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class ObservatoryProjectionRenameRequest(_StrictApiModel): + schema_version: Literal[ + "missioncore.observatory-lab-projection-rename/v1" + ] + display_name: str = Field(min_length=1, max_length=160) + + +class ObservatoryProjectionDocument(_StrictApiModel): + schema_version: Literal["missioncore.observatory-lab-projection/v1"] + session_id: str + display_name: str + + +def build_observatory_router(store: SessionStore) -> APIRouter: + """Build bounded catalog-only mutations for typed Observatory projections.""" + + router = APIRouter(tags=["observatory"]) + + @router.patch( + "/api/v1/observatory/lab-projections/{session_id}", + response_model=ObservatoryProjectionDocument, + ) + def rename_observatory_lab_projection( + session_id: str, + request: ObservatoryProjectionRenameRequest, + ) -> ObservatoryProjectionDocument: + try: + display_name = store.rename_capability_lab_projection( + session_id, + request.display_name, + ) + except SessionNotFoundError as exc: + raise HTTPException( + status_code=404, + detail="Проекция Обсерватории не найдена.", + ) from exc + except SessionIntegrityError as exc: + raise HTTPException( + status_code=409, + detail="Запись не является управляемой проекцией Обсерватории.", + ) from exc + except ValueError as exc: + raise HTTPException( + status_code=422, + detail="Некорректные параметры проекции Обсерватории.", + ) from exc + return ObservatoryProjectionDocument( + schema_version=OBSERVATORY_PROJECTION_SCHEMA, + session_id=session_id, + display_name=display_name, + ) + + @router.delete( + "/api/v1/observatory/lab-projections/{session_id}", + status_code=204, + ) + def delete_observatory_lab_projection(session_id: str) -> Response: + try: + store.delete_capability_lab_projection(session_id) + except SessionNotFoundError as exc: + raise HTTPException( + status_code=404, + detail="Проекция Обсерватории не найдена.", + ) from exc + except SessionIntegrityError as exc: + raise HTTPException( + status_code=409, + detail="Запись не является управляемой проекцией Обсерватории.", + ) from exc + except ValueError as exc: + raise HTTPException( + status_code=422, + detail="Некорректный идентификатор проекции Обсерватории.", + ) from exc + return Response(status_code=204) + + return router diff --git a/tests/test_session_api.py b/tests/test_session_api.py index a7a6f7a..e101c24 100644 --- a/tests/test_session_api.py +++ b/tests/test_session_api.py @@ -38,6 +38,7 @@ from k1link.sessions.media import _manifest_generation_sha256 from k1link.viewer.recorded import recorded_blueprint_rrd from k1link.viewer.rerun_bridge import RerunSceneSettings from k1link.web.camera_archive import CameraArchiveWriter +from k1link.web.observatory_api import build_observatory_router from k1link.web.session_api import ( LayoutPutRequest, RecordedBlueprintRequest, @@ -422,6 +423,141 @@ def test_session_router_rolls_capability_projections_out_only_in_v2( ).status_code == 422 +def test_observatory_projection_api_renames_alias_and_deletes_only_projection( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repository = tmp_path / "repo" + sessions = repository / "sessions" + source = make_legacy_session(sessions, "20260716T205632Z_viewer_live") + source_payload = source / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt" + source_payload_sha256 = hashlib.sha256(source_payload.read_bytes()).hexdigest() + store = SessionStore(repository, data_dir=tmp_path / "data") + store.reconcile_archive(xgrids_k1_archive_source(sessions)) + source_snapshot = store.get_session_with_catalog_snapshot(source.name)[1] + capability = LabReplayCapability( + schema_version="missioncore.observation-lab-replay-capability/v1", + kind="canonical-recorded-rerun", + viewer_profile="recorded-session", + timeline="session_time", + activation="explicit", + commands_enabled=False, + ) + projection = store.publish_lab_instance( + session_id="lab-observatory-api-projection", + source_session_id=source.name, + display_name="LAB V1 · canonical API projection", + lab_id="LAB V1", + result_kind="recorded-perception-qualification", + result_id="lab-v1-vegetation-shadow-" + "8" * 64, + source_result_id="lab-v1-vegetation-shadow-" + "9" * 64, + run_created_at_utc="2026-08-29T18:05:11.329061+00:00", + include_recorded_media=False, + replay_capability=capability, + provenance={ + "replay_capability": capability.as_dict(), + "method": lab_method(), + }, + ) + cache_sentinel = store.data_dir / "recordings" / projection.session_id / "cached.rrd" + cache_sentinel.parent.mkdir(parents=True) + cache_sentinel.write_bytes(b"must-not-be-touched") + monkeypatch.setattr( + store, + "delete_session", + lambda _session_id: pytest.fail( + "Observatory router must not enter generic evidence/cache deletion" + ), + ) + application = FastAPI() + application.include_router(build_observatory_router(store)) + client = TestClient(application) + url = f"/api/v1/observatory/lab-projections/{projection.session_id}" + + assert client.patch( + url, + json={ + "schema_version": "missioncore.observatory-lab-projection-rename/v0", + "display_name": "Неверная версия", + }, + ).status_code == 422 + assert client.patch( + url, + json={ + "schema_version": "missioncore.observatory-lab-projection-rename/v1", + "display_name": "Новый разбор", + "unexpected": True, + }, + ).status_code == 422 + assert client.patch( + url, + json={ + "schema_version": "missioncore.observatory-lab-projection-rename/v1", + "display_name": " ", + }, + ).status_code == 422 + + renamed = client.patch( + url, + json={ + "schema_version": "missioncore.observatory-lab-projection-rename/v1", + "display_name": " Маршрут у школы ", + }, + ) + assert renamed.status_code == 200 + assert renamed.json() == { + "schema_version": "missioncore.observatory-lab-projection/v1", + "session_id": projection.session_id, + "display_name": "Маршрут у школы", + } + assert store.get_session(projection.session_id).summary.display_name == "Маршрут у школы" + + deleted = client.delete(url) + assert deleted.status_code == 204 + assert deleted.content == b"" + assert client.delete(url).status_code == 404 + assert store.get_session_with_catalog_snapshot(source.name)[1] == source_snapshot + assert hashlib.sha256(source_payload.read_bytes()).hexdigest() == source_payload_sha256 + assert source.is_dir() + assert cache_sentinel.read_bytes() == b"must-not-be-touched" + + +def test_observatory_projection_api_rejects_source_and_legacy_lab( + tmp_path: Path, +) -> None: + repository = tmp_path / "repo" + sessions = repository / "sessions" + source = make_legacy_session(sessions, "20260716T205632Z_viewer_live") + store = SessionStore(repository, data_dir=tmp_path / "data") + store.reconcile_archive(xgrids_k1_archive_source(sessions)) + legacy = store.publish_lab_instance( + session_id="lab-observatory-api-legacy", + source_session_id=source.name, + display_name="LAB E21 · legacy API row", + lab_id="LAB E21", + result_kind="e21-realtime-envelope", + result_id="e21-realtime-envelope-" + "a" * 64, + run_created_at_utc="2026-07-23T15:55:15.548Z", + provenance={"method": lab_method()}, + ) + application = FastAPI() + application.include_router(build_observatory_router(store)) + client = TestClient(application) + rename_document = { + "schema_version": "missioncore.observatory-lab-projection-rename/v1", + "display_name": "Нельзя изменить", + } + + for session_id in (source.name, legacy.session_id): + url = f"/api/v1/observatory/lab-projections/{session_id}" + assert client.patch(url, json=rename_document).status_code == 409 + assert client.delete(url).status_code == 409 + + assert source.is_dir() + assert store.get_session(source.name).summary.session_id == source.name + assert store.get_session(legacy.session_id).summary.display_name == "LAB E21 · legacy API row" + + def test_delete_session_removes_evidence_and_cache_but_refuses_an_open_recording( tmp_path: Path, ) -> None: diff --git a/tests/test_session_store.py b/tests/test_session_store.py index d62422b..74c278e 100644 --- a/tests/test_session_store.py +++ b/tests/test_session_store.py @@ -907,6 +907,9 @@ def test_lab_replay_capability_column_migrates_existing_catalog( connection.execute( "ALTER TABLE observation_lab_instances DROP COLUMN include_recorded_media" ) + connection.execute( + "ALTER TABLE observation_lab_instances DROP COLUMN operator_display_name" + ) connection.commit() migrated = SessionStore(repository, data_dir=data_dir) @@ -919,6 +922,7 @@ def test_lab_replay_capability_column_migrates_existing_catalog( } assert "replay_capability_json" in columns assert "include_recorded_media" in columns + assert "operator_display_name" in columns assert migrated.get_lab_instance(legacy.session_id).replay_capability is None @@ -1248,6 +1252,265 @@ def test_lab_instance_persists_typed_explicit_recorded_replay_capability( store.get_lab_instance(binding.session_id) +def test_capability_projection_rename_is_only_an_operator_alias( + tmp_path: Path, +) -> None: + repository = tmp_path / "repo" + sessions = repository / "sessions" + source = make_legacy_session(sessions, "20260716T205632Z_viewer_live") + source_payload = source / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt" + source_payload_sha256 = hashlib.sha256(source_payload.read_bytes()).hexdigest() + store = SessionStore(repository, data_dir=tmp_path / "data") + store.reconcile_archive(xgrids_k1_archive_source(sessions)) + _source_detail, source_snapshot = store.get_session_with_catalog_snapshot(source.name) + capability = LabReplayCapability( + schema_version="missioncore.observation-lab-replay-capability/v1", + kind="canonical-recorded-rerun", + viewer_profile="recorded-session", + timeline="session_time", + activation="explicit", + commands_enabled=False, + ) + parameters = { + "session_id": "lab-recorded-operator-alias", + "source_session_id": source.name, + "display_name": "LAB V1 · canonical recorded replay", + "lab_id": "LAB V1", + "result_kind": "recorded-perception-qualification", + "result_id": "lab-v1-vegetation-shadow-" + "a" * 64, + "source_result_id": "lab-v1-vegetation-shadow-" + "b" * 64, + "run_created_at_utc": "2026-08-29T18:05:11.329061+00:00", + "include_recorded_media": False, + "replay_capability": capability, + "provenance": { + "replay_capability": capability.as_dict(), + "method": lab_method(), + }, + } + binding = store.publish_lab_instance(**parameters) + immutable_binding = store.get_lab_instance(binding.session_id) + _projection_detail, projection_snapshot = store.get_session_with_catalog_snapshot( + binding.session_id + ) + + assert store.rename_capability_lab_projection( + binding.session_id, + " Маршрут у школы ", + ) == "Маршрут у школы" + + assert store.get_session(binding.session_id).summary.display_name == "Маршрут у школы" + assert ( + store.list_recent(scope="laboratory").items[0].display_name + == "Маршрут у школы" + ) + assert store.get_lab_instance(binding.session_id) == immutable_binding + assert store.get_session_with_catalog_snapshot(source.name)[1] == source_snapshot + assert ( + store.get_session_with_catalog_snapshot(binding.session_id)[1] + == projection_snapshot + ) + assert hashlib.sha256(source_payload.read_bytes()).hexdigest() == source_payload_sha256 + with sqlite3.connect(store.database_path) as connection: + row = connection.execute( + "SELECT sessions.display_name, lab.operator_display_name " + "FROM observation_sessions AS sessions " + "JOIN observation_lab_instances AS lab USING (session_id) " + "WHERE sessions.session_id = ?", + (binding.session_id,), + ).fetchone() + assert row == ("LAB V1 · canonical recorded replay", "Маршрут у школы") + + # A strict publisher retry still sees the untouched canonical name and + # immutable provenance, even while the catalog presents the alias. + assert store.publish_lab_instance(**parameters) == binding + assert store.get_session(binding.session_id).summary.display_name == "Маршрут у школы" + assert store.rename_capability_lab_projection( + binding.session_id, + parameters["display_name"], + ) == parameters["display_name"] + with sqlite3.connect(store.database_path) as connection: + override = connection.execute( + "SELECT operator_display_name FROM observation_lab_instances " + "WHERE session_id = ?", + (binding.session_id,), + ).fetchone()[0] + assert override is None + + +def test_capability_projection_delete_removes_only_catalog_projection( + tmp_path: Path, +) -> None: + repository = tmp_path / "repo" + sessions = repository / "sessions" + source = make_legacy_session(sessions, "20260716T205632Z_viewer_live") + source_payload = source / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt" + source_payload_sha256 = hashlib.sha256(source_payload.read_bytes()).hexdigest() + store = SessionStore(repository, data_dir=tmp_path / "data") + store.reconcile_archive(xgrids_k1_archive_source(sessions)) + source_detail, source_snapshot = store.get_session_with_catalog_snapshot(source.name) + source_replay = store.prepare_replay(source.name) + capability = LabReplayCapability( + schema_version="missioncore.observation-lab-replay-capability/v1", + kind="canonical-recorded-rerun", + viewer_profile="recorded-session", + timeline="session_time", + activation="explicit", + commands_enabled=False, + ) + binding = store.publish_lab_instance( + session_id="lab-recorded-delete-only-projection", + source_session_id=source.name, + display_name="LAB V1 · disposable catalog projection", + lab_id="LAB V1", + result_kind="recorded-perception-qualification", + result_id="lab-v1-vegetation-shadow-" + "c" * 64, + source_result_id="lab-v1-vegetation-shadow-" + "d" * 64, + run_created_at_utc="2026-08-29T18:05:11.329061+00:00", + include_recorded_media=False, + replay_capability=capability, + provenance={ + "replay_capability": capability.as_dict(), + "method": lab_method(), + }, + ) + projection_replay = store.prepare_replay(binding.session_id) + cache_sentinel = store.data_dir / "recordings" / binding.session_id / "sentinel.rrd" + cache_sentinel.parent.mkdir(parents=True) + cache_sentinel.write_bytes(b"cache-owned-by-eviction-policy") + assert projection_replay.primary_artifact.path == source_replay.primary_artifact.path + + store.delete_capability_lab_projection(binding.session_id) + + with pytest.raises(SessionNotFoundError): + store.get_session(binding.session_id) + retained, retained_snapshot = store.get_session_with_catalog_snapshot(source.name) + assert retained == source_detail + assert retained_snapshot == source_snapshot + assert source.is_dir() + assert source_payload.is_file() + assert hashlib.sha256(source_payload.read_bytes()).hexdigest() == source_payload_sha256 + assert cache_sentinel.read_bytes() == b"cache-owned-by-eviction-policy" + assert ( + store.prepare_replay(source.name).primary_artifact.path + == source_replay.primary_artifact.path + ) + with sqlite3.connect(store.database_path) as connection: + assert connection.execute( + "SELECT COUNT(*) FROM observation_lab_instances WHERE session_id = ?", + (binding.session_id,), + ).fetchone()[0] == 0 + assert connection.execute( + "SELECT COUNT(*) FROM observation_session_artifacts WHERE session_id = ?", + (binding.session_id,), + ).fetchone()[0] == 0 + + +def test_capability_projection_mutations_reject_source_legacy_and_corrupt_rows( + tmp_path: Path, +) -> None: + repository = tmp_path / "repo" + sessions = repository / "sessions" + source = make_legacy_session(sessions, "20260716T205632Z_viewer_live") + store = SessionStore(repository, data_dir=tmp_path / "data") + store.reconcile_archive(xgrids_k1_archive_source(sessions)) + legacy = store.publish_lab_instance( + session_id="lab-legacy-not-observatory-owned", + source_session_id=source.name, + display_name="LAB E21 · legacy", + lab_id="LAB E21", + result_kind="e21-realtime-envelope", + result_id="e21-realtime-envelope-" + "e" * 64, + run_created_at_utc="2026-07-23T15:55:15.548Z", + provenance={"method": lab_method()}, + ) + capability = LabReplayCapability( + schema_version="missioncore.observation-lab-replay-capability/v1", + kind="canonical-recorded-rerun", + viewer_profile="recorded-session", + timeline="session_time", + activation="explicit", + commands_enabled=False, + ) + corrupt = store.publish_lab_instance( + session_id="lab-recorded-corrupt-owner", + source_session_id=source.name, + display_name="LAB V1 · corrupt owner", + lab_id="LAB V1", + result_kind="recorded-perception-qualification", + result_id="lab-v1-vegetation-shadow-" + "f" * 64, + run_created_at_utc="2026-08-29T18:05:11.329061+00:00", + include_recorded_media=False, + replay_capability=capability, + provenance={ + "replay_capability": capability.as_dict(), + "method": lab_method(), + }, + ) + mismatch = store.publish_lab_instance( + session_id="lab-recorded-provenance-mismatch", + source_session_id=source.name, + display_name="LAB V1 · provenance mismatch", + lab_id="LAB V1", + result_kind="recorded-perception-qualification", + result_id="lab-v1-vegetation-shadow-" + "1" * 64, + run_created_at_utc="2026-08-29T18:05:11.329061+00:00", + include_recorded_media=False, + replay_capability=capability, + provenance={ + "replay_capability": capability.as_dict(), + "method": lab_method(), + }, + ) + with sqlite3.connect(store.database_path) as connection: + connection.execute( + "UPDATE observation_sessions SET archive_id = ? WHERE session_id = ?", + ("not-observatory-owned", corrupt.session_id), + ) + mismatched_provenance = { + "replay_capability": { + **capability.as_dict(), + "commands_enabled": True, + }, + "method": lab_method(), + } + connection.execute( + "UPDATE observation_lab_instances SET provenance_json = ? " + "WHERE session_id = ?", + ( + json.dumps(mismatched_provenance, separators=(",", ":")), + mismatch.session_id, + ), + ) + connection.commit() + + for session_id in (source.name, legacy.session_id, corrupt.session_id): + with pytest.raises(SessionIntegrityError, match="capability-owned"): + store.rename_capability_lab_projection(session_id, "Нельзя изменить") + with pytest.raises(SessionIntegrityError, match="capability-owned"): + store.delete_capability_lab_projection(session_id) + + with pytest.raises(SessionIntegrityError, match="does not match provenance"): + store.rename_capability_lab_projection(mismatch.session_id, "Нельзя изменить") + with pytest.raises(SessionIntegrityError, match="does not match provenance"): + store.delete_capability_lab_projection(mismatch.session_id) + + assert store.get_session(source.name).summary.session_id == source.name + assert store.get_session(legacy.session_id).summary.display_name == "LAB E21 · legacy" + with sqlite3.connect(store.database_path) as connection: + retained_ids = { + row[0] + for row in connection.execute( + "SELECT session_id FROM observation_sessions" + ).fetchall() + } + assert { + source.name, + legacy.session_id, + corrupt.session_id, + mismatch.session_id, + } <= retained_ids + + @pytest.mark.parametrize("value", [0, 1, None, True]) def test_lab_replay_capability_rejects_non_literal_false(value: object) -> None: with pytest.raises(ValueError, match="replay capability"):