--- phase: 02-plugin-system plan: 02 type: execute wave: 2 depends_on: ["02-01"] files_modified: - summercms/src/plugin/SummerPlugin.scala - summercms/src/plugin/PluginState.scala - summercms/src/plugin/PluginContext.scala - summercms/src/plugin/PluginRegistration.scala - summercms/src/plugin/DependencyResolver.scala - summercms/src/plugin/PluginManager.scala autonomous: true must_haves: truths: - "Plugins implement SummerPlugin trait with register/boot/shutdown hooks" - "Plugin dependencies resolve in topological order (dependencies boot first)" - "Circular dependencies are detected at discovery time with clear error" - "Plugin state tracks through lifecycle: Discovered -> Registered -> Booting -> Running" - "Boot failures disable the failed plugin and its dependents" artifacts: - path: "summercms/src/plugin/SummerPlugin.scala" provides: "Plugin lifecycle trait" contains: "trait SummerPlugin" - path: "summercms/src/plugin/PluginManager.scala" provides: "Manages plugin lifecycle" exports: ["PluginManager"] - path: "summercms/src/plugin/DependencyResolver.scala" provides: "Topological sort for dependency ordering" contains: "def resolve" - path: "summercms/src/plugin/PluginState.scala" provides: "Plugin state machine" contains: "enum PluginState" key_links: - from: "summercms/src/plugin/PluginManager.scala" to: "summercms/src/plugin/DependencyResolver.scala" via: "dependency resolution before boot" pattern: "DependencyResolver\\.resolve" - from: "summercms/src/plugin/PluginManager.scala" to: "summercms/src/plugin/SummerPlugin.scala" via: "calls lifecycle hooks" pattern: "plugin\\.(register|boot|shutdown)" --- Implement plugin lifecycle management with dependency resolution for SummerCMS. 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 @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md @.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:** ```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, // Form field definitions (for YAML-driven forms) fields: List[FieldDef] = 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) case class FieldDef(name: String, fieldType: String) ``` **SummerPlugin.scala:** ```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. * * PluginEnv is a placeholder that will be expanded in 02-03 to include * EventService and ExtensionRegistry when the extension API is implemented. */ 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. * Initially just PluginContext; extended in 02-03 to include EventService & ExtensionRegistry. */ type PluginEnv = PluginContext ``` Run `./mill summercms.compile` - all types compile without errors PluginState enum with all lifecycle states, PluginContext, PluginRegistration with placeholder defs including FieldDef, SummerPlugin trait Task 2: Create dependency resolver with topological sort summercms/src/plugin/DependencyResolver.scala **DependencyResolver.scala:** ```scala package plugin 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 ``` Run `./mill summercms.compile` - DependencyResolver compiles. Test mentally: A depends on B, B depends on C -> resolve returns [C, B, A] DependencyResolver.resolve() returns topologically sorted plugin IDs, detects cycles Task 3: Create PluginManager service summercms/src/plugin/PluginManager.scala **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)) ``` Run `./mill summercms.compile` - PluginManager compiles. The service can load, register, boot, and shutdown plugins. PluginManager.live provides working service with full lifecycle management 1. `./mill summercms.compile` succeeds with all lifecycle types 2. SummerPlugin trait has register/boot/shutdown methods 3. PluginState enum covers full lifecycle 4. DependencyResolver.resolve returns topologically sorted IDs 5. PluginManager.live boots plugins in correct dependency order - 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 After completion, create `.planning/phases/02-plugin-system/02-02-SUMMARY.md`