From bb4b0ce51421c0d3454fe379e04500806269789a Mon Sep 17 00:00:00 2001 From: leocrm-bot Date: Tue, 7 Jul 2026 07:44:02 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Core=20Plugin=20system=20=E2=80=94=20is?= =?UTF-8?q?=5Fcore=20flag,=20topological=20sort=20dependency=20resolution,?= =?UTF-8?q?=20deactivation=20protection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- alembic/versions/0016_plugin_is_core.py | 45 ++++++++++ app/main.py | 3 +- app/models/plugin.py | 1 + app/plugins/builtins/entity_links/plugin.py | 1 + app/plugins/builtins/permissions/plugin.py | 1 + app/plugins/builtins/tags/plugin.py | 1 + app/plugins/manifest.py | 3 + app/plugins/registry.py | 93 +++++++++++++++++++++ 8 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/0016_plugin_is_core.py diff --git a/alembic/versions/0016_plugin_is_core.py b/alembic/versions/0016_plugin_is_core.py new file mode 100644 index 0000000..4c756c6 --- /dev/null +++ b/alembic/versions/0016_plugin_is_core.py @@ -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") diff --git a/app/main.py b/app/main.py index 98c5d31..9259c48 100644 --- a/app/main.py +++ b/app/main.py @@ -112,7 +112,7 @@ async def lifespan(app: FastAPI): async_session = async_sessionmaker(get_engine(), expire_on_commit=False) async with async_session() as db: - for name in registry._plugins: + for name in registry.resolve_load_order(): plugin = registry.get_plugin(name) if plugin is None: continue @@ -131,6 +131,7 @@ async def lifespan(app: FastAPI): version=plugin.manifest.version, status="installed", active=True, + is_core=plugin.manifest.is_core, ) db.add(plugin_record) await db.flush() diff --git a/app/models/plugin.py b/app/models/plugin.py index c06e4c9..5d95da8 100644 --- a/app/models/plugin.py +++ b/app/models/plugin.py @@ -31,6 +31,7 @@ class Plugin(Base, TimestampMixin): status: Mapped[str] = mapped_column(String(20), nullable=False, default="installed") installed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) 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( Text, nullable=True, # JSON string for plugin configuration diff --git a/app/plugins/builtins/entity_links/plugin.py b/app/plugins/builtins/entity_links/plugin.py index 33fd946..c3c0659 100644 --- a/app/plugins/builtins/entity_links/plugin.py +++ b/app/plugins/builtins/entity_links/plugin.py @@ -37,6 +37,7 @@ class EntityLinksPlugin(BasePlugin): events=["company.deleted", "contact.deleted"], migrations=["0001_initial.sql"], permissions=[], + is_core=True, ) async def on_company_deleted(self, payload: dict[str, Any]) -> None: diff --git a/app/plugins/builtins/permissions/plugin.py b/app/plugins/builtins/permissions/plugin.py index 184fb29..fad113b 100644 --- a/app/plugins/builtins/permissions/plugin.py +++ b/app/plugins/builtins/permissions/plugin.py @@ -30,4 +30,5 @@ class PermissionsPlugin(BasePlugin): events=[], migrations=["0001_initial.sql"], permissions=[], + is_core=True, ) diff --git a/app/plugins/builtins/tags/plugin.py b/app/plugins/builtins/tags/plugin.py index 0384662..c494970 100644 --- a/app/plugins/builtins/tags/plugin.py +++ b/app/plugins/builtins/tags/plugin.py @@ -25,4 +25,5 @@ class TagsPlugin(BasePlugin): events=[], migrations=["0001_initial.sql"], permissions=[], + is_core=True, ) diff --git a/app/plugins/manifest.py b/app/plugins/manifest.py index 9c849ee..69d0ed0 100644 --- a/app/plugins/manifest.py +++ b/app/plugins/manifest.py @@ -39,6 +39,9 @@ class PluginManifest(BaseModel): permissions: list[str] = Field( 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") @classmethod diff --git a/app/plugins/registry.py b/app/plugins/registry.py index a387b81..0f7c7c0 100644 --- a/app/plugins/registry.py +++ b/app/plugins/registry.py @@ -180,6 +180,78 @@ class PluginRegistry: 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) ── def _check_permissions(self, name: str) -> list[str]: @@ -289,6 +361,7 @@ class PluginRegistry: status="installed", installed=True, active=False, + is_core=plugin.manifest.is_core, ) db.add(record) await db.flush() @@ -370,6 +443,26 @@ class PluginRegistry: if not record.active and record.status == "inactive": 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) await plugin.on_deactivate(db, self._container, self._event_bus)