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
+2 -1
View File
@@ -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()
+1
View File
@@ -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
@@ -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:
@@ -30,4 +30,5 @@ class PermissionsPlugin(BasePlugin):
events=[],
migrations=["0001_initial.sql"],
permissions=[],
is_core=True,
)
+1
View File
@@ -25,4 +25,5 @@ class TagsPlugin(BasePlugin):
events=[],
migrations=["0001_initial.sql"],
permissions=[],
is_core=True,
)
+3
View File
@@ -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
+93
View File
@@ -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)