feat: introduce device plugin runtime boundary

This commit is contained in:
DCCONSTRUCTIONS
2026-07-16 13:49:10 +03:00
parent 9225227421
commit 27bf7527df
45 changed files with 3748 additions and 1062 deletions
@@ -0,0 +1,160 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { IdleMissionRuntimeProvider } from "../runtime/MissionRuntimeContext";
import type { DeviceUiPlugin, RegisteredDeviceModel } from "./contracts";
import { createDevicePluginRegistry, type DevicePluginRegistry } from "./registry";
interface DevicePluginHostValue {
registry: DevicePluginRegistry;
selection: RegisteredDeviceModel | null;
selectionTransitionPending: boolean;
selectionTransitionError: string | null;
selectModel: (modelId: string) => Promise<boolean>;
clearSelection: () => Promise<boolean>;
}
const DevicePluginHostContext = createContext<DevicePluginHostValue | null>(null);
export function DevicePluginHostProvider({
plugins,
children,
}: {
plugins: readonly DeviceUiPlugin[];
children: ReactNode;
}) {
const registry = useMemo(() => createDevicePluginRegistry(plugins), [plugins]);
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
const [selectionTransitionPending, setSelectionTransitionPending] = useState(false);
const [selectionTransitionError, setSelectionTransitionError] = useState<string | null>(null);
const transitionInFlight = useRef(false);
const deactivationHandlers = useRef(new Map<string, () => Promise<boolean>>());
const selection = selectedModelId ? registry.resolveModel(selectedModelId) : null;
const registerDeactivation = useCallback(
(pluginId: string, handler: () => Promise<boolean>) => {
deactivationHandlers.current.set(pluginId, handler);
return () => {
if (deactivationHandlers.current.get(pluginId) === handler) {
deactivationHandlers.current.delete(pluginId);
}
};
},
[],
);
const transitionTo = useCallback(
async (nextModelId: string | null): Promise<boolean> => {
if (transitionInFlight.current) return false;
if (nextModelId !== null && !registry.resolveModel(nextModelId)) {
throw new Error(`Модель устройства не зарегистрирована: ${nextModelId}.`);
}
if (nextModelId === selectedModelId) return true;
transitionInFlight.current = true;
setSelectionTransitionPending(true);
setSelectionTransitionError(null);
try {
const current = selectedModelId ? registry.resolveModel(selectedModelId) : null;
const deactivate = current
? deactivationHandlers.current.get(current.plugin.manifest.metadata.id)
: undefined;
if (current && !deactivate) {
setSelectionTransitionError(
"Активный плагин не зарегистрировал безопасное завершение сессии.",
);
return false;
}
if (deactivate) {
try {
if (!(await deactivate())) {
setSelectionTransitionError(
"Плагин не подтвердил завершение текущей сессии.",
);
return false;
}
} catch {
setSelectionTransitionError(
"Не удалось безопасно завершить текущую сессию плагина.",
);
return false;
}
}
setSelectedModelId(nextModelId);
return true;
} finally {
transitionInFlight.current = false;
setSelectionTransitionPending(false);
}
},
[registry, selectedModelId],
);
const deactivationRegistrars = useMemo(
() =>
new Map(
registry.plugins.map((plugin) => [
plugin.manifest.metadata.id,
(handler: () => Promise<boolean>) =>
registerDeactivation(plugin.manifest.metadata.id, handler),
]),
),
[registerDeactivation, registry.plugins],
);
const value = useMemo<DevicePluginHostValue>(
() => ({
registry,
selection,
selectionTransitionPending,
selectionTransitionError,
selectModel: (modelId) => transitionTo(modelId),
clearSelection: () => transitionTo(null),
}),
[
registry,
selection,
selectionTransitionError,
selectionTransitionPending,
transitionTo,
],
);
const runtimeTree = [...registry.plugins].reverse().reduce<ReactNode>((child, plugin) => {
const RuntimeProvider = plugin.RuntimeProvider;
return (
<RuntimeProvider
key={plugin.manifest.metadata.id}
activeModel={
selection?.plugin.manifest.metadata.id === plugin.manifest.metadata.id
? selection.model
: null
}
registerDeactivation={deactivationRegistrars.get(plugin.manifest.metadata.id)!}
>
{child}
</RuntimeProvider>
);
}, children);
return (
<DevicePluginHostContext.Provider value={value}>
<IdleMissionRuntimeProvider>{runtimeTree}</IdleMissionRuntimeProvider>
</DevicePluginHostContext.Provider>
);
}
export function useDevicePluginHost(): DevicePluginHostValue {
const value = useContext(DevicePluginHostContext);
if (!value) {
throw new Error("DevicePluginHostProvider не подключён в composition root.");
}
return value;
}
@@ -0,0 +1,74 @@
import type { ComponentType, ReactNode } from "react";
export const DEVICE_PLUGIN_API_VERSION = "missioncore.nodedc/v1alpha1" as const;
export const DEVICE_STATE_READ_ACTION_ID = "state.read" as const;
export interface DeviceCapability {
id: string;
label: string;
}
export interface DevicePluginActionDefinition {
id: string;
mutating: boolean;
secretFields: readonly string[];
}
export interface DeviceModelDefinition {
id: string;
vendor: string;
displayName: string;
category: string;
description: string;
verified: boolean;
capabilities: readonly DeviceCapability[];
ui: {
slot: "device.connection";
componentKey: string;
};
}
export interface DevicePluginManifest {
apiVersion: typeof DEVICE_PLUGIN_API_VERSION;
kind: "DevicePlugin";
metadata: {
id: string;
version: string;
displayName: string;
};
spec: {
hostApiRange: "v1alpha1";
runtime: {
backendEntrypoint: string;
isolation: "transitional-in-process";
};
permissions: readonly string[];
actions: readonly DevicePluginActionDefinition[];
models: readonly DeviceModelDefinition[];
};
}
export interface DevicePluginHostActions {
openSpatialScene: () => void;
}
export interface DevicePluginConnectionProps {
model: DeviceModelDefinition;
host: DevicePluginHostActions;
}
export interface DeviceUiPlugin {
manifest: DevicePluginManifest;
RuntimeProvider: ComponentType<{
activeModel: DeviceModelDefinition | null;
registerDeactivation: (handler: () => Promise<boolean>) => () => void;
children: ReactNode;
}>;
connectionViews: Readonly<Record<string, ComponentType<DevicePluginConnectionProps>>>;
}
export interface RegisteredDeviceModel {
plugin: DeviceUiPlugin;
model: DeviceModelDefinition;
ConnectionView: ComponentType<DevicePluginConnectionProps>;
}
@@ -0,0 +1,182 @@
import {
DEVICE_PLUGIN_API_VERSION,
type DeviceCapability,
type DeviceModelDefinition,
type DevicePluginActionDefinition,
type DevicePluginManifest,
} from "./contracts";
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`Некорректный manifest: ${path} должен быть объектом.`);
}
return value as Record<string, unknown>;
}
function exactKeys(
value: Record<string, unknown>,
path: string,
allowed: readonly string[],
): void {
const extras = Object.keys(value).filter((key) => !allowed.includes(key));
if (extras.length) {
throw new Error(`Некорректный manifest: ${path} содержит неизвестные поля ${extras.join(", ")}.`);
}
}
function text(value: unknown, path: string, maxLength = 160): string {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`Некорректный manifest: ${path} должен быть непустой строкой.`);
}
if (value.length > maxLength) {
throw new Error(
`Некорректный manifest: ${path} длиннее ${maxLength} символов.`,
);
}
return value;
}
function flag(value: unknown, path: string): boolean {
if (typeof value !== "boolean") {
throw new Error(`Некорректный manifest: ${path} должен быть boolean.`);
}
return value;
}
function list(value: unknown, path: string): unknown[] {
if (!Array.isArray(value)) {
throw new Error(`Некорректный manifest: ${path} должен быть массивом.`);
}
return value;
}
function capability(value: unknown, path: string): DeviceCapability {
const item = record(value, path);
exactKeys(item, path, ["id", "label"]);
return { id: text(item.id, `${path}.id`), label: text(item.label, `${path}.label`) };
}
function action(value: unknown, path: string): DevicePluginActionDefinition {
const item = record(value, path);
exactKeys(item, path, ["id", "mutating", "secretFields"]);
return {
id: text(item.id, `${path}.id`),
mutating: flag(item.mutating, `${path}.mutating`),
secretFields: list(item.secretFields, `${path}.secretFields`).map((field, index) =>
text(field, `${path}.secretFields[${index}]`),
),
};
}
function model(value: unknown, path: string): DeviceModelDefinition {
const item = record(value, path);
exactKeys(item, path, [
"id",
"vendor",
"displayName",
"category",
"description",
"verified",
"capabilities",
"ui",
]);
const ui = record(item.ui, `${path}.ui`);
exactKeys(ui, `${path}.ui`, ["slot", "componentKey"]);
const slot = text(ui.slot, `${path}.ui.slot`);
if (slot !== "device.connection") {
throw new Error(`Некорректный manifest: слот ${slot} пока не поддерживается.`);
}
return {
id: text(item.id, `${path}.id`),
vendor: text(item.vendor, `${path}.vendor`),
displayName: text(item.displayName, `${path}.displayName`),
category: text(item.category, `${path}.category`),
description: text(item.description, `${path}.description`, 1024),
verified: flag(item.verified, `${path}.verified`),
capabilities: list(item.capabilities, `${path}.capabilities`).map((entry, index) =>
capability(entry, `${path}.capabilities[${index}]`),
),
ui: {
slot,
componentKey: text(ui.componentKey, `${path}.ui.componentKey`),
},
};
}
export function parseDevicePluginManifest(document: unknown): DevicePluginManifest {
const root = record(document, "root");
exactKeys(root, "root", ["apiVersion", "kind", "metadata", "spec"]);
if (root.apiVersion !== DEVICE_PLUGIN_API_VERSION || root.kind !== "DevicePlugin") {
throw new Error("Manifest использует несовместимую версию или kind.");
}
const metadata = record(root.metadata, "metadata");
exactKeys(metadata, "metadata", ["id", "version", "displayName"]);
const spec = record(root.spec, "spec");
exactKeys(spec, "spec", [
"hostApiRange",
"runtime",
"permissions",
"actions",
"models",
]);
if (spec.hostApiRange !== "v1alpha1") {
throw new Error(`Manifest требует несовместимый host API: ${String(spec.hostApiRange)}.`);
}
const runtime = record(spec.runtime, "spec.runtime");
exactKeys(runtime, "spec.runtime", ["backendEntrypoint", "isolation"]);
const isolation = text(runtime.isolation, "spec.runtime.isolation");
if (isolation !== "transitional-in-process") {
throw new Error(`Некорректный manifest: неизвестная изоляция ${isolation}.`);
}
const models = list(spec.models, "spec.models").map((entry, index) =>
model(entry, `spec.models[${index}]`),
);
if (models.length !== 1) {
throw new Error("Manifest v1alpha1 должен объявлять ровно одну модель.");
}
const version = text(metadata.version, "metadata.version");
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) {
throw new Error(`Некорректный manifest: версия ${version} не является semver.`);
}
return {
apiVersion: DEVICE_PLUGIN_API_VERSION,
kind: "DevicePlugin",
metadata: {
id: text(metadata.id, "metadata.id"),
version,
displayName: text(metadata.displayName, "metadata.displayName"),
},
spec: {
hostApiRange: "v1alpha1",
runtime: {
backendEntrypoint: text(
runtime.backendEntrypoint,
"spec.runtime.backendEntrypoint",
256,
),
isolation,
},
permissions: list(spec.permissions, "spec.permissions").map((entry, index) =>
text(entry, `spec.permissions[${index}]`),
),
actions: list(spec.actions, "spec.actions").map((entry, index) =>
action(entry, `spec.actions[${index}]`),
),
models,
},
};
}
export function requirePluginAction(
manifest: DevicePluginManifest,
actionId: string,
): string {
const action = manifest.spec.actions.find((candidate) => candidate.id === actionId);
if (!action) {
throw new Error(`Плагин ${manifest.metadata.id} не объявляет действие ${actionId}.`);
}
return action.id;
}
@@ -0,0 +1,95 @@
import {
DEVICE_PLUGIN_API_VERSION,
DEVICE_STATE_READ_ACTION_ID,
type DeviceUiPlugin,
type RegisteredDeviceModel,
} from "./contracts";
export interface DevicePluginRegistry {
readonly plugins: readonly DeviceUiPlugin[];
readonly models: readonly RegisteredDeviceModel[];
resolveModel: (modelId: string) => RegisteredDeviceModel | null;
}
export function createDevicePluginRegistry(
installedPlugins: readonly DeviceUiPlugin[],
): DevicePluginRegistry {
const pluginIds = new Set<string>();
const modelIds = new Set<string>();
const models: RegisteredDeviceModel[] = [];
for (const plugin of installedPlugins) {
const { manifest } = plugin;
if (manifest.apiVersion !== DEVICE_PLUGIN_API_VERSION) {
throw new Error(
`Плагин ${manifest.metadata.id} использует несовместимый контракт ${manifest.apiVersion}.`,
);
}
if (manifest.kind !== "DevicePlugin") {
throw new Error(`Неподдерживаемый kind плагина: ${manifest.kind}.`);
}
if (!manifest.metadata.id.trim() || pluginIds.has(manifest.metadata.id)) {
throw new Error(
`Идентификатор плагина пуст или повторяется: ${manifest.metadata.id || "<empty>"}.`,
);
}
pluginIds.add(manifest.metadata.id);
if (manifest.spec.models.length !== 1) {
throw new Error(
`Плагин ${manifest.metadata.id} должен объявлять ровно одну модель в v1alpha1.`,
);
}
const permissions = new Set(manifest.spec.permissions);
if (permissions.size !== manifest.spec.permissions.length) {
throw new Error(`Плагин ${manifest.metadata.id} повторяет permission.`);
}
const actionIds = new Set<string>();
for (const action of manifest.spec.actions) {
if (!action.id.trim() || actionIds.has(action.id)) {
throw new Error(
`Идентификатор действия пуст или повторяется: ${action.id || "<empty>"}.`,
);
}
actionIds.add(action.id);
if (new Set(action.secretFields).size !== action.secretFields.length) {
throw new Error(`Действие ${action.id} повторяет secret field.`);
}
}
const stateRead = manifest.spec.actions.find(
(action) => action.id === DEVICE_STATE_READ_ACTION_ID,
);
if (!stateRead || stateRead.mutating || stateRead.secretFields.length) {
throw new Error(
`Плагин ${manifest.metadata.id} должен объявлять безопасное действие state.read.`,
);
}
for (const model of manifest.spec.models) {
if (!model.id.trim() || modelIds.has(model.id)) {
throw new Error(`Идентификатор модели пуст или повторяется: ${model.id || "<empty>"}.`);
}
modelIds.add(model.id);
const capabilityIds = new Set(model.capabilities.map((capability) => capability.id));
if (capabilityIds.size !== model.capabilities.length) {
throw new Error(`Модель ${model.id} повторяет capability.`);
}
const ConnectionView = plugin.connectionViews[model.ui.componentKey];
if (!ConnectionView) {
throw new Error(
`Плагин ${manifest.metadata.id} не реализует UI ${model.ui.componentKey}.`,
);
}
models.push({ plugin, model, ConnectionView });
}
}
const modelById = new Map(models.map((registered) => [registered.model.id, registered]));
return {
plugins: Object.freeze([...installedPlugins]),
models: Object.freeze(models),
resolveModel: (modelId) => modelById.get(modelId) ?? null,
};
}
@@ -0,0 +1,35 @@
import { createContext, useContext, type ReactNode } from "react";
import type { MissionRuntimeController } from "./contracts";
const idleRuntime: MissionRuntimeController = {
state: {
phase: "unconfigured",
message: "Сначала выберите модель локального устройства.",
sourceMode: "idle",
},
backendStatus: "unconfigured",
pendingAction: null,
refresh: () => undefined,
updateViewerSettings: async () => false,
};
const MissionRuntimeContext = createContext<MissionRuntimeController>(idleRuntime);
export function MissionRuntimeProvider({
value,
children,
}: {
value: MissionRuntimeController;
children: ReactNode;
}) {
return <MissionRuntimeContext.Provider value={value}>{children}</MissionRuntimeContext.Provider>;
}
export function IdleMissionRuntimeProvider({ children }: { children: ReactNode }) {
return <MissionRuntimeProvider value={idleRuntime}>{children}</MissionRuntimeProvider>;
}
export function useMissionRuntime(): MissionRuntimeController {
return useContext(MissionRuntimeContext);
}
@@ -0,0 +1,65 @@
export type BackendStatus = "unconfigured" | "checking" | "online" | "degraded" | "offline";
export type RuntimePhase =
| "unconfigured"
| "idle"
| "configuring"
| "connected"
| "starting"
| "streaming"
| "replaying"
| "stopping"
| "error";
export type SourceMode = "idle" | "live" | "replay";
export interface ViewerSettings {
point_size: number;
color_mode: "intensity" | "height" | "distance" | "rgb" | "class";
palette: "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
custom_color: string;
accumulation_seconds: number;
show_points: boolean;
show_trajectory: boolean;
show_grid: boolean;
}
export interface StreamMetrics {
latencyMs?: number | null;
frameRateHz?: number | null;
pointCount?: number | null;
droppedPreviewFrames?: number | null;
}
export interface ActiveDeviceSnapshot {
pluginId: string;
modelId: string;
displayName: string;
instanceId?: string | null;
endpointLabel?: string | null;
}
export interface SpatialSourceDescriptor {
id: string;
url: string;
label: string;
kind: "rerun-grpc" | "rrd" | "other";
}
export interface MissionRuntimeState {
phase: RuntimePhase;
message?: string | null;
activeDevice?: ActiveDeviceSnapshot | null;
spatialSource?: SpatialSourceDescriptor | null;
viewerSettings?: ViewerSettings | null;
sourceMode: SourceMode;
metrics?: StreamMetrics;
}
export interface MissionRuntimeController {
state: MissionRuntimeState | null;
backendStatus: BackendStatus;
pendingAction: string | null;
refresh: () => void | Promise<void>;
updateViewerSettings: (settings: ViewerSettings) => Promise<boolean>;
}