docs(02): create phase plan
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
This commit is contained in:
309
.planning/phases/02-plugin-system/02-01-PLAN.md
Normal file
309
.planning/phases/02-plugin-system/02-01-PLAN.md
Normal file
@@ -0,0 +1,309 @@
|
||||
---
|
||||
phase: 02-plugin-system
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- build.mill
|
||||
- summercms/src/plugin/PluginId.scala
|
||||
- summercms/src/plugin/PluginManifest.scala
|
||||
- summercms/src/plugin/PluginError.scala
|
||||
- summercms/src/plugin/PluginDiscovery.scala
|
||||
- plugins/.gitkeep
|
||||
autonomous: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Developer can write a plugin.yaml with vendor, name, version, dependencies"
|
||||
- "System discovers all plugin.yaml files in plugins/ directory"
|
||||
- "Invalid YAML manifests produce clear error messages"
|
||||
- "Dependency version constraints parse correctly (^1.2.0, >=1.0.0)"
|
||||
artifacts:
|
||||
- path: "summercms/src/plugin/PluginManifest.scala"
|
||||
provides: "Plugin manifest case class with circe derivation"
|
||||
contains: "case class PluginManifest"
|
||||
- path: "summercms/src/plugin/PluginDiscovery.scala"
|
||||
provides: "Service to discover plugins from filesystem"
|
||||
exports: ["PluginDiscovery", "DiscoveredPlugin"]
|
||||
- path: "summercms/src/plugin/PluginId.scala"
|
||||
provides: "Plugin identifier (vendor.name)"
|
||||
contains: "case class PluginId"
|
||||
- path: "summercms/src/plugin/PluginError.scala"
|
||||
provides: "Plugin error ADT"
|
||||
contains: "enum PluginError"
|
||||
key_links:
|
||||
- from: "summercms/src/plugin/PluginDiscovery.scala"
|
||||
to: "summercms/src/plugin/PluginManifest.scala"
|
||||
via: "parseManifest"
|
||||
pattern: "parser\\.parse.*as\\[PluginManifest\\]"
|
||||
- from: "build.mill"
|
||||
to: "circe-yaml"
|
||||
via: "mvnDeps"
|
||||
pattern: "circe-yaml"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the plugin discovery and manifest parsing foundation for SummerCMS.
|
||||
|
||||
Purpose: All plugins need manifests parsed from YAML. Discovery finds plugins automatically. This is the foundation that lifecycle management (02-02) builds upon.
|
||||
|
||||
Output: PluginManifest case class, PluginDiscovery service, PluginId/PluginError types, dependencies added to build.mill
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/jin/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<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
|
||||
@build.mill
|
||||
@summercms/src/Main.scala
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add YAML and version parsing dependencies</name>
|
||||
<files>build.mill</files>
|
||||
<action>
|
||||
Add the following dependencies to build.mill mvnDeps:
|
||||
|
||||
```scala
|
||||
// YAML parsing for plugin manifests
|
||||
mvn"io.circe::circe-yaml:0.16.1",
|
||||
mvn"io.circe::circe-generic:0.14.10",
|
||||
mvn"io.circe::circe-parser:0.14.10",
|
||||
|
||||
// Semver version constraints
|
||||
mvn"com.github.sh4869::semver-parser-scala:0.0.4",
|
||||
```
|
||||
|
||||
Add after the existing Flyway dependencies.
|
||||
|
||||
Note: circe-parser is needed alongside circe-yaml for JSON operations.
|
||||
</action>
|
||||
<verify>Run `./mill summercms.compile` - should succeed with new dependencies resolved</verify>
|
||||
<done>build.mill contains circe-yaml, circe-generic, circe-parser, and semver-parser-scala dependencies</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create plugin type foundations</name>
|
||||
<files>
|
||||
summercms/src/plugin/PluginId.scala
|
||||
summercms/src/plugin/PluginError.scala
|
||||
</files>
|
||||
<action>
|
||||
Create `summercms/src/plugin/` directory with these files:
|
||||
|
||||
**PluginId.scala:**
|
||||
```scala
|
||||
package plugin
|
||||
|
||||
/** Plugin identifier combining vendor and name */
|
||||
case class PluginId(vendor: String, name: String):
|
||||
/** Full identifier string (vendor.name) */
|
||||
def fullId: String = s"$vendor.$name"
|
||||
|
||||
override def toString: String = fullId
|
||||
|
||||
object PluginId:
|
||||
/** Parse "vendor.name" string into PluginId */
|
||||
def parse(s: String): Either[String, PluginId] =
|
||||
s.split('.').toList match
|
||||
case vendor :: name :: Nil if vendor.nonEmpty && name.nonEmpty =>
|
||||
Right(PluginId(vendor, name))
|
||||
case _ =>
|
||||
Left(s"Invalid plugin ID '$s': expected 'vendor.name' format")
|
||||
|
||||
/** Unsafe parse - throws on invalid format */
|
||||
def unsafeParse(s: String): PluginId =
|
||||
parse(s).getOrElse(throw new IllegalArgumentException(s"Invalid plugin ID: $s"))
|
||||
```
|
||||
|
||||
**PluginError.scala:**
|
||||
```scala
|
||||
package plugin
|
||||
|
||||
import java.nio.file.Path
|
||||
|
||||
/** Plugin system error ADT */
|
||||
enum PluginError:
|
||||
case ManifestNotFound(path: Path)
|
||||
case ManifestParseError(path: Path, message: String)
|
||||
case InvalidPluginId(raw: String, reason: String)
|
||||
case DependencyNotFound(plugin: PluginId, dependency: String)
|
||||
case VersionMismatch(plugin: PluginId, dependency: String, required: String, actual: String)
|
||||
case CyclicDependency(plugins: Set[PluginId])
|
||||
case BootError(plugin: PluginId, cause: Throwable)
|
||||
case ShutdownError(plugin: PluginId, cause: Throwable)
|
||||
|
||||
def message: String = this match
|
||||
case ManifestNotFound(p) => s"Plugin manifest not found: $p"
|
||||
case ManifestParseError(p, m) => s"Failed to parse manifest at $p: $m"
|
||||
case InvalidPluginId(r, reason) => s"Invalid plugin ID '$r': $reason"
|
||||
case DependencyNotFound(p, d) => s"Plugin ${p.fullId} requires $d which is not installed"
|
||||
case VersionMismatch(p, d, req, act) => s"Plugin ${p.fullId} requires $d $req but found $act"
|
||||
case CyclicDependency(ps) => s"Cyclic dependency detected: ${ps.map(_.fullId).mkString(" -> ")}"
|
||||
case BootError(p, c) => s"Plugin ${p.fullId} failed to boot: ${c.getMessage}"
|
||||
case ShutdownError(p, c) => s"Plugin ${p.fullId} failed to shutdown: ${c.getMessage}"
|
||||
```
|
||||
</action>
|
||||
<verify>Run `./mill summercms.compile` - plugin package compiles without errors</verify>
|
||||
<done>PluginId case class with parse methods, PluginError ADT with all error cases</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Create manifest parsing and discovery service</name>
|
||||
<files>
|
||||
summercms/src/plugin/PluginManifest.scala
|
||||
summercms/src/plugin/PluginDiscovery.scala
|
||||
plugins/.gitkeep
|
||||
</files>
|
||||
<action>
|
||||
**PluginManifest.scala:**
|
||||
```scala
|
||||
package plugin
|
||||
|
||||
import io.circe.*
|
||||
import io.circe.generic.auto.*
|
||||
import io.circe.yaml.parser
|
||||
import java.nio.file.Path
|
||||
|
||||
/** Plugin manifest parsed from plugin.yaml */
|
||||
case class PluginManifest(
|
||||
vendor: String,
|
||||
name: String,
|
||||
version: String,
|
||||
description: Option[String] = None,
|
||||
author: Option[String] = None,
|
||||
homepage: Option[String] = None,
|
||||
license: Option[String] = None,
|
||||
dependencies: Map[String, String] = Map.empty, // "vendor.name" -> "^1.2.0"
|
||||
optionalDependencies: Map[String, String] = Map.empty,
|
||||
priority: Int = 0, // Higher = boots first among peers
|
||||
enabled: Boolean = true
|
||||
):
|
||||
/** Derived plugin ID */
|
||||
def id: PluginId = PluginId(vendor, name)
|
||||
|
||||
object PluginManifest:
|
||||
/** Parse YAML string into PluginManifest */
|
||||
def parse(yaml: String): Either[PluginError, PluginManifest] =
|
||||
parser.parse(yaml)
|
||||
.flatMap(_.as[PluginManifest])
|
||||
.left.map(e => PluginError.ManifestParseError(
|
||||
java.nio.file.Path.of("unknown"),
|
||||
e.getMessage
|
||||
))
|
||||
|
||||
/** Parse YAML file at path */
|
||||
def parseFile(path: Path, content: String): Either[PluginError, PluginManifest] =
|
||||
parser.parse(content)
|
||||
.flatMap(_.as[PluginManifest])
|
||||
.left.map(e => PluginError.ManifestParseError(path, e.getMessage))
|
||||
```
|
||||
|
||||
**PluginDiscovery.scala:**
|
||||
```scala
|
||||
package plugin
|
||||
|
||||
import zio.*
|
||||
import java.nio.file.{Files, Path, Paths}
|
||||
import scala.jdk.CollectionConverters.*
|
||||
|
||||
/** A discovered plugin with its manifest and location */
|
||||
case class DiscoveredPlugin(
|
||||
directory: Path,
|
||||
manifest: PluginManifest
|
||||
):
|
||||
def id: PluginId = manifest.id
|
||||
|
||||
/** Service for discovering plugins from filesystem */
|
||||
trait PluginDiscovery:
|
||||
/** Discover all plugins in the given directory */
|
||||
def discover(pluginsDir: Path): IO[PluginError, List[DiscoveredPlugin]]
|
||||
|
||||
object PluginDiscovery:
|
||||
/** Default plugins directory */
|
||||
val defaultPluginsDir: Path = Paths.get("plugins")
|
||||
|
||||
/** Access the discovery service */
|
||||
def discover(pluginsDir: Path): ZIO[PluginDiscovery, PluginError, List[DiscoveredPlugin]] =
|
||||
ZIO.serviceWithZIO[PluginDiscovery](_.discover(pluginsDir))
|
||||
|
||||
/** Live implementation that scans filesystem */
|
||||
val live: ULayer[PluginDiscovery] = ZLayer.succeed {
|
||||
new PluginDiscovery:
|
||||
def discover(pluginsDir: Path): IO[PluginError, List[DiscoveredPlugin]] =
|
||||
for
|
||||
exists <- ZIO.attemptBlocking(Files.exists(pluginsDir))
|
||||
.orDie
|
||||
plugins <- if !exists then ZIO.succeed(List.empty)
|
||||
else findAndParseManifests(pluginsDir)
|
||||
yield plugins
|
||||
|
||||
private def findAndParseManifests(pluginsDir: Path): IO[PluginError, List[DiscoveredPlugin]] =
|
||||
ZIO.attemptBlocking {
|
||||
// Walk up to 3 levels: plugins/vendor/name/plugin.yaml
|
||||
Files.walk(pluginsDir, 3).iterator.asScala
|
||||
.filter(p => p.getFileName.toString == "plugin.yaml")
|
||||
.toList
|
||||
}.orDie.flatMap { manifestPaths =>
|
||||
ZIO.foreach(manifestPaths) { path =>
|
||||
for
|
||||
content <- ZIO.attemptBlocking(Files.readString(path))
|
||||
.mapError(_ => PluginError.ManifestNotFound(path))
|
||||
manifest <- ZIO.fromEither(PluginManifest.parseFile(path, content))
|
||||
yield DiscoveredPlugin(path.getParent, manifest)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Create plugins directory:**
|
||||
Create `plugins/.gitkeep` (empty file) to ensure the plugins directory exists in git.
|
||||
</action>
|
||||
<verify>
|
||||
Run `./mill summercms.compile` - all plugin files compile.
|
||||
|
||||
Create a test manifest at `plugins/test/sample/plugin.yaml`:
|
||||
```yaml
|
||||
vendor: test
|
||||
name: sample
|
||||
version: 1.0.0
|
||||
description: Test plugin
|
||||
dependencies:
|
||||
golem15.user: "^1.0.0"
|
||||
```
|
||||
|
||||
Then verify parsing works by checking the code compiles and the types are correct.
|
||||
</verify>
|
||||
<done>PluginManifest parses YAML manifests, PluginDiscovery scans plugins/ directory, plugins/ directory exists</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `./mill summercms.compile` succeeds
|
||||
2. Dependencies include circe-yaml:0.16.1, circe-generic:0.14.10, semver-parser-scala:0.0.4
|
||||
3. Plugin directory structure exists: `plugins/`
|
||||
4. All plugin types compile: PluginId, PluginError, PluginManifest, PluginDiscovery
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- build.mill has all new dependencies
|
||||
- PluginId.parse("golem15.blog") returns Right(PluginId("golem15", "blog"))
|
||||
- PluginManifest.parse(validYaml) returns Right(manifest)
|
||||
- PluginDiscovery.live provides working service
|
||||
- plugins/ directory exists with .gitkeep
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-plugin-system/02-01-SUMMARY.md`
|
||||
</output>
|
||||
518
.planning/phases/02-plugin-system/02-02-PLAN.md
Normal file
518
.planning/phases/02-plugin-system/02-02-PLAN.md
Normal file
@@ -0,0 +1,518 @@
|
||||
---
|
||||
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)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/jin/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create plugin lifecycle types</name>
|
||||
<files>
|
||||
summercms/src/plugin/PluginState.scala
|
||||
summercms/src/plugin/PluginContext.scala
|
||||
summercms/src/plugin/PluginRegistration.scala
|
||||
summercms/src/plugin/SummerPlugin.scala
|
||||
</files>
|
||||
<action>
|
||||
**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
|
||||
)
|
||||
|
||||
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:**
|
||||
```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
|
||||
```
|
||||
</action>
|
||||
<verify>Run `./mill summercms.compile` - all types compile without errors</verify>
|
||||
<done>PluginState enum with all lifecycle states, PluginContext, PluginRegistration with placeholder defs, SummerPlugin trait</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create dependency resolver with topological sort</name>
|
||||
<files>summercms/src/plugin/DependencyResolver.scala</files>
|
||||
<action>
|
||||
**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
|
||||
```
|
||||
</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))
|
||||
```
|
||||
</action>
|
||||
<verify>Run `./mill summercms.compile` - PluginManager compiles. The service can load, register, boot, and shutdown plugins.</verify>
|
||||
<done>PluginManager.live provides working service with full lifecycle management</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
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
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-plugin-system/02-02-SUMMARY.md`
|
||||
</output>
|
||||
510
.planning/phases/02-plugin-system/02-03-PLAN.md
Normal file
510
.planning/phases/02-plugin-system/02-03-PLAN.md
Normal file
@@ -0,0 +1,510 @@
|
||||
---
|
||||
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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/jin/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create event system with ZIO Hub</name>
|
||||
<files>
|
||||
summercms/src/plugin/SummerEvent.scala
|
||||
summercms/src/plugin/EventService.scala
|
||||
</files>
|
||||
<action>
|
||||
**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
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>Run `./mill summercms.compile` - event system compiles. EventService.live provides a Hub-based pub/sub system.</verify>
|
||||
<done>SummerEvent trait with lifecycle and domain events, EventService with publish/subscribe via ZIO Hub</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create extension registry</name>
|
||||
<files>summercms/src/plugin/ExtensionRegistry.scala</files>
|
||||
<action>
|
||||
**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
|
||||
```
|
||||
</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 and integrate with Main</name>
|
||||
<files>
|
||||
summercms/src/plugin/package.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 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)
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
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).
|
||||
</verify>
|
||||
<done>Plugin package exports combined layer, Main.scala loads and boots plugins on startup</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
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)
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-plugin-system/02-03-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user