feat(device-plugins): add profiled K1 lifecycle and canonical data plane
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user