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
@@ -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 {