Files
Jakub Zych 5f2e7b87c9 fix(02): revise plans based on checker feedback
- 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
2026-02-05 13:29:45 +01:00

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
02-02
summercms/src/plugin/EventService.scala
summercms/src/plugin/ExtensionRegistry.scala
summercms/src/plugin/SummerEvent.scala
summercms/src/plugin/package.scala
summercms/src/plugin/SummerPlugin.scala
summercms/src/Main.scala
true
truths artifacts key_links
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
path provides exports
summercms/src/plugin/EventService.scala ZIO Hub-based pub/sub event system
EventService
path provides exports
summercms/src/plugin/ExtensionRegistry.scala Type-safe extension registration
ExtensionRegistry
path provides contains
summercms/src/plugin/SummerEvent.scala Base event trait and lifecycle events sealed trait SummerEvent
path provides contains
summercms/src/plugin/package.scala Plugin package exports and type aliases type PluginLayer
from to via pattern
summercms/src/Main.scala summercms/src/plugin/PluginManager.scala loads and boots plugins on startup PluginManager.(loadPlugins|bootAll)
from to via pattern
summercms/src/plugin/ExtensionRegistry.scala summercms/src/plugin/PluginManager.scala extensions registered during plugin boot 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

<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 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.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)
}
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, PluginEnv updated to include EventService and ExtensionRegistry, 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)

<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>
After completion, create `.planning/phases/02-plugin-system/02-03-SUMMARY.md`