feat(plugins): add runtime handshake boundary

This commit is contained in:
DCCONSTRUCTIONS
2026-07-17 19:50:01 +03:00
parent 24a47318f2
commit 9d51080d2e
22 changed files with 856 additions and 138 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""Mission Core runtime with the transitional XGRIDS K1 compatibility adapter."""
"""Mission Core control, observation, and device-plugin runtime."""
__version__ = "0.1.0"
+18 -4
View File
@@ -15,7 +15,10 @@ from pathlib import Path
from typing import Any, Literal, Protocol, cast
from bleak.exc import BleakError
from missioncore_plugin_sdk.v0alpha2 import RuntimeActionInvocation
from missioncore_plugin_sdk.v0alpha2 import (
RuntimeActionInvocation,
RuntimePluginDescriptor,
)
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError
from k1link.artifacts import write_json_atomic
@@ -50,11 +53,13 @@ from k1link.web.device_lifecycle import (
)
from k1link.web.plugin_runtime import (
DevicePluginRuntimeContribution,
InProcessDevicePluginRuntime,
PluginActionNotFoundError,
PluginExecutionError,
)
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
XGRIDS_K1_PLUGIN_VERSION = "0.3.0"
XGRIDS_K1_MODEL_ID = "xgrids.lixelkity-k1"
XGRIDS_K1_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
@@ -1821,15 +1826,24 @@ def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribu
_validate_installed_compatibility_profile(repository_root)
service = XgridsK1CompatibilityService(repository_root)
adapter = XgridsK1PluginFacade(service)
runtime = InProcessDevicePluginRuntime(
adapter,
RuntimePluginDescriptor(
plugin_id=XGRIDS_K1_PLUGIN_ID,
plugin_version=XGRIDS_K1_PLUGIN_VERSION,
supported_host_api_versions=("missioncore.nodedc/v1alpha2",),
action_ids=tuple(sorted(adapter.action_ids)),
),
close=service.close,
)
return DevicePluginRuntimeContribution(
adapter=adapter,
runtime=runtime,
legacy_routers=(
build_xgrids_k1_legacy_router(adapter),
build_xgrids_k1_legacy_router(runtime),
build_xgrids_k1_camera_router(
service.camera_preview,
XGRIDS_K1_PLUGIN_ID,
),
),
observation=build_xgrids_k1_observation(repository_root),
close=service.close,
)
@@ -18,25 +18,28 @@ from k1link.device_plugins.xgrids_k1.facade import (
LiveRequest,
ReplayRequest,
ViewerSettingsRequest,
XgridsK1PluginFacade,
)
from k1link.web.plugin_runtime import PluginExecutionError, invoke_device_plugin_adapter
from k1link.web.plugin_runtime import (
DevicePluginRuntimeTransport,
PluginExecutionError,
invoke_device_plugin_runtime,
)
def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
def build_xgrids_k1_legacy_router(runtime: DevicePluginRuntimeTransport) -> APIRouter:
"""Temporary flat API kept for scripts created before the plugin boundary."""
router = APIRouter(include_in_schema=True)
@router.get("/api/state", deprecated=True)
async def get_state() -> dict[str, Any]:
return await invoke_device_plugin_adapter(adapter, ACTION_STATE_READ, {})
return await invoke_device_plugin_runtime(runtime, ACTION_STATE_READ, {})
@router.post("/api/ble/scan", deprecated=True)
async def scan_ble(request: BleScanRequest) -> dict[str, Any]:
try:
return await invoke_device_plugin_adapter(
adapter,
return await invoke_device_plugin_runtime(
runtime,
ACTION_DISCOVERY_SCAN,
request.model_dump(),
)
@@ -46,8 +49,8 @@ def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
@router.post("/api/connect", deprecated=True)
async def connect(request: ConnectRequest) -> dict[str, Any]:
try:
return await invoke_device_plugin_adapter(
adapter,
return await invoke_device_plugin_runtime(
runtime,
ACTION_NETWORK_PROVISION,
request.model_dump(),
)
@@ -60,8 +63,8 @@ def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
@router.post("/api/session/live", deprecated=True)
async def start_live(request: LiveRequest) -> dict[str, Any]:
try:
return await invoke_device_plugin_adapter(
adapter,
return await invoke_device_plugin_runtime(
runtime,
ACTION_STREAM_START_LIVE,
request.model_dump(),
)
@@ -71,8 +74,8 @@ def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
@router.post("/api/session/replay", deprecated=True)
async def start_replay(request: ReplayRequest) -> dict[str, Any]:
try:
return await invoke_device_plugin_adapter(
adapter,
return await invoke_device_plugin_runtime(
runtime,
ACTION_STREAM_START_REPLAY,
request.model_dump(),
)
@@ -81,12 +84,12 @@ def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
@router.post("/api/session/stop", deprecated=True)
async def stop_session() -> dict[str, Any]:
return await invoke_device_plugin_adapter(adapter, ACTION_STREAM_STOP, {})
return await invoke_device_plugin_runtime(runtime, ACTION_STREAM_STOP, {})
@router.post("/api/viewer/settings", deprecated=True)
async def update_viewer_settings(request: ViewerSettingsRequest) -> dict[str, Any]:
return await invoke_device_plugin_adapter(
adapter,
return await invoke_device_plugin_runtime(
runtime,
ACTION_VIEWER_SETTINGS_UPDATE,
request.model_dump(),
)
@@ -98,8 +101,8 @@ def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
while True:
await websocket.send_json(
{
"state": await invoke_device_plugin_adapter(
adapter,
"state": await invoke_device_plugin_runtime(
runtime,
ACTION_STATE_READ,
{},
)
+17 -3
View File
@@ -32,6 +32,7 @@ from k1link.web.plugin_runtime import (
PluginActionNotFoundError,
PluginExecutionError,
PluginNotFoundError,
PluginRuntimeUnavailableError,
)
from k1link.web.session_api import build_session_router
@@ -175,11 +176,17 @@ async def request_validation_error_handler(
@app.get("/api/health")
def health() -> dict[str, Any]:
runtime_health = plugin_environment.runtime_health
runtimes_ready = all(item["status"] == "ready" for item in runtime_health)
return {
"ok": True,
"status": "ok",
"ok": runtimes_ready,
"status": "ok" if runtimes_ready else "degraded",
"service": "mission-core-control-plane",
"version": __version__,
"plugin_runtimes": {
"ready": sum(item["status"] == "ready" for item in runtime_health),
"total": len(runtime_health),
},
}
@@ -199,6 +206,11 @@ def get_device_models() -> dict[str, Any]:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.get("/api/v1/device-plugin-runtimes")
def get_device_plugin_runtimes() -> dict[str, Any]:
return {"items": list(plugin_environment.runtime_health)}
@app.post("/api/v1/device-plugins/{plugin_id}/actions/{action_id}")
async def invoke_device_plugin_action(
plugin_id: str,
@@ -216,6 +228,8 @@ async def invoke_device_plugin_action(
raise HTTPException(status_code=400, detail=str(exc)) from exc
except PluginExecutionError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except PluginRuntimeUnavailableError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@app.websocket("/api/v1/device-plugins/{plugin_id}/events")
@@ -232,7 +246,7 @@ async def device_plugin_events(websocket: WebSocket, plugin_id: str) -> None:
return
except (PluginNotFoundError, PluginActionNotFoundError):
await websocket.close(code=1008, reason="Device plugin is not available")
except PluginExecutionError:
except (PluginExecutionError, PluginRuntimeUnavailableError):
await websocket.close(code=1011, reason="Device plugin state stream failed")
+69 -14
View File
@@ -1,11 +1,14 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from importlib import import_module
from pathlib import Path
from typing import Any
from uuid import uuid4
from fastapi import APIRouter
from missioncore_plugin_sdk.v0alpha2 import RuntimeHandshakeRequest
from k1link.sessions.plugin_contract import ObservationArchiveSource, RecordingExporter
from k1link.web.plugin_catalog import DevicePluginCatalog, DevicePluginManifest
@@ -38,11 +41,20 @@ class InstalledDevicePluginEnvironment:
@property
def recording_exporters(self) -> dict[str, RecordingExporter]:
return {
contribution.adapter.plugin_id: contribution.observation.recording_exporter
contribution.runtime.descriptor.plugin_id: (
contribution.observation.recording_exporter
)
for contribution in self._contributions
if contribution.observation is not None
}
@property
def runtime_health(self) -> tuple[dict[str, Any], ...]:
return tuple(
contribution.runtime.health().model_dump(mode="json")
for contribution in self._contributions
)
def close(self) -> None:
cleanup_errors = _close_contributions(self._contributions)
if cleanup_errors:
@@ -60,7 +72,7 @@ def _close_contributions(
errors: list[Exception] = []
for contribution in reversed(contributions):
try:
contribution.close()
contribution.runtime.close()
except Exception as exc:
errors.append(exc)
return errors
@@ -110,42 +122,83 @@ def _load_contribution(
)
try:
adapter = contribution.adapter
if adapter.plugin_id != manifest.metadata.id:
runtime = contribution.runtime
descriptor = runtime.descriptor
if descriptor.plugin_id != manifest.metadata.id:
raise DevicePluginCompositionError(
"Device-plugin manifest/runtime id mismatch: "
f"{manifest.metadata.id} != {adapter.plugin_id}"
f"{manifest.metadata.id} != {descriptor.plugin_id}"
)
if descriptor.plugin_version != manifest.metadata.version:
raise DevicePluginCompositionError(
"Device-plugin manifest/runtime version mismatch: "
f"{manifest.metadata.version} != {descriptor.plugin_version}"
)
if manifest.apiVersion not in descriptor.supported_host_api_versions:
raise DevicePluginCompositionError(
f"Device-plugin runtime does not support {manifest.apiVersion}"
)
manifest_actions = frozenset(action.id for action in manifest.spec.actions)
if adapter.action_ids != manifest_actions:
if frozenset(descriptor.action_ids) != manifest_actions:
raise DevicePluginCompositionError(
f"Device-plugin manifest/runtime actions mismatch for {adapter.plugin_id}"
"Device-plugin manifest/runtime actions mismatch for "
f"{descriptor.plugin_id}"
)
observation = contribution.observation
if observation is not None:
if not observation.archives:
raise DevicePluginCompositionError(
f"Device-plugin observation contribution is empty for {adapter.plugin_id}"
"Device-plugin observation contribution is empty for "
f"{descriptor.plugin_id}"
)
archive_ids: set[str] = set()
archive_roots: set[Path] = set()
for archive in observation.archives:
if archive.plugin_id != adapter.plugin_id:
if archive.plugin_id != descriptor.plugin_id:
raise DevicePluginCompositionError(
"Device-plugin observation/runtime id mismatch: "
f"{archive.plugin_id} != {adapter.plugin_id}"
f"{archive.plugin_id} != {descriptor.plugin_id}"
)
if archive.archive_id in archive_ids:
raise DevicePluginCompositionError(
f"Duplicate observation archive id for {adapter.plugin_id}"
f"Duplicate observation archive id for {descriptor.plugin_id}"
)
resolved_root = archive.root.expanduser().resolve()
if resolved_root in archive_roots:
raise DevicePluginCompositionError(
f"Duplicate observation archive root for {adapter.plugin_id}"
f"Duplicate observation archive root for {descriptor.plugin_id}"
)
archive_ids.add(archive.archive_id)
archive_roots.add(resolved_root)
handshake_request = RuntimeHandshakeRequest(
handshake_id=uuid4().hex,
plugin_id=manifest.metadata.id,
plugin_version=manifest.metadata.version,
host_api_version=manifest.apiVersion,
requested_at=datetime.now(UTC),
required_action_ids=tuple(sorted(manifest_actions)),
)
try:
handshake = runtime.handshake(handshake_request)
runtime_health = runtime.health()
except Exception as exc:
raise DevicePluginCompositionError(
f"Device-plugin runtime handshake failed for {descriptor.plugin_id}"
) from exc
if (
handshake.handshake_id != handshake_request.handshake_id
or handshake.descriptor != descriptor
or handshake.accepted_host_api_version != manifest.apiVersion
or runtime_health.runtime_instance_id != handshake.runtime_instance_id
or runtime_health.plugin_id != descriptor.plugin_id
or runtime_health.plugin_version != descriptor.plugin_version
or runtime_health.runtime_protocol_version
!= descriptor.runtime_protocol_version
or runtime_health.status != "ready"
):
raise DevicePluginCompositionError(
f"Device-plugin runtime handshake is inconsistent for {descriptor.plugin_id}"
)
except Exception as exc:
_add_cleanup_notes(exc, _close_contributions((contribution,)))
raise
@@ -155,7 +208,7 @@ def _load_contribution(
def load_installed_device_plugins(
repository_root: Path,
) -> InstalledDevicePluginEnvironment:
"""Load only local, validated manifest entrypoints and cross-check every adapter."""
"""Load local manifests and admit only compatible plugin runtimes."""
catalog = DevicePluginCatalog(repository_root)
manifests = catalog.manifests()
@@ -167,7 +220,9 @@ def load_installed_device_plugins(
_add_cleanup_notes(exc, _close_contributions(loaded))
raise
contributions = tuple(loaded)
dispatcher = DevicePluginDispatcher([contribution.adapter for contribution in contributions])
dispatcher = DevicePluginDispatcher(
[contribution.runtime for contribution in contributions]
)
catalog_ids = {manifest.metadata.id for manifest in manifests}
if set(dispatcher.action_declarations) != catalog_ids:
raise DevicePluginCompositionError(
+175 -32
View File
@@ -3,11 +3,19 @@ from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any, Protocol
from threading import RLock
from typing import Any, Literal, Protocol
from uuid import uuid4
from fastapi import APIRouter
from missioncore_plugin_sdk.v0alpha2 import RuntimeActionInvocation, RuntimeActionResult
from missioncore_plugin_sdk.v0alpha2 import (
RuntimeActionInvocation,
RuntimeActionResult,
RuntimeHandshakeRequest,
RuntimeHandshakeResult,
RuntimeHealthSnapshot,
RuntimePluginDescriptor,
)
from pydantic import BaseModel, ConfigDict, Field
from k1link.sessions.plugin_contract import ObservationRuntimeContribution
@@ -32,14 +40,18 @@ def _noop() -> None:
return None
@dataclass(frozen=True)
class DevicePluginRuntimeContribution:
"""One reviewed backend plugin contribution loaded from its manifest factory."""
class DevicePluginRuntimeTransport(Protocol):
"""Replaceable control-plane seam between Core and one plugin runtime."""
adapter: DevicePluginActionAdapter
legacy_routers: tuple[APIRouter, ...] = ()
observation: ObservationRuntimeContribution | None = None
close: Callable[[], None] = _noop
descriptor: RuntimePluginDescriptor
def handshake(self, request: RuntimeHandshakeRequest) -> RuntimeHandshakeResult: ...
def health(self) -> RuntimeHealthSnapshot: ...
async def invoke(self, invocation: RuntimeActionInvocation) -> RuntimeActionResult: ...
def close(self) -> None: ...
class PluginNotFoundError(LookupError):
@@ -54,19 +66,150 @@ class PluginExecutionError(RuntimeError):
"""A validated plugin action failed while talking to its device/runtime."""
class PluginRuntimeCompatibilityError(RuntimeError):
"""A plugin runtime cannot satisfy the reviewed manifest/host contract."""
class PluginRuntimeUnavailableError(RuntimeError):
"""A plugin runtime has not completed activation or has already stopped."""
class InProcessDevicePluginRuntime:
"""Laboratory transport preserving the future process boundary in-process."""
def __init__(
self,
adapter: DevicePluginActionAdapter,
descriptor: RuntimePluginDescriptor,
*,
close: Callable[[], None] = _noop,
) -> None:
if adapter.plugin_id != descriptor.plugin_id:
raise PluginRuntimeCompatibilityError(
"In-process adapter/runtime descriptor plugin id mismatch"
)
if adapter.action_ids != frozenset(descriptor.action_ids):
raise PluginRuntimeCompatibilityError(
"In-process adapter/runtime descriptor actions mismatch"
)
self._adapter = adapter
self.descriptor = descriptor
self._close = close
self._runtime_instance_id = f"{descriptor.plugin_id}:{uuid4().hex}"
self._status: Literal["starting", "ready", "stopped"] = "starting"
self._lock = RLock()
def handshake(self, request: RuntimeHandshakeRequest) -> RuntimeHandshakeResult:
with self._lock:
if self._status == "stopped":
raise PluginRuntimeUnavailableError(
f"Device plugin runtime is stopped: {self.descriptor.plugin_id}"
)
if request.plugin_id != self.descriptor.plugin_id:
raise PluginRuntimeCompatibilityError(
"Runtime handshake plugin id does not match the descriptor"
)
if request.plugin_version != self.descriptor.plugin_version:
raise PluginRuntimeCompatibilityError(
"Runtime handshake plugin version does not match the descriptor"
)
if request.runtime_protocol_version != self.descriptor.runtime_protocol_version:
raise PluginRuntimeCompatibilityError(
"Runtime handshake protocol version is not supported"
)
if request.host_api_version not in self.descriptor.supported_host_api_versions:
raise PluginRuntimeCompatibilityError(
"Runtime does not support the manifest host API version"
)
if frozenset(request.required_action_ids) != frozenset(
self.descriptor.action_ids
):
raise PluginRuntimeCompatibilityError(
"Runtime handshake actions do not match the manifest"
)
self._status = "ready"
return RuntimeHandshakeResult(
handshake_id=request.handshake_id,
runtime_instance_id=self._runtime_instance_id,
accepted_host_api_version=request.host_api_version,
descriptor=self.descriptor,
ready_at=datetime.now(UTC),
)
def health(self) -> RuntimeHealthSnapshot:
with self._lock:
status = self._status
return RuntimeHealthSnapshot(
runtime_instance_id=self._runtime_instance_id,
plugin_id=self.descriptor.plugin_id,
plugin_version=self.descriptor.plugin_version,
runtime_protocol_version=self.descriptor.runtime_protocol_version,
status=status,
observed_at=datetime.now(UTC),
)
async def invoke(self, invocation: RuntimeActionInvocation) -> RuntimeActionResult:
with self._lock:
if self._status != "ready":
raise PluginRuntimeUnavailableError(
f"Device plugin runtime is not ready: {self.descriptor.plugin_id}"
)
if invocation.plugin_id != self.descriptor.plugin_id:
raise PluginActionNotFoundError("Runtime invocation targets another plugin")
if invocation.action_id not in self.descriptor.action_ids:
raise PluginActionNotFoundError(
f"Device plugin {self.descriptor.plugin_id} does not declare action "
f"{invocation.action_id}"
)
output = await self._adapter.invoke(invocation)
return RuntimeActionResult(
invocation_id=invocation.invocation_id,
plugin_id=invocation.plugin_id,
action_id=invocation.action_id,
completed_at=datetime.now(UTC),
output=dict(output),
)
def close(self) -> None:
with self._lock:
if self._status == "stopped":
return
self._status = "stopped"
self._close()
@dataclass(frozen=True)
class DevicePluginRuntimeContribution:
"""One reviewed backend plugin contribution loaded from its manifest factory."""
runtime: DevicePluginRuntimeTransport
legacy_routers: tuple[APIRouter, ...] = ()
observation: ObservationRuntimeContribution | None = None
class DevicePluginDispatcher:
"""Host-owned dispatcher for allowlisted, namespaced plugin actions."""
def __init__(self, adapters: list[DevicePluginActionAdapter]) -> None:
self._adapters: dict[str, DevicePluginActionAdapter] = {}
for adapter in adapters:
if adapter.plugin_id in self._adapters:
raise ValueError(f"Duplicate runtime device-plugin id: {adapter.plugin_id}")
self._adapters[adapter.plugin_id] = adapter
def __init__(self, runtimes: list[DevicePluginRuntimeTransport]) -> None:
self._runtimes: dict[str, DevicePluginRuntimeTransport] = {}
for runtime in runtimes:
plugin_id = runtime.descriptor.plugin_id
if plugin_id in self._runtimes:
raise ValueError(f"Duplicate runtime device-plugin id: {plugin_id}")
self._runtimes[plugin_id] = runtime
@property
def action_declarations(self) -> dict[str, frozenset[str]]:
return {plugin_id: adapter.action_ids for plugin_id, adapter in self._adapters.items()}
return {
plugin_id: frozenset(runtime.descriptor.action_ids)
for plugin_id, runtime in self._runtimes.items()
}
@property
def health_snapshots(self) -> dict[str, RuntimeHealthSnapshot]:
return {
plugin_id: runtime.health() for plugin_id, runtime in self._runtimes.items()
}
async def invoke(
self,
@@ -74,36 +217,36 @@ class DevicePluginDispatcher:
action_id: str,
payload: Mapping[str, Any],
) -> dict[str, Any]:
adapter = self._adapters.get(plugin_id)
if adapter is None:
runtime = self._runtimes.get(plugin_id)
if runtime is None:
raise PluginNotFoundError(f"Device plugin is not installed: {plugin_id}")
return await invoke_device_plugin_adapter(adapter, action_id, payload)
return await invoke_device_plugin_runtime(runtime, action_id, payload)
async def invoke_device_plugin_adapter(
adapter: DevicePluginActionAdapter,
async def invoke_device_plugin_runtime(
runtime: DevicePluginRuntimeTransport,
action_id: str,
payload: Mapping[str, Any],
) -> dict[str, Any]:
"""Validate SDK request/result envelopes around one plugin action call."""
if action_id not in adapter.action_ids:
descriptor = runtime.descriptor
if action_id not in descriptor.action_ids:
raise PluginActionNotFoundError(
f"Device plugin {adapter.plugin_id} does not declare action {action_id}"
f"Device plugin {descriptor.plugin_id} does not declare action {action_id}"
)
invocation = RuntimeActionInvocation(
invocation_id=uuid4().hex,
plugin_id=adapter.plugin_id,
plugin_id=descriptor.plugin_id,
action_id=action_id,
requested_at=datetime.now(UTC),
parameters=dict(payload),
)
output = await adapter.invoke(invocation)
result = RuntimeActionResult(
invocation_id=invocation.invocation_id,
plugin_id=invocation.plugin_id,
action_id=invocation.action_id,
completed_at=datetime.now(UTC),
output=dict(output),
)
result = await runtime.invoke(invocation)
if (
result.invocation_id != invocation.invocation_id
or result.plugin_id != invocation.plugin_id
or result.action_id != invocation.action_id
):
raise PluginExecutionError("Plugin runtime returned an uncorrelated action result")
return dict(result.output)