feat(k1): complete primary acquisition lifecycle

This commit is contained in:
DCCONSTRUCTIONS
2026-07-17 23:03:59 +03:00
parent 9d51080d2e
commit aa3680948f
66 changed files with 6093 additions and 544 deletions
+45 -3
View File
@@ -30,6 +30,10 @@ import { ObservationSessionSelect } from "./components/ObservationSessionSelect"
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
import type { ViewerSettings } from "./core/runtime/contracts";
import {
SPATIAL_SOURCE_SWITCH_BLOCKED_REASON,
isSpatialSourceSwitchBlocked,
} from "./core/runtime/acquisitionGuard";
import {
createLatestAsyncCommitter,
type LatestAsyncCommitter,
@@ -144,6 +148,12 @@ export default function App() {
const [layoutSaveNotice, setLayoutSaveNotice] = useState<string | null>(null);
const [sceneSettings, setSceneSettings] = useState<SceneSettings>(defaultSceneSettings);
const [displayDraft, setDisplayDraft] = useState<SceneSettings>(defaultSceneSettings);
const sourceSwitchBlocked = isSpatialSourceSwitchBlocked(runtime.state);
const sourceSwitchBlockedReason = sourceSwitchBlocked
? SPATIAL_SOURCE_SWITCH_BLOCKED_REASON
: null;
const sourceSwitchBlockedRef = useRef(sourceSwitchBlocked);
sourceSwitchBlockedRef.current = sourceSwitchBlocked;
const appliedProfileKeyRef = useRef<string | null>(null);
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
@@ -423,6 +433,9 @@ export default function App() {
};
const beginRecordedReplaySwitch = useCallback(async () => {
if (sourceSwitchBlockedRef.current) {
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
}
// useObservationSessions calls this only after backend preparation has
// produced a validated launch descriptor. Keep the old scene mounted
// before this point; now perform one controlled receiver teardown before
@@ -437,6 +450,9 @@ export default function App() {
}, []);
const acceptRecordedReplay = useCallback((launch: ObservationSessionReplayLaunch) => {
if (sourceSwitchBlockedRef.current) {
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
}
setRecordedReplay(launch);
setSourceUrl(launch.sourceUrl);
setSourceDraft(launch.sourceUrl);
@@ -447,6 +463,17 @@ export default function App() {
if (outcome !== "accepted") setReplayTransitioning(false);
}, []);
const activateAutomaticSpatialSource = useCallback(() => {
// A plugin-owned live/file-replay start must release any host-selected
// archive or manual URL before the new acquisition becomes non-terminal.
// This transition is an internal start boundary, not an operator source
// switch, so it intentionally does not consult the acquisition guard.
setReplayTransitioning(false);
setRecordedReplay(null);
setSourceUrl("");
setSourceDraft("");
}, []);
useEffect(() => {
// ObservationSessionSelect owns the cancellable request and unmounts when
// the operator leaves the spatial workspace. Its unmount cannot safely
@@ -637,7 +664,8 @@ export default function App() {
<div className="observation-header-tools">
<ObservationSessionSelect
limit={3}
disabled={runtime.pendingAction !== null}
disabled={runtime.pendingAction !== null || sourceSwitchBlocked}
blockedReason={sourceSwitchBlockedReason}
onReplayBegin={beginRecordedReplaySwitch}
onReplayAccepted={(_session, launch) => acceptRecordedReplay(launch)}
onReplaySettled={(_session, outcome) => settleRecordedReplaySwitch(outcome)}
@@ -667,6 +695,7 @@ export default function App() {
{activeDefinition.kind === "device" ? (
<DeviceWorkspace
onOpenSpatialScene={() => openView("spatial-scene")}
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
/>
) : (
<WorkspaceRenderer
@@ -682,11 +711,18 @@ export default function App() {
stageDisplayPatch({ accumulationSeconds })}
onAccumulationCommit={flushDisplaySettings}
observationLayout={observationLayout}
spatialControls={selection?.SpatialControlsView
? {
View: selection.SpatialControlsView,
model: selection.model,
}
: null}
navigation={{
openView,
openSource,
openDisplay,
openLayers,
activateAutomaticSpatialSource,
}}
/>
)}
@@ -713,7 +749,10 @@ export default function App() {
<WindowFooterActions>
<Button
variant="ghost"
disabled={sourceSwitchBlocked}
title={sourceSwitchBlockedReason ?? undefined}
onClick={() => {
if (sourceSwitchBlockedRef.current) return;
setSourceDraft("");
setSourceUrl("");
setRecordedReplay(null);
@@ -724,8 +763,10 @@ export default function App() {
<Button
variant="primary"
shape="pill"
disabled={!sourceDraft.trim()}
disabled={sourceSwitchBlocked || !sourceDraft.trim()}
title={sourceSwitchBlockedReason ?? undefined}
onClick={() => {
if (sourceSwitchBlockedRef.current) return;
setSourceUrl(sourceDraft.trim());
setRecordedReplay(null);
}}
@@ -766,9 +807,10 @@ export default function App() {
hint="необязательно"
value={sourceDraft}
onChange={(event) => setSourceDraft(event.target.value)}
disabled={sourceSwitchBlocked}
spellCheck={false}
placeholder="rerun+http://127.0.0.1:9876/proxy"
description="Пустое значение использует автоматический локальный источник. Ручной адрес нужен для другого gRPC-потока или записи RRD."
description={sourceSwitchBlockedReason ?? "Пустое значение использует автоматический локальный источник. Ручной адрес нужен для другого gRPC-потока или записи RRD."}
/>
</div>
),
@@ -93,12 +93,14 @@ function sessionDescription(session: ObservationSessionSummary): string {
export function ObservationSessionSelect({
limit = 3,
disabled = false,
blockedReason = null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
}: {
limit?: number;
disabled?: boolean;
blockedReason?: string | null;
onReplayBegin?: (
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
@@ -114,6 +116,7 @@ export function ObservationSessionSelect({
}) {
const sessions = useObservationSessions({
limit,
replayEnabled: blockedReason === null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
@@ -123,6 +126,7 @@ export function ObservationSessionSelect({
: sessions.state === "loading"
? "Загружаем сессии…"
: "Сохранённые сессии";
const presentedTriggerCopy = blockedReason ?? triggerCopy;
return (
<Dropdown
@@ -147,10 +151,11 @@ export function ObservationSessionSelect({
aria-expanded={open}
aria-controls={surfaceId}
disabled={disabled}
title={blockedReason ?? undefined}
onClick={toggle}
>
<Icon name="database" size={15} />
<span>{triggerCopy}</span>
<span>{presentedTriggerCopy}</span>
{sessions.state === "ready" ? <small>{sessions.items.length}</small> : null}
<Icon name="chevron-down" size={14} />
</button>
@@ -94,6 +94,7 @@ export function isDevicePluginManifestV1Alpha2(
export interface DevicePluginHostActions {
openSpatialScene: () => void;
activateAutomaticSpatialSource: () => void;
}
export interface DevicePluginConnectionProps {
@@ -109,10 +110,12 @@ export interface DeviceUiPlugin {
children: ReactNode;
}>;
connectionViews: Readonly<Record<string, ComponentType<DevicePluginConnectionProps>>>;
SpatialControlsView?: ComponentType<DevicePluginConnectionProps>;
}
export interface RegisteredDeviceModel {
plugin: DeviceUiPlugin;
model: DeviceModelDefinition;
ConnectionView: ComponentType<DevicePluginConnectionProps>;
SpatialControlsView?: ComponentType<DevicePluginConnectionProps>;
}
@@ -90,7 +90,14 @@ export function createDevicePluginRegistry(
`Плагин ${manifest.metadata.id} не реализует UI ${model.ui.componentKey}.`,
);
}
models.push({ plugin, model, ConnectionView });
models.push({
plugin,
model,
ConnectionView,
...(plugin.SpatialControlsView
? { SpatialControlsView: plugin.SpatialControlsView }
: {}),
});
}
if (isDevicePluginManifestV1Alpha2(manifest)) {
@@ -101,9 +101,11 @@ export function createObservationReplayCoordinator(): ObservationReplayCoordinat
};
},
cancel() {
sequence += 1;
active?.abort();
active = null;
// 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.
},
};
}
@@ -359,11 +361,13 @@ export function clearObservationReplayPreparation(
export function useObservationSessions({
limit = 3,
replayEnabled = true,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
}: {
limit?: number;
replayEnabled?: boolean;
/** Called only after the archive is ready, immediately before replacing the old viewer. */
onReplayBegin?: (
session: ObservationSessionSummary,
@@ -388,11 +392,23 @@ export function useObservationSessions({
const mounted = useRef(true);
const catalogSequence = useRef(0);
const reattachStarted = useRef(false);
const replayEnabledRef = useRef(replayEnabled);
replayEnabledRef.current = replayEnabled;
const preparationPollSequence = useRef(0);
const replayCoordinator = useRef<ObservationReplayCoordinator | null>(null);
if (replayCoordinator.current === null) {
replayCoordinator.current = createObservationReplayCoordinator();
}
useEffect(() => {
if (replayEnabled) return;
// Cancels only this browser's polling/selection attempt. The shared
// backend preparation remains untouched and can be selected again later.
reattachStarted.current = true;
replayCoordinator.current?.cancel();
setReplayingSessionId(null);
setReplayProgress(null);
}, [replayEnabled]);
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 3;
const refresh = useCallback(async () => {
@@ -466,6 +482,7 @@ export function useObservationSessions({
session: ObservationSessionSummary,
resumedPreparation?: ObservationSessionPreparation,
) => {
if (!replayEnabledRef.current) return false;
const attempt = replayCoordinator.current!.begin();
setReplayingSessionId(session.id);
setFailedSessionId(null);
@@ -502,14 +519,26 @@ export function useObservationSessions({
signal: attempt.signal,
onUpdate,
});
if (!mounted.current || !attempt.isCurrent()) return false;
if (
!mounted.current ||
!attempt.isCurrent() ||
!replayEnabledRef.current
) return false;
// The current scene stays mounted throughout preparation. Only now that
// the launch descriptor exists do we release the previous viewer.
await onReplayBegin?.(session, launch);
if (!mounted.current || !attempt.isCurrent()) return false;
if (
!mounted.current ||
!attempt.isCurrent() ||
!replayEnabledRef.current
) return false;
await onReplayAccepted?.(session, launch);
if (!mounted.current || !attempt.isCurrent()) return false;
if (
!mounted.current ||
!attempt.isCurrent() ||
!replayEnabledRef.current
) return false;
outcome = "accepted";
try {
clearObservationReplayPreparation();
@@ -543,6 +572,7 @@ export function useObservationSessions({
}, [onReplayAccepted, onReplayBegin, onReplaySettled]);
const replay = useCallback(async (sessionId: string) => {
if (!replayEnabledRef.current) return false;
const session = items.find((candidate) => candidate.id === sessionId);
if (!session || !session.replayable) return false;
reattachStarted.current = true;
@@ -550,7 +580,7 @@ export function useObservationSessions({
}, [executeReplay, items]);
useEffect(() => {
if (state !== "ready" || reattachStarted.current) return;
if (!replayEnabled || state !== "ready" || reattachStarted.current) return;
reattachStarted.current = true;
let stored: ObservationSessionPreparation | null = null;
try {
@@ -569,7 +599,7 @@ export function useObservationSessions({
return;
}
void executeReplay(session, stored);
}, [executeReplay, items, state]);
}, [executeReplay, items, replayEnabled, state]);
const retry = useCallback(async () => {
if (!failedSessionId) return false;
@@ -0,0 +1,26 @@
import type { MissionRuntimeState } from "./contracts";
const TERMINAL_ACQUISITION_STATES = new Set([
"completed",
"failed",
"aborted",
"interrupted",
]);
export const SPATIAL_SOURCE_SWITCH_BLOCKED_REASON =
"Завершите текущий приём перед сменой источника.";
/**
* An acquisition snapshot is blocking unless its state is explicitly known to
* be terminal. Unknown future states therefore fail closed.
*/
export function isSpatialSourceSwitchBlocked(
state: MissionRuntimeState | null | undefined,
): boolean {
const acquisition = state?.acquisition;
return Boolean(
acquisition &&
(acquisition.cleanupPending === true ||
!TERMINAL_ACQUISITION_STATES.has(acquisition.state)),
);
}
@@ -29,6 +29,9 @@ export interface StreamMetrics {
frameRateHz?: number | null;
pointCount?: number | null;
droppedPreviewFrames?: number | null;
elapsedSeconds?: number | null;
routeDistanceMeters?: number | null;
speedMetersPerSecond?: number | null;
}
export interface ActiveDeviceSnapshot {
@@ -55,6 +58,7 @@ export interface RuntimeAcquisitionSnapshot {
state: string;
stateRevision: number;
operatorInstructions: readonly string[];
cleanupPending?: boolean;
}
export interface RuntimeOperationSnapshot {
@@ -8,6 +8,15 @@
gap: 0.45rem;
}
.scene-device-controls {
position: absolute;
z-index: 30;
top: 0.85rem;
left: 50%;
max-width: calc(100% - 9rem);
transform: translateX(-50%);
}
.scene-source-picker__trigger,
.scene-source-control,
.scene-focus-exit {
@@ -168,6 +168,11 @@
left: 0.6rem;
}
.scene-device-controls {
top: 0.6rem;
max-width: calc(100% - 8rem);
}
.scene-adapter-note {
right: 0.75rem;
left: 0.75rem;
@@ -40,7 +40,13 @@ function ModelCard({
);
}
export function DeviceWorkspace({ onOpenSpatialScene }: { onOpenSpatialScene: () => void }) {
export function DeviceWorkspace({
onOpenSpatialScene,
onActivateAutomaticSpatialSource,
}: {
onOpenSpatialScene: () => void;
onActivateAutomaticSpatialSource: () => void;
}) {
const {
registry,
selection,
@@ -115,7 +121,10 @@ export function DeviceWorkspace({ onOpenSpatialScene }: { onOpenSpatialScene: ()
</div>
<ConnectionView
model={selection.model}
host={{ openSpatialScene: onOpenSpatialScene }}
host={{
openSpatialScene: onOpenSpatialScene,
activateAutomaticSpatialSource: onActivateAutomaticSpatialSource,
}}
/>
</div>
);
@@ -1,4 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
} from "react";
import {
Button,
GlassSurface,
@@ -21,6 +28,10 @@ import type {
RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
import type { ObservationLayoutController } from "../core/observation/useObservationLayout";
import type {
DeviceModelDefinition,
DevicePluginConnectionProps,
} from "../core/device-plugins/contracts";
import type {
BackendStatus,
MissionRuntimeState,
@@ -98,6 +109,7 @@ export interface WorkspaceNavigation {
openSource: () => void;
openDisplay: () => void;
openLayers: () => void;
activateAutomaticSpatialSource: () => void;
}
export interface WorkspaceRendererProps {
@@ -113,6 +125,10 @@ export interface WorkspaceRendererProps {
onAccumulationCommit: () => void;
observationLayout: ObservationLayoutController;
navigation: WorkspaceNavigation;
spatialControls: {
View: ComponentType<DevicePluginConnectionProps>;
model: DeviceModelDefinition;
} | null;
}
function OverviewWorkspace({
@@ -271,6 +287,7 @@ function SpatialWorkspace({
onAccumulationCommit,
observationLayout,
navigation,
spatialControls,
}: WorkspaceRendererProps) {
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
const [viewerMessage, setViewerMessage] = useState("");
@@ -435,6 +452,18 @@ function SpatialWorkspace({
<EmptySpatialStage settings={sceneSettings} />
)}
{spatialControls && !recordedSource ? (
<div className="scene-device-controls">
<spatialControls.View
model={spatialControls.model}
host={{
openSpatialScene: () => navigation.openView("spatial-scene"),
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
}}
/>
</div>
) : null}
{pointCloudFocused ? (
<button
type="button"