Phase 02: Plugin System - 3 plans in 3 waves (sequential) - 02-01: Plugin discovery and manifest parsing - 02-02: Lifecycle management and dependency resolution - 02-03: Extension API with type-safe plugin-to-plugin communication - Ready for execution
18 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-plugin-system | 02 | execute | 2 |
|
|
true |
|
Purpose: Plugins need to boot in correct dependency order with proper state tracking. This enables plugins to safely depend on and extend each other. The extension API (02-03) requires running plugins.
Output: SummerPlugin trait, PluginManager service, DependencyResolver, PluginState enum, PluginContext/PluginRegistration types
<execution_context> @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02-plugin-system/02-RESEARCH.md @.planning/phases/02-plugin-system/02-CONTEXT.md @.planning/phases/02-plugin-system/02-01-SUMMARY.md @summercms/src/plugin/PluginId.scala @summercms/src/plugin/PluginError.scala @summercms/src/plugin/PluginManifest.scala @summercms/src/plugin/PluginDiscovery.scala Task 1: Create plugin lifecycle types summercms/src/plugin/PluginState.scala summercms/src/plugin/PluginContext.scala summercms/src/plugin/PluginRegistration.scala summercms/src/plugin/SummerPlugin.scala **PluginState.scala:** ```scala package plugin/** Plugin lifecycle state machine */ enum PluginState: case Discovered // Found on filesystem, manifest parsed case Registered // register() completed successfully case Booting // boot() in progress case Running // boot() succeeded, plugin active case Failed(error: PluginError) // boot() failed case Disabled // Manually disabled via config case ShuttingDown // shutdown() in progress case Stopped // shutdown() completed
def isActive: Boolean = this == Running def canBoot: Boolean = this == Registered def canShutdown: Boolean = this == Running || this == Failed(null) // null check is loose, refine if needed
**PluginContext.scala:**
```scala
package plugin
import java.nio.file.Path
/** Context provided to plugins during registration and boot */
case class PluginContext(
manifest: PluginManifest,
directory: Path,
config: Map[String, String] = Map.empty // Plugin-specific config from application.conf
):
def id: PluginId = manifest.id
def vendor: String = manifest.vendor
def name: String = manifest.name
def version: String = manifest.version
PluginRegistration.scala:
package plugin
/** Data returned from plugin's register() method - declarative, no effects */
case class PluginRegistration(
// Components provided by this plugin (Phase 3)
components: List[ComponentDef] = List.empty,
// Permissions defined by this plugin (Phase 6)
permissions: List[PermissionDef] = List.empty,
// Navigation items for admin backend (Phase 8)
navigation: List[NavigationDef] = List.empty,
// Settings pages (Phase 8)
settings: List[SettingDef] = List.empty,
// Event subscriptions (Phase 2 extension API)
events: List[EventSubscription] = List.empty,
// Extensions to other plugins (Phase 2 extension API)
extensions: List[ExtensionDef] = List.empty
)
object PluginRegistration:
val empty: PluginRegistration = PluginRegistration()
// Placeholder types - will be fleshed out in later phases
case class ComponentDef(name: String, className: String)
case class PermissionDef(code: String, label: String)
case class NavigationDef(id: String, label: String, url: String, icon: String = "", order: Int = 0)
case class SettingDef(key: String, label: String, icon: String = "")
case class EventSubscription(eventType: String, handler: String)
case class ExtensionDef(target: String, extensionClass: String)
SummerPlugin.scala:
package plugin
import zio.*
/** Base trait for all SummerCMS plugins */
trait SummerPlugin:
/** Plugin identifier - must match manifest */
def id: PluginId
/**
* Synchronous registration phase - pure data, no effects.
* Called during discovery to collect plugin declarations.
*/
def register(ctx: PluginContext): PluginRegistration = PluginRegistration.empty
/**
* Async boot phase - can perform effects.
* Called after all dependencies have booted.
* Use for: database setup, event subscriptions, service initialization.
*/
def boot: ZIO[PluginEnv, PluginError, Unit] = ZIO.unit
/**
* Async shutdown phase - cleanup resources.
* Called in reverse dependency order during application shutdown.
*/
def shutdown: ZIO[Any, Nothing, Unit] = ZIO.unit
/** Environment available to plugins during boot */
type PluginEnv = PluginContext
import scala.annotation.tailrec
/** Resolves plugin load order using topological sort (Kahn's algorithm) */ object DependencyResolver:
/**
- Resolve plugins into dependency order (dependencies first).
- Returns Left(CyclicDependency) if cycle detected.
- Returns Right(orderedPlugins) on success. */ def resolve( plugins: Map[PluginId, PluginManifest] ): Either[PluginError, List[PluginId]] = // Build graph: plugin -> plugins that depend on it val dependents = buildDependentsGraph(plugins)
// Calculate in-degrees (number of dependencies each plugin has)
val inDegree = plugins.map { case (id, manifest) =>
id -> manifest.dependencies.keys.count { depStr =>
PluginId.parse(depStr).toOption.exists(plugins.contains)
}
}
// Find plugins with no dependencies (in-degree = 0)
val roots = inDegree.filter(_._2 == 0).keys.toList
.sortBy(id => -plugins(id).priority) // Higher priority first
// Run Kahn's algorithm
process(roots, List.empty, inDegree, dependents, plugins)
private def buildDependentsGraph( plugins: Map[PluginId, PluginManifest] ): Map[PluginId, Set[PluginId]] = plugins.values.foldLeft(Map.empty[PluginId, Set[PluginId]]) { (acc, manifest) => manifest.dependencies.keys.foldLeft(acc) { (a, depStr) => PluginId.parse(depStr).toOption match case Some(depId) if plugins.contains(depId) => // depId -> manifest.id means "manifest.id depends on depId" a.updated(depId, a.getOrElse(depId, Set.empty) + manifest.id) case _ => a // Skip invalid or missing dependencies } }
@tailrec private def process( queue: List[PluginId], result: List[PluginId], degrees: Map[PluginId, Int], dependents: Map[PluginId, Set[PluginId]], plugins: Map[PluginId, PluginManifest] ): Either[PluginError, List[PluginId]] = queue match case Nil => // If we processed all plugins, success; otherwise cycle detected if result.size == plugins.size then Right(result.reverse) else val remaining = plugins.keySet -- result.toSet Left(PluginError.CyclicDependency(remaining))
case head :: tail =>
// Process this plugin, decrement in-degrees of its dependents
val deps = dependents.getOrElse(head, Set.empty)
val newDegrees = deps.foldLeft(degrees) { (d, dep) =>
d.updated(dep, d(dep) - 1)
}
// Find newly ready plugins (in-degree became 0)
val newReady = deps
.filter(d => newDegrees(d) == 0)
.toList
.sortBy(id => -plugins(id).priority) // Higher priority first
process(tail ++ newReady, head :: result, newDegrees, dependents, plugins)
/**
- Validate that all dependencies exist and meet version constraints.
- Call this after resolve() to check version compatibility. */ def validateDependencies( plugins: Map[PluginId, PluginManifest] ): List[PluginError] = plugins.values.flatMap { manifest => manifest.dependencies.flatMap { case (depStr, constraint) => PluginId.parse(depStr).toOption match case None => Some(PluginError.InvalidPluginId(depStr, "Invalid format in dependency")) case Some(depId) => plugins.get(depId) match case None => Some(PluginError.DependencyNotFound(manifest.id, depStr)) case Some(depManifest) => // TODO: Use semver-parser-scala to validate version constraint // For now, just check dependency exists None } }.toList
</action>
<verify>Run `./mill summercms.compile` - DependencyResolver compiles. Test mentally: A depends on B, B depends on C -> resolve returns [C, B, A]</verify>
<done>DependencyResolver.resolve() returns topologically sorted plugin IDs, detects cycles</done>
</task>
<task type="auto">
<name>Task 3: Create PluginManager service</name>
<files>summercms/src/plugin/PluginManager.scala</files>
<action>
**PluginManager.scala:**
```scala
package plugin
import zio.*
import java.nio.file.Path
/** Manages plugin lifecycle: discovery, registration, boot, shutdown */
trait PluginManager:
/** Discover and register all plugins from directory */
def loadPlugins(pluginsDir: Path): IO[PluginError, Unit]
/** Boot all registered plugins in dependency order */
def bootAll: IO[PluginError, Unit]
/** Shutdown all running plugins in reverse dependency order */
def shutdownAll: UIO[Unit]
/** Get current state of a plugin */
def getState(id: PluginId): UIO[Option[PluginState]]
/** Get all registered plugins */
def listPlugins: UIO[List[(PluginId, PluginState)]]
/** Get registration data for a plugin */
def getRegistration(id: PluginId): UIO[Option[PluginRegistration]]
object PluginManager:
/** Access the manager service */
def loadPlugins(pluginsDir: Path): ZIO[PluginManager, PluginError, Unit] =
ZIO.serviceWithZIO[PluginManager](_.loadPlugins(pluginsDir))
def bootAll: ZIO[PluginManager, PluginError, Unit] =
ZIO.serviceWithZIO[PluginManager](_.bootAll)
def shutdownAll: ZIO[PluginManager, Nothing, Unit] =
ZIO.serviceWithZIO[PluginManager](_.shutdownAll)
def getState(id: PluginId): ZIO[PluginManager, Nothing, Option[PluginState]] =
ZIO.serviceWithZIO[PluginManager](_.getState(id))
def listPlugins: ZIO[PluginManager, Nothing, List[(PluginId, PluginState)]] =
ZIO.serviceWithZIO[PluginManager](_.listPlugins)
/** Live implementation */
val live: ZLayer[PluginDiscovery, Nothing, PluginManager] =
ZLayer.fromZIO {
for
discovery <- ZIO.service[PluginDiscovery]
pluginsRef <- Ref.make(Map.empty[PluginId, PluginInstance])
bootOrderRef <- Ref.make(List.empty[PluginId])
yield LivePluginManager(discovery, pluginsRef, bootOrderRef)
}
/** Internal: plugin instance with state tracking */
private case class PluginInstance(
discovered: DiscoveredPlugin,
plugin: Option[SummerPlugin], // None until loaded
registration: Option[PluginRegistration],
stateRef: Ref[PluginState]
)
private class LivePluginManager(
discovery: PluginDiscovery,
plugins: Ref[Map[PluginId, PluginInstance]],
bootOrder: Ref[List[PluginId]]
) extends PluginManager:
def loadPlugins(pluginsDir: Path): IO[PluginError, Unit] =
for
discovered <- discovery.discover(pluginsDir)
// Filter enabled plugins only
enabled = discovered.filter(_.manifest.enabled)
// Build manifest map for dependency resolution
manifestMap = enabled.map(d => d.id -> d.manifest).toMap
// Validate dependencies exist
errors = DependencyResolver.validateDependencies(manifestMap)
_ <- ZIO.fail(errors.head).when(errors.nonEmpty)
// Resolve boot order
order <- ZIO.fromEither(DependencyResolver.resolve(manifestMap))
// Create plugin instances
instances <- ZIO.foreach(enabled) { d =>
Ref.make(PluginState.Discovered).map { stateRef =>
d.id -> PluginInstance(d, None, None, stateRef)
}
}
_ <- plugins.set(instances.toMap)
_ <- bootOrder.set(order)
// Register all plugins (synchronous phase)
_ <- ZIO.foreachDiscard(order)(registerPlugin)
yield ()
private def registerPlugin(id: PluginId): IO[PluginError, Unit] =
for
instance <- plugins.get.map(_.get(id)).someOrFail(
PluginError.DependencyNotFound(id, id.fullId)
)
// TODO: Load actual plugin class from directory
// For now, plugins are registered via code - manual registration
// In future: ClassLoader/reflection or compile-time wiring
ctx = PluginContext(instance.discovered.manifest, instance.discovered.directory)
// Mark as registered (actual plugin loading deferred)
_ <- instance.stateRef.set(PluginState.Registered)
yield ()
def bootAll: IO[PluginError, Unit] =
for
order <- bootOrder.get
_ <- ZIO.foreachDiscard(order)(bootPlugin)
yield ()
private def bootPlugin(id: PluginId): IO[PluginError, Unit] =
for
map <- plugins.get
instance <- ZIO.fromOption(map.get(id)).orElseFail(
PluginError.DependencyNotFound(id, id.fullId)
)
state <- instance.stateRef.get
_ <- ZIO.when(state.canBoot) {
for
_ <- instance.stateRef.set(PluginState.Booting)
// Boot the plugin if it exists
result <- instance.plugin match
case Some(p) =>
val ctx = PluginContext(instance.discovered.manifest, instance.discovered.directory)
p.boot.provideEnvironment(ZEnvironment(ctx)).either
case None =>
// No plugin class loaded yet - just mark as running
ZIO.succeed(Right(()))
_ <- result match
case Right(_) => instance.stateRef.set(PluginState.Running)
case Left(err) =>
instance.stateRef.set(PluginState.Failed(err)) *>
disableDependents(id)
yield ()
}
yield ()
private def disableDependents(failedId: PluginId): UIO[Unit] =
for
map <- plugins.get
// Find plugins that depend on failedId
dependents = map.filter { case (_, inst) =>
inst.discovered.manifest.dependencies.keys.exists { depStr =>
PluginId.parse(depStr).toOption.contains(failedId)
}
}
_ <- ZIO.foreachDiscard(dependents.values) { inst =>
inst.stateRef.set(PluginState.Disabled)
}
yield ()
def shutdownAll: UIO[Unit] =
for
order <- bootOrder.get
map <- plugins.get
// Shutdown in reverse order
_ <- ZIO.foreachDiscard(order.reverse) { id =>
map.get(id) match
case Some(inst) =>
for
state <- inst.stateRef.get
_ <- ZIO.when(state.isActive) {
inst.stateRef.set(PluginState.ShuttingDown) *>
inst.plugin.fold(ZIO.unit)(_.shutdown) *>
inst.stateRef.set(PluginState.Stopped)
}
yield ()
case None => ZIO.unit
}
yield ()
def getState(id: PluginId): UIO[Option[PluginState]] =
plugins.get.flatMap { map =>
map.get(id).fold(ZIO.none)(_.stateRef.get.map(Some(_)))
}
def listPlugins: UIO[List[(PluginId, PluginState)]] =
plugins.get.flatMap { map =>
ZIO.foreach(map.toList) { case (id, inst) =>
inst.stateRef.get.map(id -> _)
}
}
def getRegistration(id: PluginId): UIO[Option[PluginRegistration]] =
plugins.get.map(_.get(id).flatMap(_.registration))
<success_criteria>
- SummerPlugin trait is implementable by plugins
- DependencyResolver detects cycles (returns Left(CyclicDependency))
- DependencyResolver returns dependency-first order (A depends on B -> [B, A])
- PluginManager tracks state through Discovered -> Registered -> Running
- Boot failures disable dependent plugins </success_criteria>