--- phase: 02-plugin-system plan: 03 type: execute wave: 3 depends_on: ["02-02"] files_modified: - summercms/src/plugin/EventService.scala - summercms/src/plugin/ExtensionRegistry.scala - summercms/src/plugin/SummerEvent.scala - summercms/src/plugin/package.scala - summercms/src/Main.scala autonomous: true must_haves: truths: - "Plugins can publish events to a central hub" - "Plugins can subscribe to events from other plugins" - "Plugins can register type-safe extensions for other plugins" - "Extensions are discoverable by target plugin at boot time" - "Plugin system integrates with Main.scala and boots on application start" artifacts: - path: "summercms/src/plugin/EventService.scala" provides: "ZIO Hub-based pub/sub event system" exports: ["EventService"] - path: "summercms/src/plugin/ExtensionRegistry.scala" provides: "Type-safe extension registration" exports: ["ExtensionRegistry"] - path: "summercms/src/plugin/SummerEvent.scala" provides: "Base event trait and lifecycle events" contains: "sealed trait SummerEvent" - path: "summercms/src/plugin/package.scala" provides: "Plugin package exports and type aliases" contains: "type PluginLayer" key_links: - from: "summercms/src/Main.scala" to: "summercms/src/plugin/PluginManager.scala" via: "loads and boots plugins on startup" pattern: "PluginManager\\.(loadPlugins|bootAll)" - from: "summercms/src/plugin/ExtensionRegistry.scala" to: "summercms/src/plugin/PluginManager.scala" via: "extensions registered during plugin boot" pattern: "ExtensionRegistry\\.register" --- Implement the extension API with event system and type-safe extension registry for SummerCMS. Purpose: Plugins need to communicate loosely via events and extend each other type-safely. This completes the plugin system, making it ready for components (Phase 3) and themes (Phase 4) to build upon. Output: EventService (Hub-based), ExtensionRegistry, SummerEvent trait, plugin layer integration with Main.scala @/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 @.planning/phases/02-plugin-system/02-02-SUMMARY.md @summercms/src/plugin/SummerPlugin.scala @summercms/src/plugin/PluginManager.scala @summercms/src/Main.scala Task 1: Create event system with ZIO Hub summercms/src/plugin/SummerEvent.scala summercms/src/plugin/EventService.scala **SummerEvent.scala:** ```scala package plugin import java.time.Instant /** Base trait for all SummerCMS events */ sealed trait SummerEvent: def timestamp: Instant = Instant.now() object SummerEvent: // ===== Plugin Lifecycle Events ===== /** Emitted when a plugin completes boot successfully */ case class PluginBooted(id: PluginId) extends SummerEvent /** Emitted when a plugin fails to boot */ case class PluginBootFailed(id: PluginId, error: PluginError) extends SummerEvent /** Emitted when a plugin begins shutdown */ case class PluginShuttingDown(id: PluginId) extends SummerEvent /** Emitted when a plugin completes shutdown */ case class PluginStopped(id: PluginId) extends SummerEvent // ===== Domain Events (plugins define their own) ===== /** Marker trait for domain-specific events from plugins */ trait DomainEvent extends SummerEvent /** Example: User events (will be used by User plugin in Phase 10) */ trait UserEvent extends DomainEvent case class UserCreated(userId: Long, email: String) extends UserEvent case class UserLoggedIn(userId: Long) extends UserEvent case class UserLoggedOut(userId: Long) extends UserEvent /** Example: Content events (will be used by CMS in Phase 9) */ trait ContentEvent extends DomainEvent case class PageCreated(pageId: Long, slug: String) extends ContentEvent case class PagePublished(pageId: Long) extends ContentEvent ``` **EventService.scala:** ```scala package plugin import zio.* import scala.reflect.ClassTag /** Pub/sub event service using ZIO Hub */ trait EventService: /** Publish an event to all subscribers */ def publish[E <: SummerEvent](event: E): UIO[Boolean] /** Subscribe to all events - returns Dequeue in a Scope */ def subscribe: ZIO[Scope, Nothing, Dequeue[SummerEvent]] /** Subscribe to events of a specific type */ def subscribeFiltered[E <: SummerEvent: ClassTag]: ZIO[Scope, Nothing, Dequeue[E]] /** Subscribe with a handler function that processes events */ def subscribeWith[E <: SummerEvent: ClassTag]( handler: E => UIO[Unit] ): ZIO[Scope, Nothing, Fiber.Runtime[Nothing, Nothing]] object EventService: /** Publish an event */ def publish[E <: SummerEvent](event: E): ZIO[EventService, Nothing, Boolean] = ZIO.serviceWithZIO[EventService](_.publish(event)) /** Subscribe to all events */ def subscribe: ZIO[EventService & Scope, Nothing, Dequeue[SummerEvent]] = ZIO.serviceWithZIO[EventService](_.subscribe) /** Subscribe to specific event type */ def subscribeFiltered[E <: SummerEvent: ClassTag]: ZIO[EventService & Scope, Nothing, Dequeue[E]] = ZIO.serviceWithZIO[EventService](_.subscribeFiltered[E]) /** Live implementation with bounded Hub */ val live: ULayer[EventService] = ZLayer.scoped { Hub.bounded[SummerEvent](256).map { hub => new EventService: def publish[E <: SummerEvent](event: E): UIO[Boolean] = hub.publish(event) def subscribe: ZIO[Scope, Nothing, Dequeue[SummerEvent]] = hub.subscribe def subscribeFiltered[E <: SummerEvent: ClassTag]: ZIO[Scope, Nothing, Dequeue[E]] = hub.subscribe.map { dequeue => new Dequeue[E]: def take: UIO[E] = dequeue.take.flatMap { case e: E => ZIO.succeed(e) case _ => take // Skip non-matching events } def takeAll: UIO[Chunk[E]] = dequeue.takeAll.map(_.collect { case e: E => e }) def takeUpTo(max: Int): UIO[Chunk[E]] = dequeue.takeUpTo(max).map(_.collect { case e: E => e }) def poll: UIO[Option[E]] = dequeue.poll.map(_.collect { case e: E => e }) def size: UIO[Int] = dequeue.size def capacity: Int = dequeue.capacity def isShutdown: UIO[Boolean] = dequeue.isShutdown def shutdown: UIO[Unit] = dequeue.shutdown def awaitShutdown: UIO[Unit] = dequeue.awaitShutdown } def subscribeWith[E <: SummerEvent: ClassTag]( handler: E => UIO[Unit] ): ZIO[Scope, Nothing, Fiber.Runtime[Nothing, Nothing]] = subscribeFiltered[E].flatMap { queue => queue.take.flatMap(handler).forever.fork } } } ``` Run `./mill summercms.compile` - event system compiles. EventService.live provides a Hub-based pub/sub system. SummerEvent trait with lifecycle and domain events, EventService with publish/subscribe via ZIO Hub Task 2: Create extension registry summercms/src/plugin/ExtensionRegistry.scala **ExtensionRegistry.scala:** ```scala package plugin import zio.* import scala.reflect.ClassTag /** * Registry for type-safe plugin extensions. * * Plugins can register extensions for other plugins: * - SEO plugin registers BlogExtension for Blog plugin * - Blog plugin queries registry for all BlogExtension implementations * * Extensions are keyed by trait type (ClassTag) and registered by plugin ID. */ trait ExtensionRegistry: /** Register an extension from a plugin */ def register[E: ClassTag](pluginId: PluginId, extension: E): UIO[Unit] /** Get all extensions of a type */ def getExtensions[E: ClassTag]: UIO[List[(PluginId, E)]] /** Get extension from a specific plugin */ def getExtension[E: ClassTag](pluginId: PluginId): UIO[Option[E]] /** Check if any extensions exist for a type */ def hasExtensions[E: ClassTag]: UIO[Boolean] /** Remove all extensions from a plugin (used during unload/reload) */ def removePlugin(pluginId: PluginId): UIO[Unit] object ExtensionRegistry: /** Register an extension */ def register[E: ClassTag](pluginId: PluginId, extension: E): ZIO[ExtensionRegistry, Nothing, Unit] = ZIO.serviceWithZIO[ExtensionRegistry](_.register(pluginId, extension)) /** Get all extensions of a type */ def getExtensions[E: ClassTag]: ZIO[ExtensionRegistry, Nothing, List[(PluginId, E)]] = ZIO.serviceWithZIO[ExtensionRegistry](_.getExtensions[E]) /** Get extension from a specific plugin */ def getExtension[E: ClassTag](pluginId: PluginId): ZIO[ExtensionRegistry, Nothing, Option[E]] = ZIO.serviceWithZIO[ExtensionRegistry](_.getExtension[E](pluginId)) /** Live implementation using Ref */ val live: ULayer[ExtensionRegistry] = ZLayer.fromZIO { // Map from extension type key -> Map from plugin ID -> extension instance Ref.make(Map.empty[String, Map[PluginId, Any]]).map { registry => new ExtensionRegistry: private def typeKey[E: ClassTag]: String = summon[ClassTag[E]].runtimeClass.getName def register[E: ClassTag](pluginId: PluginId, extension: E): UIO[Unit] = registry.update { map => val key = typeKey[E] val existing = map.getOrElse(key, Map.empty) map.updated(key, existing + (pluginId -> extension)) } def getExtensions[E: ClassTag]: UIO[List[(PluginId, E)]] = registry.get.map { map => map.getOrElse(typeKey[E], Map.empty) .toList .map { case (id, ext) => (id, ext.asInstanceOf[E]) } } def getExtension[E: ClassTag](pluginId: PluginId): UIO[Option[E]] = registry.get.map { map => map.getOrElse(typeKey[E], Map.empty) .get(pluginId) .map(_.asInstanceOf[E]) } def hasExtensions[E: ClassTag]: UIO[Boolean] = registry.get.map { map => map.getOrElse(typeKey[E], Map.empty).nonEmpty } def removePlugin(pluginId: PluginId): UIO[Unit] = registry.update { map => map.view.mapValues(_ - pluginId).toMap } } } /** * Example extension trait that plugins can define. * Other plugins implement this to extend the defining plugin. * * Example: Blog plugin defines BlogExtension, SEO plugin implements it. */ trait BlogExtension: /** Additional fields to add to post forms */ def extendPostFields: List[FieldDef] /** Transform post data before save */ def beforeSave(data: Map[String, Any]): Map[String, Any] = data /** Validate post data */ def validate(data: Map[String, Any]): Either[String, Unit] = Right(()) // FieldDef is already defined in PluginRegistration.scala, reuse it ``` Run `./mill summercms.compile` - ExtensionRegistry compiles. It provides type-safe extension registration keyed by ClassTag. ExtensionRegistry.live provides type-safe extension storage with register/get/remove operations Task 3: Create plugin package exports and integrate with Main summercms/src/plugin/package.scala summercms/src/Main.scala **package.scala:** ```scala package object plugin: /** Combined layer for all plugin services */ type PluginServices = PluginManager & PluginDiscovery & EventService & ExtensionRegistry /** Create all plugin service layers */ val pluginLayer: zio.ZLayer[Any, Nothing, PluginServices] = PluginDiscovery.live >>> (PluginManager.live ++ EventService.live ++ ExtensionRegistry.live) /** Environment provided to plugins during boot - includes all services */ type PluginBootEnv = PluginContext & EventService & ExtensionRegistry ``` **Update Main.scala** to integrate the plugin system: ```scala import zio.* import zio.http.* import zio.config.typesafe.TypesafeConfigProvider import api.Routes import _root_.config.{AppConfig as SummerConfig} import db.QuillContext import plugin.{PluginManager, PluginDiscovery, pluginLayer} object Main extends ZIOAppDefault { private val banner: String = """ | | | . | `. * | .' | `. ._|_* .' . | . * .' `. * | -------| |------- | . *`.___.' * . | .' |* `. * | .' * | . `. | . | | | S U M M E R C M S |""".stripMargin override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] = Runtime.setConfigProvider(TypesafeConfigProvider.fromResourcePath()) override def run: ZIO[Any, Any, Any] = for { cfg <- ZIO.config[SummerConfig](SummerConfig.config) _ <- Console.printLine(banner) _ <- Console.printLine(s"Starting on port ${cfg.server.port}...") _ <- Console.printLine("") // Initialize plugin system _ <- Console.printLine("Loading plugins...") _ <- PluginManager.loadPlugins(PluginDiscovery.defaultPluginsDir) .catchAll(e => Console.printLine(s"Plugin loading warning: ${e.message}").as(())) _ <- PluginManager.bootAll .catchAll(e => Console.printLine(s"Plugin boot warning: ${e.message}").as(())) plugins <- PluginManager.listPlugins _ <- Console.printLine(s"Loaded ${plugins.count(_._2.isActive)} plugin(s)") _ <- Console.printLine("") // Note: Migrations are NOT auto-run. Use CLI to run migrations (Phase 5). _ <- Server.serve(Routes.routes).provide( Server.defaultWithPort(cfg.server.port), QuillContext.dataSourceLayer ) } yield () // Provide plugin layers override def run: ZIO[Any, Any, Any] = (for { cfg <- ZIO.config[SummerConfig](SummerConfig.config) _ <- Console.printLine(banner) _ <- Console.printLine(s"Starting on port ${cfg.server.port}...") _ <- Console.printLine("") // Initialize plugin system _ <- Console.printLine("Loading plugins...") _ <- PluginManager.loadPlugins(PluginDiscovery.defaultPluginsDir) .catchAll(e => Console.printLine(s"Plugin loading warning: ${e.message}").as(())) _ <- PluginManager.bootAll .catchAll(e => Console.printLine(s"Plugin boot warning: ${e.message}").as(())) plugins <- PluginManager.listPlugins _ <- Console.printLine(s"Loaded ${plugins.count(_._2.isActive)} plugin(s)") _ <- Console.printLine("") // Note: Migrations are NOT auto-run. Use CLI to run migrations (Phase 5). _ <- Server.serve(Routes.routes).provide( Server.defaultWithPort(cfg.server.port), QuillContext.dataSourceLayer ) } yield ()).provide(pluginLayer) ``` Wait - the above has a duplicate `def run`. Let me provide the correct version: **Main.scala (corrected full file):** ```scala import zio.* import zio.http.* import zio.config.typesafe.TypesafeConfigProvider import api.Routes import _root_.config.{AppConfig as SummerConfig} import db.QuillContext import plugin.{PluginManager, PluginDiscovery, pluginLayer} object Main extends ZIOAppDefault { private val banner: String = """ | | | . | `. * | .' | `. ._|_* .' . | . * .' `. * | -------| |------- | . *`.___.' * . | .' |* `. * | .' * | . `. | . | | | S U M M E R C M S |""".stripMargin override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] = Runtime.setConfigProvider(TypesafeConfigProvider.fromResourcePath()) private val startupLogic: ZIO[PluginManager & PluginDiscovery, Any, Nothing] = for { cfg <- ZIO.config[SummerConfig](SummerConfig.config) _ <- Console.printLine(banner) _ <- Console.printLine(s"Starting on port ${cfg.server.port}...") _ <- Console.printLine("") // Initialize plugin system _ <- Console.printLine("Loading plugins...") _ <- PluginManager.loadPlugins(PluginDiscovery.defaultPluginsDir) .catchAll(e => Console.printLine(s"Plugin loading warning: ${e.message}").as(())) _ <- PluginManager.bootAll .catchAll(e => Console.printLine(s"Plugin boot warning: ${e.message}").as(())) plugins <- PluginManager.listPlugins _ <- Console.printLine(s"Loaded ${plugins.count(_._2.isActive)} plugin(s)") _ <- Console.printLine("") // Note: Migrations are NOT auto-run. Use CLI to run migrations (Phase 5). res <- Server.serve(Routes.routes).provide( Server.defaultWithPort(cfg.server.port), QuillContext.dataSourceLayer ) } yield res override def run: ZIO[Any, Any, Any] = startupLogic.provide(pluginLayer) } ``` Run `./mill summercms.compile` - Main.scala compiles with plugin integration. Run `./mill summercms.run` - Server starts, logs "Loading plugins..." and "Loaded 0 plugin(s)" (since no plugins exist yet). Plugin package exports combined layer, Main.scala loads and boots plugins on startup 1. `./mill summercms.compile` succeeds 2. EventService.live provides Hub-based pub/sub 3. ExtensionRegistry.live provides type-safe extension storage 4. pluginLayer combines all services 5. Main.scala boots the application with plugin system initialization 6. Running `./mill summercms.run` shows "Loaded 0 plugin(s)" (no plugins installed) - Events can be published and subscribed to via EventService - Extensions can be registered and retrieved by type via ExtensionRegistry - Application startup loads and boots plugins automatically - System works with zero plugins (graceful empty state) - Plugin system is ready for Component System (Phase 3) to build upon After completion, create `.planning/phases/02-plugin-system/02-03-SUMMARY.md`