feat(device-plugins): add profiled K1 lifecycle and canonical data plane

This commit is contained in:
DCCONSTRUCTIONS
2026-07-16 19:44:06 +03:00
parent 19ab973110
commit e6f7648b84
45 changed files with 6401 additions and 440 deletions
@@ -1,8 +1,17 @@
import type { ComponentType, ReactNode } from "react";
export const DEVICE_PLUGIN_API_VERSION = "missioncore.nodedc/v1alpha1" as const;
export const DEVICE_PLUGIN_API_VERSION_V1ALPHA1 = "missioncore.nodedc/v1alpha1" as const;
export const DEVICE_PLUGIN_API_VERSION_V1ALPHA2 = "missioncore.nodedc/v1alpha2" as const;
export const DEVICE_PLUGIN_API_VERSION = DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
export const SUPPORTED_DEVICE_PLUGIN_API_VERSIONS = Object.freeze([
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
DEVICE_PLUGIN_API_VERSION_V1ALPHA2,
] as const);
export const DEVICE_STATE_READ_ACTION_ID = "state.read" as const;
export type DevicePluginApiVersion =
(typeof SUPPORTED_DEVICE_PLUGIN_API_VERSIONS)[number];
export interface DeviceCapability {
id: string;
label: string;
@@ -28,14 +37,24 @@ export interface DeviceModelDefinition {
};
}
export interface DevicePluginManifest {
apiVersion: typeof DEVICE_PLUGIN_API_VERSION;
export interface DeviceCompatibilityProfileDefinition {
profileId: string;
path: string;
modelId: string;
}
interface DevicePluginManifestBase {
apiVersion: DevicePluginApiVersion;
kind: "DevicePlugin";
metadata: {
id: string;
version: string;
displayName: string;
};
}
export interface DevicePluginManifestV1Alpha1 extends DevicePluginManifestBase {
apiVersion: typeof DEVICE_PLUGIN_API_VERSION_V1ALPHA1;
spec: {
hostApiRange: "v1alpha1";
runtime: {
@@ -48,6 +67,31 @@ export interface DevicePluginManifest {
};
}
export interface DevicePluginManifestV1Alpha2 extends DevicePluginManifestBase {
apiVersion: typeof DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
spec: {
hostApiRange: "v1alpha2";
runtime: {
backendEntrypoint: string;
isolation: "transitional-in-process";
};
permissions: readonly string[];
actions: readonly DevicePluginActionDefinition[];
models: readonly DeviceModelDefinition[];
compatibilityProfiles: readonly DeviceCompatibilityProfileDefinition[];
};
}
export type DevicePluginManifest =
| DevicePluginManifestV1Alpha1
| DevicePluginManifestV1Alpha2;
export function isDevicePluginManifestV1Alpha2(
manifest: DevicePluginManifest,
): manifest is DevicePluginManifestV1Alpha2 {
return manifest.apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
}
export interface DevicePluginHostActions {
openSpatialScene: () => void;
}
@@ -1,9 +1,13 @@
import {
DEVICE_PLUGIN_API_VERSION,
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
DEVICE_PLUGIN_API_VERSION_V1ALPHA2,
type DeviceCapability,
type DeviceCompatibilityProfileDefinition,
type DeviceModelDefinition,
type DevicePluginActionDefinition,
type DevicePluginManifest,
type DevicePluginManifestV1Alpha1,
type DevicePluginManifestV1Alpha2,
} from "./contracts";
function record(value: unknown, path: string): Record<string, unknown> {
@@ -36,6 +40,14 @@ function text(value: unknown, path: string, maxLength = 160): string {
return value;
}
function identifier(value: unknown, path: string): string {
const candidate = text(value, path, 192);
if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(candidate)) {
throw new Error(`Некорректный manifest: ${path} не является идентификатором.`);
}
return candidate;
}
function flag(value: unknown, path: string): boolean {
if (typeof value !== "boolean") {
throw new Error(`Некорректный manifest: ${path} должен быть boolean.`);
@@ -50,25 +62,44 @@ function list(value: unknown, path: string): unknown[] {
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 contractText(value: unknown, path: string, strictIdentifiers: boolean): string {
return strictIdentifiers ? identifier(value, path) : text(value, path);
}
function action(value: unknown, path: string): DevicePluginActionDefinition {
function capability(
value: unknown,
path: string,
strictIdentifiers: boolean,
): DeviceCapability {
const item = record(value, path);
exactKeys(item, path, ["id", "label"]);
return {
id: contractText(item.id, `${path}.id`, strictIdentifiers),
label: text(item.label, `${path}.label`),
};
}
function action(
value: unknown,
path: string,
strictIdentifiers: boolean,
): DevicePluginActionDefinition {
const item = record(value, path);
exactKeys(item, path, ["id", "mutating", "secretFields"]);
return {
id: text(item.id, `${path}.id`),
id: contractText(item.id, `${path}.id`, strictIdentifiers),
mutating: flag(item.mutating, `${path}.mutating`),
secretFields: list(item.secretFields, `${path}.secretFields`).map((field, index) =>
text(field, `${path}.secretFields[${index}]`),
contractText(field, `${path}.secretFields[${index}]`, strictIdentifiers),
),
};
}
function model(value: unknown, path: string): DeviceModelDefinition {
function model(
value: unknown,
path: string,
strictIdentifiers: boolean,
): DeviceModelDefinition {
const item = record(value, path);
exactKeys(item, path, [
"id",
@@ -87,14 +118,14 @@ function model(value: unknown, path: string): DeviceModelDefinition {
throw new Error(`Некорректный manifest: слот ${slot} пока не поддерживается.`);
}
return {
id: text(item.id, `${path}.id`),
id: contractText(item.id, `${path}.id`, strictIdentifiers),
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}]`),
capability(entry, `${path}.capabilities[${index}]`, strictIdentifiers),
),
ui: {
slot,
@@ -103,23 +134,96 @@ function model(value: unknown, path: string): DeviceModelDefinition {
};
}
function compatibilityProfile(
value: unknown,
path: string,
): DeviceCompatibilityProfileDefinition {
const item = record(value, path);
exactKeys(item, path, ["profileId", "path", "modelId"]);
const profilePath = text(item.path, `${path}.path`, 512);
const pathSegments = profilePath.split("/");
if (
profilePath.startsWith("/") ||
profilePath.includes("\\") ||
!profilePath.endsWith(".json") ||
pathSegments.some((segment) => !segment || segment === "." || segment === "..")
) {
throw new Error(
`Некорректный manifest: ${path}.path должен быть безопасным относительным JSON-путём.`,
);
}
return {
profileId: identifier(item.profileId, `${path}.profileId`),
path: profilePath,
modelId: identifier(item.modelId, `${path}.modelId`),
};
}
function validateV1Alpha2Profiles(
models: readonly DeviceModelDefinition[],
profiles: readonly DeviceCompatibilityProfileDefinition[],
): void {
if (!profiles.length) {
throw new Error("Manifest v1alpha2 должен объявлять compatibilityProfiles.");
}
const modelIds = new Set(models.map((modelItem) => modelItem.id));
const profileIds = new Set<string>();
const profilePaths = new Set<string>();
const coveredModels = new Set<string>();
for (const profile of profiles) {
if (profileIds.has(profile.profileId) || profilePaths.has(profile.path)) {
throw new Error(
`Manifest v1alpha2 повторяет профиль ${profile.profileId} или его путь.`,
);
}
profileIds.add(profile.profileId);
profilePaths.add(profile.path);
if (!modelIds.has(profile.modelId)) {
throw new Error(
`Профиль ${profile.profileId} ссылается на неизвестную модель ${profile.modelId}.`,
);
}
coveredModels.add(profile.modelId);
}
const uncovered = [...modelIds].filter((modelId) => !coveredModels.has(modelId));
if (uncovered.length) {
throw new Error(
`Manifest v1alpha2 не содержит разрешённого профиля для моделей: ${uncovered.join(", ")}.`,
);
}
}
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") {
const apiVersion = root.apiVersion;
if (
(apiVersion !== DEVICE_PLUGIN_API_VERSION_V1ALPHA1 &&
apiVersion !== DEVICE_PLUGIN_API_VERSION_V1ALPHA2) ||
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", [
const specKeys = [
"hostApiRange",
"runtime",
"permissions",
"actions",
"models",
]);
if (spec.hostApiRange !== "v1alpha1") {
];
exactKeys(
spec,
"spec",
apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2
? [...specKeys, "compatibilityProfiles"]
: specKeys,
);
const expectedHostApiRange =
apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2 ? "v1alpha2" : "v1alpha1";
if (spec.hostApiRange !== expectedHostApiRange) {
throw new Error(`Manifest требует несовместимый host API: ${String(spec.hostApiRange)}.`);
}
const runtime = record(spec.runtime, "spec.runtime");
@@ -129,45 +233,72 @@ export function parseDevicePluginManifest(document: unknown): DevicePluginManife
throw new Error(`Некорректный manifest: неизвестная изоляция ${isolation}.`);
}
const strictIdentifiers = apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
const models = list(spec.models, "spec.models").map((entry, index) =>
model(entry, `spec.models[${index}]`),
model(entry, `spec.models[${index}]`, strictIdentifiers),
);
if (models.length !== 1) {
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1 && models.length !== 1) {
throw new Error("Manifest v1alpha1 должен объявлять ровно одну модель.");
}
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2 && !models.length) {
throw new Error("Manifest v1alpha2 должен объявлять хотя бы одну модель.");
}
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,
const common = {
kind: "DevicePlugin",
metadata: {
id: text(metadata.id, "metadata.id"),
id: contractText(metadata.id, "metadata.id", strictIdentifiers),
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}]`),
} as const;
const commonSpec = {
runtime: {
backendEntrypoint: text(
runtime.backendEntrypoint,
"spec.runtime.backendEntrypoint",
256,
),
actions: list(spec.actions, "spec.actions").map((entry, index) =>
action(entry, `spec.actions[${index}]`),
),
models,
isolation,
},
};
permissions: list(spec.permissions, "spec.permissions").map((entry, index) =>
contractText(entry, `spec.permissions[${index}]`, strictIdentifiers),
),
actions: list(spec.actions, "spec.actions").map((entry, index) =>
action(entry, `spec.actions[${index}]`, strictIdentifiers),
),
models,
} as const;
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1) {
return {
...common,
apiVersion,
spec: {
...commonSpec,
hostApiRange: "v1alpha1",
},
} satisfies DevicePluginManifestV1Alpha1;
}
const profiles = list(spec.compatibilityProfiles, "spec.compatibilityProfiles").map(
(entry, index) => compatibilityProfile(entry, `spec.compatibilityProfiles[${index}]`),
);
validateV1Alpha2Profiles(models, profiles);
return {
...common,
apiVersion,
spec: {
...commonSpec,
hostApiRange: "v1alpha2",
compatibilityProfiles: profiles,
},
} satisfies DevicePluginManifestV1Alpha2;
}
export function requirePluginAction(
@@ -1,6 +1,8 @@
import {
DEVICE_PLUGIN_API_VERSION,
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
DEVICE_STATE_READ_ACTION_ID,
SUPPORTED_DEVICE_PLUGIN_API_VERSIONS,
isDevicePluginManifestV1Alpha2,
type DeviceUiPlugin,
type RegisteredDeviceModel,
} from "./contracts";
@@ -20,7 +22,7 @@ export function createDevicePluginRegistry(
for (const plugin of installedPlugins) {
const { manifest } = plugin;
if (manifest.apiVersion !== DEVICE_PLUGIN_API_VERSION) {
if (!SUPPORTED_DEVICE_PLUGIN_API_VERSIONS.includes(manifest.apiVersion)) {
throw new Error(
`Плагин ${manifest.metadata.id} использует несовместимый контракт ${manifest.apiVersion}.`,
);
@@ -35,11 +37,17 @@ export function createDevicePluginRegistry(
}
pluginIds.add(manifest.metadata.id);
if (manifest.spec.models.length !== 1) {
if (
manifest.apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1 &&
manifest.spec.models.length !== 1
) {
throw new Error(
`Плагин ${manifest.metadata.id} должен объявлять ровно одну модель в v1alpha1.`,
);
}
if (!manifest.spec.models.length) {
throw new Error(`Плагин ${manifest.metadata.id} не объявляет ни одной модели.`);
}
const permissions = new Set(manifest.spec.permissions);
if (permissions.size !== manifest.spec.permissions.length) {
@@ -84,6 +92,37 @@ export function createDevicePluginRegistry(
}
models.push({ plugin, model, ConnectionView });
}
if (isDevicePluginManifestV1Alpha2(manifest)) {
const profileIds = new Set<string>();
const profilePaths = new Set<string>();
const declaredModelIds = new Set(manifest.spec.models.map((model) => model.id));
const coveredModelIds = new Set<string>();
if (!manifest.spec.compatibilityProfiles.length) {
throw new Error(`Плагин ${manifest.metadata.id} не объявляет compatibilityProfiles.`);
}
for (const profile of manifest.spec.compatibilityProfiles) {
if (profileIds.has(profile.profileId) || profilePaths.has(profile.path)) {
throw new Error(`Плагин ${manifest.metadata.id} повторяет compatibility profile.`);
}
profileIds.add(profile.profileId);
profilePaths.add(profile.path);
if (!declaredModelIds.has(profile.modelId)) {
throw new Error(
`Профиль ${profile.profileId} ссылается на неизвестную модель ${profile.modelId}.`,
);
}
coveredModelIds.add(profile.modelId);
}
const uncovered = [...declaredModelIds].filter(
(modelId) => !coveredModelIds.has(modelId),
);
if (uncovered.length) {
throw new Error(
`Плагин ${manifest.metadata.id} не имеет разрешённого профиля для ${uncovered.join(", ")}.`,
);
}
}
}
const modelById = new Map(models.map((registered) => [registered.model.id, registered]));
@@ -39,6 +39,32 @@ export interface ActiveDeviceSnapshot {
endpointLabel?: string | null;
}
export interface RuntimeDeviceSessionSnapshot {
sessionId: string;
deviceId: string;
compatibilityProfileId?: string | null;
connectivity?: string | null;
}
export interface RuntimeAcquisitionSnapshot {
acquisitionId: string;
deviceId: string;
deviceSessionId: string;
compatibilityProfileId: string;
controlMode: string;
state: string;
stateRevision: number;
operatorInstructions: readonly string[];
}
export interface RuntimeOperationSnapshot {
operationId: string;
action: string;
status: string;
stageCode?: string | null;
messageCode?: string | null;
}
export interface SpatialSourceDescriptor {
id: string;
url: string;
@@ -50,6 +76,9 @@ export interface MissionRuntimeState {
phase: RuntimePhase;
message?: string | null;
activeDevice?: ActiveDeviceSnapshot | null;
deviceSession?: RuntimeDeviceSessionSnapshot | null;
acquisition?: RuntimeAcquisitionSnapshot | null;
operations?: readonly RuntimeOperationSnapshot[];
spatialSource?: SpatialSourceDescriptor | null;
viewerSettings?: ViewerSettings | null;
sourceMode: SourceMode;