- 02-01 Task 3: Add concrete REPL verification command for PluginManifest.parse - 02-02 Task 1: Add FieldDef case class to placeholder types in PluginRegistration.scala - 02-02 Task 1: Document PluginEnv as placeholder to be expanded in 02-03 - 02-03 Task 3: Remove duplicate Main.scala code, keep only corrected version - 02-03: Add SummerPlugin.scala to files_modified for PluginEnv update
16 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 | 03 | execute | 3 |
|
|
true |
|
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
<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 @.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 pluginimport 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
}
}
}
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.serviceWithZIOExtensionRegistry
/** Get extension from a specific plugin */ def getExtension[E: ClassTag](pluginId: PluginId): ZIO[ExtensionRegistry, Nothing, Option[E]] = ZIO.serviceWithZIOExtensionRegistry
/** 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 defined in PluginRegistration.scala
</action>
<verify>Run `./mill summercms.compile` - ExtensionRegistry compiles. It provides type-safe extension registration keyed by ClassTag.</verify>
<done>ExtensionRegistry.live provides type-safe extension storage with register/get/remove operations</done>
</task>
<task type="auto">
<name>Task 3: Create plugin package exports, update PluginEnv, and integrate with Main</name>
<files>
summercms/src/plugin/package.scala
summercms/src/plugin/SummerPlugin.scala
summercms/src/Main.scala
</files>
<action>
**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 SummerPlugin.scala to use the expanded PluginEnv:
Read the existing file from 02-02 and update ONLY the PluginEnv type alias at the bottom:
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.
* Includes context, events, and extension registry for full plugin capabilities.
*/
type PluginEnv = PluginContext & EventService & ExtensionRegistry
Main.scala (complete replacement):
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)
}
<success_criteria>
- 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 </success_criteria>