---
phase: 09-content-management
plan: 06
type: execute
wave: 3
depends_on: ["09-01", "09-02"]
files_modified:
- summercms/src/hot/FileWatcher.scala
- summercms/src/hot/HotReloadService.scala
- summercms/src/hot/TemplateInvalidator.scala
- summercms/src/config/AppConfig.scala
- summercms/src/Main.scala
autonomous: true
must_haves:
truths:
- "Template file changes trigger Pebble cache invalidation"
- "Page/layout/partial modifications reflect immediately in browser"
- "Hot reload only active in development mode"
- "File watcher uses OS-native WatchService (not polling)"
- "Cache invalidation is targeted (not full cache clear)"
artifacts:
- path: "summercms/src/hot/FileWatcher.scala"
provides: "File change detection using WatchService"
contains: "class FileWatcher"
- path: "summercms/src/hot/HotReloadService.scala"
provides: "Reload orchestration service"
contains: "trait HotReloadService"
- path: "summercms/src/hot/TemplateInvalidator.scala"
provides: "Pebble cache invalidation"
contains: "class TemplateInvalidator"
key_links:
- from: "FileWatcher"
to: "HotReloadService"
via: "file change events trigger reload"
pattern: "HotReloadService.*onFileChanged"
- from: "HotReloadService"
to: "PebbleEngine.cache"
via: "invalidates template cache"
pattern: "pebbleEngine.*invalidate"
---
Implement hot reload for development mode
Purpose: Enable fast iteration during development by automatically reloading templates when files change. Per DIFF-02 requirement and CONTEXT.md decision for Claude's discretion on implementation details.
Output: FileWatcher using JDK WatchService, HotReloadService orchestrating reloads, TemplateInvalidator for Pebble cache, configuration for dev mode detection.
@/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/phases/09-content-management/09-RESEARCH.md
@.planning/phases/09-content-management/09-01-SUMMARY.md
@.planning/phases/09-content-management/09-02-SUMMARY.md
@summercms/src/config/AppConfig.scala
@summercms/src/Main.scala
Task 1: Create FileWatcher using JDK WatchService
summercms/src/hot/FileWatcher.scala
summercms/src/config/AppConfig.scala
**Update AppConfig.scala:**
Add development mode configuration:
```scala
case class DevConfig(
enabled: Boolean = false, // Enable dev features
hotReload: Boolean = false, // Enable hot reload
watchPaths: List[String] = List(), // Additional paths to watch
watchDebounceMs: Int = 100 // Debounce rapid changes
)
case class AppConfig(
server: ServerConfig,
database: DatabaseConfig,
dev: DevConfig = DevConfig()
)
```
Update application.conf:
```hocon
dev {
enabled = false
enabled = ${?DEV_MODE}
hot-reload = false
hot-reload = ${?HOT_RELOAD}
watch-paths = []
watch-debounce-ms = 100
}
```
**FileWatcher.scala:**
File change detection using java.nio.file.WatchService:
```scala
import java.nio.file.*
import java.nio.file.attribute.BasicFileAttributes
import scala.jdk.CollectionConverters.*
case class FileChangeEvent(
path: Path,
kind: FileChangeKind,
timestamp: Instant
)
enum FileChangeKind:
case Created, Modified, Deleted
trait FileWatcher:
def start: IO[FileWatchError, Unit]
def stop: IO[FileWatchError, Unit]
def events: ZStream[Any, FileWatchError, FileChangeEvent]
object FileWatcher:
def live(
watchPaths: List[Path],
debounceMs: Int = 100
): ZLayer[Any, Nothing, FileWatcher] =
ZLayer.fromZIO {
for
watchService <- ZIO.attemptBlocking(FileSystems.getDefault.newWatchService())
eventQueue <- Queue.unbounded[FileChangeEvent]
running <- Ref.make(false)
yield new FileWatcher:
def start: IO[FileWatchError, Unit] =
for
_ <- running.set(true)
// Register all directories recursively
_ <- ZIO.foreach(watchPaths)(registerRecursive(_, watchService))
// Start watch loop in background
_ <- watchLoop(watchService, eventQueue)
.ensuring(ZIO.attemptBlocking(watchService.close()).ignore)
.fork
yield ()
def stop: IO[FileWatchError, Unit] =
running.set(false)
def events: ZStream[Any, FileWatchError, FileChangeEvent] =
ZStream.fromQueue(eventQueue)
.groupedWithin(100, Duration.fromMillis(debounceMs))
.map(_.distinctBy(_.path)) // Dedupe rapid changes
.flatMap(ZStream.fromIterable)
private def watchLoop(
watcher: WatchService,
queue: Queue[FileChangeEvent]
): IO[FileWatchError, Unit] =
running.get.flatMap { isRunning =>
if !isRunning then ZIO.unit
else
for
key <- ZIO.attemptBlocking(watcher.poll(500, java.util.concurrent.TimeUnit.MILLISECONDS))
.mapError(FileWatchError.WatchFailed(_))
_ <- ZIO.whenCase(key) {
case k if k != null =>
val events = k.pollEvents().asScala.toList
val dir = k.watchable().asInstanceOf[Path]
ZIO.foreach(events) { event =>
val kind = event.kind()
if kind != StandardWatchEventKinds.OVERFLOW then
val relativePath = event.context().asInstanceOf[Path]
val fullPath = dir.resolve(relativePath)
val changeKind = kind match
case StandardWatchEventKinds.ENTRY_CREATE => FileChangeKind.Created
case StandardWatchEventKinds.ENTRY_MODIFY => FileChangeKind.Modified
case StandardWatchEventKinds.ENTRY_DELETE => FileChangeKind.Deleted
case _ => FileChangeKind.Modified
queue.offer(FileChangeEvent(fullPath, changeKind, Instant.now))
else ZIO.unit
} *> ZIO.attemptBlocking(k.reset()).ignore
}
_ <- watchLoop(watcher, queue)
yield ()
}
private def registerRecursive(path: Path, watcher: WatchService): IO[FileWatchError, Unit] =
ZIO.attemptBlocking {
if Files.isDirectory(path) then
Files.walkFileTree(path, new SimpleFileVisitor[Path] {
override def preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult = {
dir.register(
watcher,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.ENTRY_DELETE
)
FileVisitResult.CONTINUE
}
})
}.mapError(FileWatchError.RegistrationFailed(_))
}
enum FileWatchError:
case WatchFailed(cause: Throwable)
case RegistrationFailed(cause: Throwable)
```
Key features:
- Recursive directory registration
- Event debouncing to handle rapid saves
- Deduplication of same-path events
- Non-blocking poll with timeout
- Graceful shutdown via running flag
`./mill summercms.compile` succeeds
FileWatcher can be instantiated (manual test)
FileWatcher uses JDK WatchService for OS-native file change detection. Supports recursive watching, debouncing, and deduplication.
Task 2: Implement TemplateInvalidator and HotReloadService
summercms/src/hot/TemplateInvalidator.scala
summercms/src/hot/HotReloadService.scala
**TemplateInvalidator.scala:**
Targeted Pebble cache invalidation:
```scala
import io.pebbletemplates.pebble.PebbleEngine
import io.pebbletemplates.pebble.cache.PebbleCache
trait TemplateInvalidator:
def invalidate(path: Path): UIO[Unit]
def invalidateAll: UIO[Unit]
object TemplateInvalidator:
def live(pebbleEngine: PebbleEngine, themePath: Path): ULayer[TemplateInvalidator] =
ZLayer.succeed {
new TemplateInvalidator:
def invalidate(path: Path): UIO[Unit] =
ZIO.attemptBlocking {
// Convert path to template name
val templateName = themePath.relativize(path).toString
// Pebble's template cache is keyed by template name
val cache = pebbleEngine.getTemplateCache
cache.invalidate(templateName)
// Also invalidate tag cache (for custom tags)
pebbleEngine.getTagCache.invalidateAll()
}.ignore
def invalidateAll: UIO[Unit] =
ZIO.attemptBlocking {
pebbleEngine.getTemplateCache.invalidateAll()
pebbleEngine.getTagCache.invalidateAll()
}.ignore
}
```
**HotReloadService.scala:**
Orchestrates hot reload on file changes:
```scala
trait HotReloadService:
def start: IO[HotReloadError, Unit]
def stop: IO[HotReloadError, Unit]
def onFileChanged(event: FileChangeEvent): UIO[Unit]
case class HotReloadConfig(
enabled: Boolean,
watchPaths: List[Path],
debounceMs: Int
)
object HotReloadService:
def live(config: HotReloadConfig): ZLayer[
FileWatcher & TemplateInvalidator & CmsRouter,
Nothing,
HotReloadService
] =
ZLayer.fromZIO {
for
watcher <- ZIO.service[FileWatcher]
invalidator <- ZIO.service[TemplateInvalidator]
cmsRouter <- ZIO.service[CmsRouter]
running <- Ref.make(false)
yield new HotReloadService:
def start: IO[HotReloadError, Unit] =
if !config.enabled then
ZIO.logInfo("Hot reload disabled") *> ZIO.unit
else
for
_ <- ZIO.logInfo(s"Starting hot reload for paths: ${config.watchPaths}")
_ <- running.set(true)
_ <- watcher.start.mapError(HotReloadError.WatcherFailed(_))
// Process events in background
_ <- watcher.events
.tap(event => onFileChanged(event))
.runDrain
.fork
yield ()
def stop: IO[HotReloadError, Unit] =
for
_ <- running.set(false)
_ <- watcher.stop.mapError(HotReloadError.WatcherFailed(_))
_ <- ZIO.logInfo("Hot reload stopped")
yield ()
def onFileChanged(event: FileChangeEvent): UIO[Unit] =
val path = event.path
val extension = path.getFileName.toString.split('.').lastOption.getOrElse("")
extension match
case "htm" | "html" =>
// Template file changed
for
_ <- ZIO.logDebug(s"Template changed: $path")
_ <- invalidator.invalidate(path)
// Rebuild router if page URL might have changed
_ <- ZIO.when(path.toString.contains("/pages/")) {
cmsRouter.rebuild.ignore
}
yield ()
case "css" | "js" =>
// Asset changed - just log, browser handles via Vite
ZIO.logDebug(s"Asset changed: $path")
case "yaml" | "yml" =>
// Config changed - may need full invalidation
for
_ <- ZIO.logDebug(s"Config changed: $path")
_ <- invalidator.invalidateAll
yield ()
case _ =>
ZIO.logDebug(s"Ignored file change: $path")
enum HotReloadError:
case WatcherFailed(cause: FileWatchError)
case InvalidationFailed(cause: Throwable)
```
Key decisions (per CONTEXT.md Claude's discretion):
- Template changes: targeted cache invalidation
- Page changes: also rebuild router (URL might have changed)
- YAML changes: full cache invalidation
- Asset changes: logged only (Vite handles HMR)
- Debounce: 100ms default to handle editor save-on-type
`./mill summercms.compile` succeeds
HotReloadService methods have correct signatures
TemplateInvalidator provides targeted Pebble cache invalidation. HotReloadService orchestrates reload by processing file events and invalidating appropriate caches.
Task 3: Integrate hot reload with application startup
summercms/src/Main.scala
summercms/resources/application.conf
**Update Main.scala:**
Conditionally start hot reload in development mode:
```scala
object Main extends ZIOAppDefault:
override def run: ZIO[Any, Throwable, Unit] =
for
_ <- printBanner
config <- ZIO.service[AppConfig]
_ <- ZIO.logInfo(s"Starting SummerCMS on port ${config.server.port}")
// Initialize Pebble engine
pebbleEngine <- buildPebbleEngine(config)
// Start hot reload if enabled
_ <- ZIO.when(config.dev.enabled && config.dev.hotReload) {
for
_ <- ZIO.logInfo("Development mode: Hot reload enabled")
themePath <- ZIO.succeed(Path.of(config.theme.path))
watchPaths = themePath :: config.dev.watchPaths.map(Path.of)
hotReloadConfig = HotReloadConfig(
enabled = true,
watchPaths = watchPaths,
debounceMs = config.dev.watchDebounceMs
)
// Build hot reload layers
fileWatcherLayer = FileWatcher.live(watchPaths, config.dev.watchDebounceMs)
invalidatorLayer = TemplateInvalidator.live(pebbleEngine, themePath)
hotReloadLayer = HotReloadService.live(hotReloadConfig)
// Start hot reload service
_ <- ZIO.serviceWithZIO[HotReloadService](_.start)
.provide(
fileWatcherLayer,
invalidatorLayer,
hotReloadLayer,
CmsRouter.live(themePath)
)
yield ()
}
// Start HTTP server
_ <- startServer(config)
yield ()
.provide(
AppConfig.layer,
// ... other layers
)
private def buildPebbleEngine(config: AppConfig): UIO[PebbleEngine] =
ZIO.succeed {
new PebbleEngine.Builder()
.loader(new FileLoader())
.cacheActive(!config.dev.hotReload) // Disable cache in hot reload mode
.strictVariables(config.dev.enabled) // Strict mode in dev
.extension(new CmsPebbleExtension(...))
.build()
}
```
**Update application.conf:**
Add theme path configuration:
```hocon
theme {
path = "themes/default"
path = ${?THEME_PATH}
}
dev {
enabled = false
enabled = ${?DEV_MODE}
hot-reload = false
hot-reload = ${?HOT_RELOAD}
watch-paths = []
watch-debounce-ms = 100
}
```
**Development startup script (optional):**
Create scripts/dev.sh:
```bash
#!/bin/bash
export DEV_MODE=true
export HOT_RELOAD=true
./mill -w summercms.run
```
Note: Mill's --watch mode (-w) restarts on Scala code changes. Combined with our hot reload, this provides:
- Scala code changes: Mill restarts JVM
- Template changes: Hot reload invalidates cache (no restart)
- Asset changes: Vite HMR in browser
Production mode: All caching enabled, hot reload disabled, strict mode off.
`./mill summercms.compile` succeeds
DEV_MODE=true ./mill summercms.run starts with hot reload logging
Modify template file -> see "Template changed" log message
Hot reload integrated with application startup. Development mode enables file watching and cache invalidation. Production mode uses full caching.
After all tasks complete:
1. `./mill summercms.compile` - all code compiles
2. Test development mode:
- DEV_MODE=true HOT_RELOAD=true ./mill summercms.run
- See "Hot reload enabled" in logs
3. Test file watching:
- Modify themes/default/pages/home.htm
- See "Template changed" log
- Refresh browser -> see updated content immediately
4. Test cache invalidation:
- Modify layout -> layout changes reflected
- Modify partial -> partial changes reflected
- Add new page -> router rebuilt, new URL accessible
5. Test production mode:
- DEV_MODE=false ./mill summercms.run
- No hot reload logs
- Templates cached (verify via response time)
- Hot reload only active when DEV_MODE=true and HOT_RELOAD=true
- FileWatcher uses OS-native WatchService (not polling)
- Template changes invalidate specific template cache entry
- Page changes also trigger router rebuild
- Changes reflect immediately without JVM restart
- Production mode uses full caching for performance
- Event debouncing prevents rapid reload spam