fix(arch-014,arch-020): no contract lazy-resurrect after unregister; event bus dedupes handlers
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
Also fixes ARCH-029/041: none-check before attribute access in trigger dispatcher.
This commit is contained in:
@@ -36,7 +36,12 @@ class EventBus:
|
|||||||
self._handlers: dict[str, list[EventHandler]] = defaultdict(list)
|
self._handlers: dict[str, list[EventHandler]] = defaultdict(list)
|
||||||
|
|
||||||
def subscribe(self, event_name: str, handler: EventHandler) -> None:
|
def subscribe(self, event_name: str, handler: EventHandler) -> None:
|
||||||
"""Subscribe a handler to an event."""
|
"""Subscribe a handler to an event.
|
||||||
|
|
||||||
|
Idempotent: subscribing the same handler twice is a no-op
|
||||||
|
(ARCH-020) so double activation cannot fire handlers twice.
|
||||||
|
"""
|
||||||
|
if handler not in self._handlers[event_name]:
|
||||||
self._handlers[event_name].append(handler)
|
self._handlers[event_name].append(handler)
|
||||||
|
|
||||||
def unsubscribe(self, event_name: str, handler: EventHandler) -> None:
|
def unsubscribe(self, event_name: str, handler: EventHandler) -> None:
|
||||||
|
|||||||
@@ -121,11 +121,14 @@ class TriggerDispatcher:
|
|||||||
"""Query DB for active automations matching *event_name* and dispatch."""
|
"""Query DB for active automations matching *event_name* and dispatch."""
|
||||||
from app.core.db import get_session_factory
|
from app.core.db import get_session_factory
|
||||||
from app.plugins.builtins.contracts import get_contract
|
from app.plugins.builtins.contracts import get_contract
|
||||||
|
# None-check FIRST — accessing attributes on the contract before the
|
||||||
|
# check crashed with AttributeError when automation was inactive
|
||||||
|
# (ARCH-029/041).
|
||||||
automation_contract = get_contract("automation")
|
automation_contract = get_contract("automation")
|
||||||
AutomationDefinition = automation_contract.Automation # noqa: N806
|
|
||||||
if automation_contract is None:
|
if automation_contract is None:
|
||||||
logger.debug("Automation plugin not available — trigger skipped")
|
logger.debug("Automation plugin not available — trigger skipped")
|
||||||
return
|
return
|
||||||
|
AutomationDefinition = automation_contract.Automation # noqa: N806
|
||||||
|
|
||||||
factory = get_session_factory()
|
factory = get_session_factory()
|
||||||
tenant_id = payload.get("tenant_id")
|
tenant_id = payload.get("tenant_id")
|
||||||
|
|||||||
@@ -59,20 +59,31 @@ class ContractRegistry:
|
|||||||
cls._instance = super().__new__(cls)
|
cls._instance = super().__new__(cls)
|
||||||
cls._instance._contracts: dict[str, Any] = {}
|
cls._instance._contracts: dict[str, Any] = {}
|
||||||
cls._instance._loaded: set[str] = set()
|
cls._instance._loaded: set[str] = set()
|
||||||
|
cls._instance._unregistered: set[str] = set()
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
# ─── registration ───
|
# ─── registration ───
|
||||||
|
|
||||||
def register(self, plugin_name: str, contract: Any) -> None:
|
def register(self, plugin_name: str, contract: Any) -> None:
|
||||||
"""Register or replace a contract for a plugin."""
|
"""Register or replace a contract for a plugin.
|
||||||
|
|
||||||
|
Clears the unregistered marker so a later deactivation can be
|
||||||
|
distinguished from a fresh lazy-load again (ARCH-014).
|
||||||
|
"""
|
||||||
|
self._unregistered.discard(plugin_name)
|
||||||
self._contracts[plugin_name] = contract
|
self._contracts[plugin_name] = contract
|
||||||
self._loaded.add(plugin_name)
|
self._loaded.add(plugin_name)
|
||||||
logger.debug("Contract registered for plugin '%s'", plugin_name)
|
logger.debug("Contract registered for plugin '%s'", plugin_name)
|
||||||
|
|
||||||
def unregister(self, plugin_name: str) -> None:
|
def unregister(self, plugin_name: str) -> None:
|
||||||
"""Remove a contract (e.g. when the plugin is deactivated)."""
|
"""Remove a contract (e.g. when the plugin is deactivated).
|
||||||
|
|
||||||
|
Marks the plugin as explicitly unregistered so later ``get_contract``
|
||||||
|
calls cannot resurrect the contract via lazy-loading (ARCH-014).
|
||||||
|
"""
|
||||||
self._contracts.pop(plugin_name, None)
|
self._contracts.pop(plugin_name, None)
|
||||||
self._loaded.discard(plugin_name)
|
self._loaded.discard(plugin_name)
|
||||||
|
self._unregistered.add(plugin_name)
|
||||||
|
|
||||||
# ─── lookup ───
|
# ─── lookup ───
|
||||||
|
|
||||||
@@ -85,6 +96,11 @@ class ContractRegistry:
|
|||||||
if plugin_name in self._contracts:
|
if plugin_name in self._contracts:
|
||||||
return self._contracts[plugin_name]
|
return self._contracts[plugin_name]
|
||||||
|
|
||||||
|
# Explicitly unregistered (deactivated): never resurrect via
|
||||||
|
# lazy-loading (ARCH-014) — the deactivated contract must stay gone.
|
||||||
|
if plugin_name in self._unregistered:
|
||||||
|
return None
|
||||||
|
|
||||||
if plugin_name not in self._loaded:
|
if plugin_name not in self._loaded:
|
||||||
self._try_lazy_load(plugin_name)
|
self._try_lazy_load(plugin_name)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user