Files
agentic-mobile-control/cloud/plugins.py
T
q792602257 725bf4cd9c fix(plugin-system): implement register_driver_type and stop discover() aborting on one bad manifest
- driver/registry.py never actually defined register_driver_type, so
  the plugin-system's 'driver plugin registered successfully' scenario
  was unreachable in production (only simulated via test monkeypatch).
  Added a real implementation wired into the existing driver factory
  registry.
- PluginRegistry.discover() only caught PluginValidationError/
  DuplicatePluginError, so a driver-kind manifest that failed wiring
  (DriverRegistryUnavailableError/PluginTargetResolutionError) aborted
  the whole scan, silently skipping co-located tool/skill manifests.
  Now caught and skipped per manifest instead.

openspec: plugin-system capability, archived change cloud-runtime
2026-07-07 08:31:00 +08:00

259 lines
9.2 KiB
Python

"""Plugin manifest and registry: discover and wire drivers/tools/skills.
Capability: ``plugin-system``.
Two discovery paths (Python ``importlib.metadata`` entry points in group
``device_agent_runtime.plugins``, and a local ``plugin.json`` file scan) feed
into one validation + registration path. Only ``driver``-kind plugins are
concretely wired (into ``driver/registry.py``'s extension point); ``tool``- and
``skill``-kind manifests are accepted and stored as ``wired=False``.
"""
from __future__ import annotations
import importlib
import importlib.metadata
import json
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
if TYPE_CHECKING:
from cloud.store import CloudStore
logger = logging.getLogger(__name__)
ENTRY_POINT_GROUP = "device_agent_runtime.plugins"
PLUGIN_MANIFEST_FILENAME = "plugin.json"
EntryPointKind = Literal["driver", "tool", "skill"]
_VALID_ENTRY_POINT_KINDS = ("driver", "tool", "skill")
@dataclass(frozen=True)
class PluginManifest:
"""A plugin's declaration: who it is, what kind it is, where it lives."""
name: str
version: str
entry_point_kind: EntryPointKind
target: str
def __post_init__(self) -> None:
if not self.name:
raise PluginValidationError("plugin manifest 'name' is required")
if not self.version:
raise PluginValidationError("plugin manifest 'version' is required")
if not self.target:
raise PluginValidationError("plugin manifest 'target' is required")
if self.entry_point_kind not in _VALID_ENTRY_POINT_KINDS:
raise PluginValidationError(
f"plugin manifest 'entry_point_kind' must be one of "
f"{_VALID_ENTRY_POINT_KINDS}, got {self.entry_point_kind!r}"
)
def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"version": self.version,
"entry_point_kind": self.entry_point_kind,
"target": self.target,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "PluginManifest":
return cls(
name=str(data["name"]),
version=str(data["version"]),
entry_point_kind=str(data["entry_point_kind"]), # type: ignore[arg-type]
target=str(data["target"]),
)
@dataclass
class DiscoveryResult:
"""The outcome of running both discovery scans."""
registered: list[PluginManifest] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
class PluginValidationError(ValueError):
"""Raised when a plugin manifest fails schema validation."""
class DuplicatePluginError(ValueError):
"""Raised when a plugin name is already registered."""
class DriverRegistryUnavailableError(RuntimeError):
"""Raised when ``driver.registry.register_driver_type`` is not importable."""
class PluginTargetResolutionError(RuntimeError):
"""Raised when a plugin target cannot be resolved to a callable."""
# Exception types `register()` can raise for a single bad manifest (validation
# failure, name conflict, or driver-wiring failure). `discover()` catches only
# these so one bad manifest is skipped/logged, per spec's "malformed manifest
# file is skipped, not fatal" resilience intent -- anything else propagates as
# a genuine programmer error.
_PLUGIN_REGISTRATION_FAILURES = (
PluginValidationError,
DuplicatePluginError,
DriverRegistryUnavailableError,
PluginTargetResolutionError,
)
class PluginRegistry:
"""Validates, persists, and wires plugins discovered or submitted directly."""
def __init__(self, store: "CloudStore") -> None:
self.store = store
def register(self, manifest: PluginManifest) -> PluginManifest:
"""Validate, persist, and (for drivers) wire a plugin manifest."""
if self.store.get_plugin(manifest.name) is not None:
raise DuplicatePluginError(
f"a plugin named {manifest.name!r} is already registered"
)
wired = False
if manifest.entry_point_kind == "driver":
wired = self._wire_driver(manifest)
# tool/skill-kind: stored with wired=False, no other resolution attempted.
self.store.save_plugin(manifest, wired=wired)
if not wired:
logger.info(
"plugin %r registered but not wired to any execution path",
manifest.name,
)
return manifest
def list(self) -> list[tuple[PluginManifest, bool]]:
return self.store.list_plugins()
def discover(self, scan_path: str | Path | None = None) -> DiscoveryResult:
result = DiscoveryResult()
for manifest in self.discover_entry_points():
try:
self.register(manifest)
result.registered.append(manifest)
except _PLUGIN_REGISTRATION_FAILURES as exc:
result.errors.append(
f"entry-point plugin {manifest.name!r}: {exc}"
)
if scan_path is not None:
for manifest in self.discover_manifest_files(scan_path):
try:
self.register(manifest)
result.registered.append(manifest)
except _PLUGIN_REGISTRATION_FAILURES as exc:
result.errors.append(
f"manifest-file plugin {manifest.name!r}: {exc}"
)
return result
def discover_entry_points(self) -> list[PluginManifest]:
manifests: list[PluginManifest] = []
try:
entry_points = importlib.metadata.entry_points(group=ENTRY_POINT_GROUP)
except TypeError:
# Older Pythons: entry_points() returns a dict-like view.
all_eps = importlib.metadata.entry_points()
entry_points = all_eps.get(ENTRY_POINT_GROUP, []) # type: ignore[union-attr]
for entry_point in entry_points:
try:
loaded = entry_point.load()
except Exception as exc: # pragma: no cover - exercised via fake eps in tests
logger.warning("entry point %r failed to load: %s", entry_point.name, exc)
continue
manifest = _coerce_to_manifest(loaded)
if manifest is not None:
manifests.append(manifest)
return manifests
def discover_manifest_files(self, scan_path: str | Path) -> list[PluginManifest]:
root = Path(scan_path)
manifests: list[PluginManifest] = []
if not root.exists():
return manifests
for path in sorted(root.rglob(PLUGIN_MANIFEST_FILENAME)):
try:
raw = path.read_text(encoding="utf-8")
data = json.loads(raw)
manifest = PluginManifest.from_dict(data)
except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc:
logger.warning("malformed plugin manifest %s: %s", path, exc)
continue
manifests.append(manifest)
return manifests
def _wire_driver(self, manifest: PluginManifest) -> bool:
register_fn = _resolve_driver_register_function()
if register_fn is None:
raise DriverRegistryUnavailableError(
"driver.registry.register_driver_type is not importable; "
"the driver-registry extension point must be available to "
"register driver-kind plugins"
)
builder = _resolve_target(manifest.target)
register_fn(manifest.name, builder)
return True
def _resolve_driver_register_function():
"""Return ``driver.registry.register_driver_type`` if importable, else None."""
try:
module = importlib.import_module("driver.registry")
except ImportError:
return None
fn = getattr(module, "register_driver_type", None)
if not callable(fn):
return None
return fn
def _resolve_target(target: str):
"""Resolve ``module.sub:attribute`` to a callable."""
if ":" not in target:
raise PluginTargetResolutionError(
f"plugin target {target!r} must be 'module.path:attribute'"
)
module_path, _, attribute = target.partition(":")
if not module_path or not attribute:
raise PluginTargetResolutionError(
f"plugin target {target!r} must be 'module.path:attribute'"
)
try:
module = importlib.import_module(module_path)
except ImportError as exc:
raise PluginTargetResolutionError(
f"could not import plugin target module {module_path!r}: {exc}"
) from exc
obj = getattr(module, attribute, None)
if obj is None or not callable(obj):
raise PluginTargetResolutionError(
f"plugin target {target!r} did not resolve to a callable"
)
return obj
def _coerce_to_manifest(loaded: Any) -> PluginManifest | None:
if isinstance(loaded, PluginManifest):
return loaded
if isinstance(loaded, dict):
try:
return PluginManifest.from_dict(loaded)
except (KeyError, ValueError):
return None
if callable(loaded):
try:
result = loaded()
except Exception: # pragma: no cover - defensive
return None
return _coerce_to_manifest(result)
return None