Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b04cda774b | |||
| 982b4c9353 | |||
| 1d6152fb82 | |||
| 337d78ef53 |
@@ -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")
|
||||||
|
|||||||
+9
-8
@@ -284,22 +284,23 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info(f"Plugin {name} is inactive — skipping activation")
|
logger.info(f"Plugin {name} is inactive — skipping activation")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Activate plugin with a FRESH session per plugin to avoid RLS state leakage
|
# Activate plugin ONCE per process (ARCH-002 fix): a fresh session with
|
||||||
# RLS fail-closed requires app.current_tenant_id for tenant-table writes.
|
# the first tenant's RLS context satisfies fail-closed RLS for any
|
||||||
# Plugin activation may fail on duplicate cron job inserts — this is harmless
|
# tenant-table writes during activation. Plugins that need per-tenant
|
||||||
# since cron jobs already exist from previous startups.
|
# data must seed it themselves (e.g. via the default-tenant mechanism).
|
||||||
|
# Calling on_activate once prevents duplicate event listeners, cron
|
||||||
|
# jobs, mini-apps and other contributions at multi-tenant startups.
|
||||||
plugin_activated = False
|
plugin_activated = False
|
||||||
for tenant_id in all_tenant_ids:
|
if all_tenant_ids:
|
||||||
try:
|
try:
|
||||||
async with async_session() as plugin_db:
|
async with async_session() as plugin_db:
|
||||||
await set_tenant_context(plugin_db, tenant_id)
|
await set_tenant_context(plugin_db, all_tenant_ids[0])
|
||||||
await plugin.on_activate(plugin_db, container, event_bus)
|
await plugin.on_activate(plugin_db, container, event_bus)
|
||||||
await plugin_db.flush()
|
await plugin_db.flush()
|
||||||
await plugin_db.commit()
|
await plugin_db.commit()
|
||||||
plugin_activated = True
|
plugin_activated = True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"[STARTUP] Plugin {name} activation issue for tenant {tenant_id}: {exc}")
|
logger.warning(f"[STARTUP] Plugin {name} activation issue: {exc}")
|
||||||
break
|
|
||||||
|
|
||||||
if plugin_activated:
|
if plugin_activated:
|
||||||
plugin_record.status = "active"
|
plugin_record.status = "active"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -608,6 +608,22 @@ class PluginRegistry:
|
|||||||
for warning in perm_warnings:
|
for warning in perm_warnings:
|
||||||
logger.warning(warning)
|
logger.warning(warning)
|
||||||
|
|
||||||
|
# Register permissions and entity models BEFORE on_activate (ARCH-001 fix):
|
||||||
|
# the activation hook may already rely on its own permissions/entities being
|
||||||
|
# resolvable (e.g. tools declaring required_permission).
|
||||||
|
from app.core.permission_registry import (
|
||||||
|
get_permission_registry,
|
||||||
|
register_plugin_permissions,
|
||||||
|
)
|
||||||
|
|
||||||
|
if plugin.manifest.permissions:
|
||||||
|
register_plugin_permissions(name, plugin.manifest.permissions)
|
||||||
|
get_permission_registry()._active_plugins.add(name)
|
||||||
|
for entity_type, model_class in plugin.get_entity_models().items():
|
||||||
|
from app.services.entity_permission_service import register_entity_model
|
||||||
|
|
||||||
|
register_entity_model(entity_type, model_class)
|
||||||
|
|
||||||
# Call on_activate hook (registers event listeners)
|
# Call on_activate hook (registers event listeners)
|
||||||
await plugin.on_activate(db, self._container, self._event_bus)
|
await plugin.on_activate(db, self._container, self._event_bus)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.deps import require_admin, require_permission
|
from app.deps import require_admin, require_permission
|
||||||
|
from app.deps import get_current_user
|
||||||
from app.plugins.migration_runner import MigrationValidationError
|
from app.plugins.migration_runner import MigrationValidationError
|
||||||
from app.services.plugin_service import get_plugin_service
|
from app.services.plugin_service import get_plugin_service
|
||||||
|
|
||||||
@@ -94,7 +95,10 @@ async def check_plugin_updates(
|
|||||||
@router.get("/active-manifests")
|
@router.get("/active-manifests")
|
||||||
async def get_active_manifests(
|
async def get_active_manifests(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("plugins:read")),
|
# ARCH-003 fix: every authenticated user needs the UI manifests for the
|
||||||
|
# dynamic sidebar/routes — the data is pure UI metadata; actual data
|
||||||
|
# access stays protected by each endpoint's own permission.
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Get UI manifests for all active plugins.
|
"""Get UI manifests for all active plugins.
|
||||||
|
|
||||||
|
|||||||
@@ -530,7 +530,7 @@ class TestAgentPermissions:
|
|||||||
) as mock_resolve:
|
) as mock_resolve:
|
||||||
mock_resolve.return_value = _permissions(permissions=["mail:read"])
|
mock_resolve.return_value = _permissions(permissions=["mail:read"])
|
||||||
with patch(
|
with patch(
|
||||||
"app.plugins.builtins.ai_assistant.contracts.get_tool_registry",
|
"app.ai.tool_registry.get_tool_registry",
|
||||||
return_value=tool_registry,
|
return_value=tool_registry,
|
||||||
):
|
):
|
||||||
ctx = await resolve_agent_permissions(
|
ctx = await resolve_agent_permissions(
|
||||||
@@ -575,7 +575,7 @@ class TestAgentPermissions:
|
|||||||
) as mock_resolve:
|
) as mock_resolve:
|
||||||
mock_resolve.return_value = _permissions(permissions=["mail:read"])
|
mock_resolve.return_value = _permissions(permissions=["mail:read"])
|
||||||
with patch(
|
with patch(
|
||||||
"app.plugins.builtins.ai_assistant.contracts.get_tool_registry",
|
"app.ai.tool_registry.get_tool_registry",
|
||||||
return_value=tool_registry,
|
return_value=tool_registry,
|
||||||
):
|
):
|
||||||
ctx = await resolve_agent_permissions(
|
ctx = await resolve_agent_permissions(
|
||||||
@@ -1015,7 +1015,7 @@ class TestContextBuilder:
|
|||||||
])
|
])
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"app.plugins.builtins.ai_assistant.contracts.get_tool_registry",
|
"app.ai.tool_registry.get_tool_registry",
|
||||||
return_value=tool_registry,
|
return_value=tool_registry,
|
||||||
):
|
):
|
||||||
messages = await build_agent_context(
|
messages = await build_agent_context(
|
||||||
|
|||||||
Reference in New Issue
Block a user