feat(plugins): add runtime handshake boundary
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user