--- 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: - "Developer edits template file, browser refresh shows changes without server restart" - "Page/layout/partial modifications reflect immediately after save" - "Hot reload only active when DEV_MODE environment variable is true" - "File system changes detected efficiently without CPU overhead" - "Only changed template is reloaded, not the entire cache" artifacts: - path: "summercms/src/hot/FileWatcher.scala" provides: "File change detection" 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 - Template file saved -> changes visible on next browser refresh (no restart) - Targeted invalidation: only changed template reloaded, not all templates - Page changes also trigger router rebuild for URL changes - Production mode uses full caching for performance - Event debouncing prevents rapid reload spam during fast typing After completion, create `.planning/phases/09-content-management/09-06-SUMMARY.md`