feat: Core Plugin system — is_core flag, topological sort dependency resolution, deactivation protection
This commit is contained in:
+2
-1
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -25,4 +25,5 @@ class TagsPlugin(BasePlugin):
|
||||
events=[],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=[],
|
||||
is_core=True,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user