feat: Core Plugin system — is_core flag, topological sort dependency resolution, deactivation protection

This commit is contained in:
leocrm-bot
2026-07-07 07:44:02 +02:00
parent 1694b14c55
commit bb4b0ce514
8 changed files with 147 additions and 1 deletions
+45
View File
@@ -0,0 +1,45 @@
"""Add is_core boolean column to plugins table.
Marks core plugins (permissions, entity_links, tags) as is_core=True so they
cannot be deactivated and are always loaded first.
Revision ID: 0016_plugin_is_core
Revises: 0015_rls_policies
Create Date: 2026-07-07
"""
from __future__ import annotations
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0016_plugin_is_core"
down_revision: Union[str, None] = "0015_rls_policies"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
CORE_PLUGIN_NAMES = ["permissions", "entity_links", "tags"]
def upgrade() -> None:
# Add is_core column with server default False so existing rows get False
op.add_column(
"plugins",
sa.Column("is_core", sa.Boolean(), nullable=False, server_default=sa.text("false")),
)
# Mark known core plugins as is_core=True
op.execute(
sa.text(
"UPDATE plugins SET is_core = true WHERE name IN :names"
).bindparams(sa.bindparam("names", expanding=True)).bindparams(names=CORE_PLUGIN_NAMES)
)
def downgrade() -> None:
op.drop_column("plugins", "is_core")
+2 -1
View File
@@ -112,7 +112,7 @@ async def lifespan(app: FastAPI):
async_session = async_sessionmaker(get_engine(), expire_on_commit=False) async_session = async_sessionmaker(get_engine(), expire_on_commit=False)
async with async_session() as db: async with async_session() as db:
for name in registry._plugins: for name in registry.resolve_load_order():
plugin = registry.get_plugin(name) plugin = registry.get_plugin(name)
if plugin is None: if plugin is None:
continue continue
@@ -131,6 +131,7 @@ async def lifespan(app: FastAPI):
version=plugin.manifest.version, version=plugin.manifest.version,
status="installed", status="installed",
active=True, active=True,
is_core=plugin.manifest.is_core,
) )
db.add(plugin_record) db.add(plugin_record)
await db.flush() await db.flush()
+1
View File
@@ -31,6 +31,7 @@ class Plugin(Base, TimestampMixin):
status: Mapped[str] = mapped_column(String(20), nullable=False, default="installed") status: Mapped[str] = mapped_column(String(20), nullable=False, default="installed")
installed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) installed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_core: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
config: Mapped[dict[str, Any] | None] = mapped_column( config: Mapped[dict[str, Any] | None] = mapped_column(
Text, Text,
nullable=True, # JSON string for plugin configuration nullable=True, # JSON string for plugin configuration
@@ -37,6 +37,7 @@ class EntityLinksPlugin(BasePlugin):
events=["company.deleted", "contact.deleted"], events=["company.deleted", "contact.deleted"],
migrations=["0001_initial.sql"], migrations=["0001_initial.sql"],
permissions=[], permissions=[],
is_core=True,
) )
async def on_company_deleted(self, payload: dict[str, Any]) -> None: async def on_company_deleted(self, payload: dict[str, Any]) -> None:
@@ -30,4 +30,5 @@ class PermissionsPlugin(BasePlugin):
events=[], events=[],
migrations=["0001_initial.sql"], migrations=["0001_initial.sql"],
permissions=[], permissions=[],
is_core=True,
) )
+1
View File
@@ -25,4 +25,5 @@ class TagsPlugin(BasePlugin):
events=[], events=[],
migrations=["0001_initial.sql"], migrations=["0001_initial.sql"],
permissions=[], permissions=[],
is_core=True,
) )
+3
View File
@@ -39,6 +39,9 @@ class PluginManifest(BaseModel):
permissions: list[str] = Field( permissions: list[str] = Field(
default_factory=list, description="Required permissions for this plugin" default_factory=list, description="Required permissions for this plugin"
) )
is_core: bool = Field(
default=False, description="Whether this is a core plugin that cannot be deactivated"
)
@field_validator("name") @field_validator("name")
@classmethod @classmethod
+93
View File
@@ -180,6 +180,78 @@ class PluginRegistry:
f"Activate them first." f"Activate them first."
) )
# ── Topological Sort & Dependents ──
def resolve_load_order(self) -> list[str]:
"""Return plugin names in dependency-aware load order using Kahn's algorithm.
Core plugins (is_core=True) are emitted first, then non-core plugins
in topological order based on declared dependencies.
Raises RuntimeError on circular dependencies or missing dependencies.
"""
names = set(self._plugins.keys())
# Validate that all declared dependencies exist in the registry
for name in names:
plugin = self._plugins[name]
for dep in plugin.manifest.dependencies:
if dep not in names:
raise RuntimeError(
f"Plugin '{name}' depends on '{dep}' which is not discovered. "
f"Available plugins: {', '.join(sorted(names))}"
)
# Build adjacency: dep -> list of plugins that depend on it
# and in-degree: plugin -> count of unsatisfied dependencies
in_degree: dict[str, int] = {name: 0 for name in names}
dependents_map: dict[str, list[str]] = {name: [] for name in names}
for name in names:
plugin = self._plugins[name]
for dep in plugin.manifest.dependencies:
dependents_map[dep].append(name)
in_degree[name] += 1
# Single Kahn pass with priority: core plugins before non-core at each level.
# Queue entries are tuples: (priority, name) where priority 0 = core, 1 = non-core.
import heapq
heap: list[tuple[int, str]] = []
for name in names:
if in_degree[name] == 0:
priority = 0 if self._plugins[name].manifest.is_core else 1
heapq.heappush(heap, (priority, name))
result: list[str] = []
while heap:
_, current = heapq.heappop(heap)
result.append(current)
for dependent in dependents_map[current]:
in_degree[dependent] -= 1
if in_degree[dependent] == 0:
priority = 0 if self._plugins[dependent].manifest.is_core else 1
heapq.heappush(heap, (priority, dependent))
if len(result) != len(names):
unresolved = names - set(result)
raise RuntimeError(
f"Circular dependency detected among plugins: {', '.join(sorted(unresolved))}"
)
return result
def get_dependents(self, name: str) -> list[str]:
"""Return list of discovered plugin names that depend on the given plugin.
This examines manifest.dependencies of all discovered plugins.
"""
dependents: list[str] = []
for plugin_name, plugin in self._plugins.items():
if name in plugin.manifest.dependencies:
dependents.append(plugin_name)
return sorted(dependents)
# ── Permission Validation (soft check) ── # ── Permission Validation (soft check) ──
def _check_permissions(self, name: str) -> list[str]: def _check_permissions(self, name: str) -> list[str]:
@@ -289,6 +361,7 @@ class PluginRegistry:
status="installed", status="installed",
installed=True, installed=True,
active=False, active=False,
is_core=plugin.manifest.is_core,
) )
db.add(record) db.add(record)
await db.flush() await db.flush()
@@ -370,6 +443,26 @@ class PluginRegistry:
if not record.active and record.status == "inactive": if not record.active and record.status == "inactive":
return record return record
# Reject deactivation of core plugins
if plugin.manifest.is_core or record.is_core:
raise ValueError(
f"Plugin '{name}' is a core plugin and cannot be deactivated"
)
# Reject deactivation if other active plugins depend on it
dependents = self.get_dependents(name)
active_dependents: list[str] = []
for dep_name in dependents:
dep_record = await self._get_plugin_record(db, dep_name)
if dep_record is not None and dep_record.active:
active_dependents.append(dep_name)
if active_dependents:
raise ValueError(
f"Plugin '{name}' cannot be deactivated because active plugins "
f"depend on it: {', '.join(active_dependents)}"
)
# Call on_deactivate hook (unregisters event listeners) # Call on_deactivate hook (unregisters event listeners)
await plugin.on_deactivate(db, self._container, self._event_bus) await plugin.on_deactivate(db, self._container, self._event_bus)