16 KiB
Live Recipe Watching Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Watch the recipe directory for .cook file changes and update the in-memory recipe index without a server restart.
Architecture: Replace the [RecipeInfo] closure with an IORef (Map FilePath RecipeInfo). A forkIO'd watcher thread using fsnotify subscribes to filesystem events, debounces within a 200ms window, and updates the IORef via atomicModifyIORef'. HTTP handlers read from the IORef on each request.
Tech Stack: Haskell, fsnotify-0.4.4.0, Data.IORef, Data.Map.Strict, Data.Time.Clock, Control.Concurrent.
File Map
| File | Action | Purpose |
|---|---|---|
stack.yaml |
Modify | Add fsnotify as extra-dep (not in LTS-24.38) |
package.yaml |
Modify | Add fsnotify to library dependencies |
roux-server.cabal |
Regenerate | Via ./hs hpack |
src/Roux/Server.hs |
Modify | State type, watcher thread, handler updates |
No new files needed — all changes are contained in Server.hs.
Task 1: Add fsnotify dependency
Files:
-
Modify:
package.yaml,stack.yaml -
Regenerate:
roux-server.cabal -
Step 1: Add fsnotify to stack.yaml extra-deps
Since fsnotify is not in LTS-24.38, add it as an extra-dep in stack.yaml:
extra-deps:
- fsnotify-0.4.4.0
- Step 2: Add fsnotify to package.yaml library dependencies
- containers
- directory
- filepath
- fsnotify
- http-types
- Step 3: Regenerate roux-server.cabal
Run: ./hs hpack
Expected: no errors.
- Step 4: Verify stack can resolve fsnotify
Run: ./hs stack build --no-run-tests 2>&1 | tail -10
Expected: builds successfully (or at least resolves deps).
- Step 5: Commit
git add package.yaml stack.yaml roux-server.cabal
git commit -m "chore: add fsnotify dependency for live recipe watching"
Task 2: Refactor Server.hs for live watching
Files:
- Modify:
src/Roux/Server.hs
This is the main implementation task. All changes are in one file.
- Step 1: Rewrite imports
Replace all imports in src/Roux/Server.hs with:
{-# LANGUAGE OverloadedStrings #-}
{- | WAI application for the Roux recipe server.
Handles HTTP requests directly via wai — no framework.
Recipes are loaded from disk at startup, then watched live via fsnotify.
-}
module Roux.Server (
app,
) where
import Control.Concurrent (forkIO)
import Control.Concurrent.Chan (Chan, newChan, readChan)
import Control.Monad (when)
import Data.ByteString.Lazy (ByteString)
import Data.Char (toLower)
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Time.Clock (UTCTime, getCurrentTime, diffUTCTime)
import qualified Network.HTTP.Types as HTTP
import qualified Network.Wai as Wai
import System.Directory (doesFileExist)
import System.FilePath ((</>), takeBaseName)
import System.FSNotify (Event (..), EventIsDirectory (..), StopListening,
WatchConfig (..), WatchMode (..), withManagerConf,
watchTreeChan)
import qualified Roux.Html as Html
import qualified Roux.RecipeIndex as Idx
- Step 2: Rewrite
appto useIORefand fork the watcher
Replace the entire app function body:
-- | Build the WAI 'Application' given a path to the recipe directory.
--
-- Recipes are scanned at startup and then watched live via fsnotify.
app :: FilePath -> IO Wai.Application
app recipeDir = do
putStrLn $ "[roux] scanning recipes from " <> recipeDir
recipes <- Idx.scanRecipes recipeDir
let count = length recipes
ok = length [() | r <- recipes, isRight (Idx.riRecipe r)]
errs = count - ok
putStrLn $ "[roux] found " <> show count <> " recipe(s)"
when (errs > 0) $
putStrLn $
"[roux] " <> show errs <> " had parse errors"
-- Build initial map keyed by lowercased filename
let recipeMap = Map.fromList [(map toLower (Idx.riFilename r), r) | r <- recipes]
state <- newIORef recipeMap
-- Start the filesystem watcher
forkIO (watchRecipes recipeDir state)
pure (handleRequest state)
where
isRight (Right _) = True
isRight (Left _) = False
Note: isRight was previously a top-level function. It's now local to app. That's fine.
- Step 3: Add the
watchRecipesfunction
Add after app (before handleRequest):
-- | Watch a directory for .cook file changes and update the shared state.
-- Implements debouncing: events for the same path within 200ms are skipped.
watchRecipes :: FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> IO ()
watchRecipes dir state = do
putStrLn $ "[roux] watching " <> dir <> " for changes"
let cfg = defaultConfig{confWatchMode = WatchModeOS}
withManagerConf cfg $ \mgr -> do
chan <- newChan
_stopListening <- watchTreeChan mgr dir (const True) chan
-- lastSeen tracks when we last processed each path (for debouncing)
debounceLoop chan state dir Map.empty
where
debounceLoop :: Chan Event -> IORef (Map FilePath Idx.RecipeInfo) -> FilePath -> Map FilePath UTCTime -> IO ()
debounceLoop chan state dir lastSeen = do
ev <- readChan chan
-- Only process file events (skip directory events)
let isDir = eventIsDirectory ev
now <- getCurrentTime
if isDir == IsDirectory
then debounceLoop chan state dir lastSeen
else do
let path = eventPath ev
let key = map toLower path
let lastTime = Map.lookup key lastSeen
case lastTime of
Just t | diffUTCTime now t < 0.2 -> do
-- Debounced: skip, but pass through lastSeen
debounceLoop chan state dir lastSeen
_ -> do
-- Process the event
reReadRecipe dir path state
debounceLoop chan state dir (Map.insert key now lastSeen)
Key points:
-
Uses
defaultConfigwithWatchModeOS(inotify on Linux) -
watchTreeChantakes anActionPredicate(const Truemeans watch everything) -
readChanblocks until events arrive -
Debouncing: if the same path was processed within 0.2 seconds, skip
-
Directory events are filtered out
-
Step 4: Add the
reReadRecipehelper
Add after watchRecipes:
-- | Re-read a single recipe file and update the shared state.
-- On read/parse errors, logs the error and keeps the previous version.
reReadRecipe :: FilePath -> FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> IO ()
reReadRecipe dir path state = do
let key = map toLower path
let fullPath = dir </> path
exists <- doesFileExist fullPath
if not exists
then do
atomicModifyIORef' state $ \m ->
(Map.delete key m, ())
putStrLn $ "[roux] removed " <> path
else do
content <- readFile fullPath
let parsed = parseCookFile (T.pack content)
title = case parsed of
Right r -> extractTitle r path
Left _ -> T.pack (takeBaseName path)
case parsed of
Left err -> do
putStrLn $ "[roux] parse error in " <> path <> ": " <> err
putStrLn $ "[roux] keeping previous version"
Right _ -> do
let info =
Idx.RecipeInfo
{ Idx.riFilename = path
, Idx.riTitle = title
, Idx.riRecipe = parsed
}
atomicModifyIORef' state $ \m ->
(Map.insert key info m, ())
putStrLn $ "[roux] updated " <> path
where
extractTitle recipe filename =
case metaTitle (recipeMetadata recipe) of
Just t -> t
Nothing -> T.pack (takeBaseName filename)
metaTitle (Data.CookLang.Metadata m) = m ... -- WRONG, need to check Data.CookLang
Wait — the extractTitle function already exists in Roux.RecipeIndex. Let me check what's exported.
Idx.extractTitle is a module-internal function (not exported from Roux.RecipeIndex). The Idx.loadRecipe function does the same logic internally.
I have two options:
- Export
extractTitlefromRoux.RecipeIndex - Import
Data.CookLangand extract title inline
Option 1 is cleaner. I'll add extractTitle to the export list of RecipeIndex.hs.
Actually wait — looking at the Roux.RecipeIndex module:
module Roux.RecipeIndex (
RecipeInfo (..),
RecipeSearchEntry (..),
scanRecipes,
toSearchEntry,
) where
extractTitle is defined but not exported. I have a few choices:
Option A: Add extractTitle to the export list.
Option B: Use the existing loadRecipe pattern but that requires access to the raw recipe's metadata via Data.CookLang.
Option C: Write a local helper in Server.hs.
The simplest is Option A or creating a small helper in Roux.RecipeIndex that can load a single file (like loadRecipe).
Actually, the cleanest approach: export loadRecipe from RecipeIndex. It's already defined there. Then I can call Idx.reloadRecipe or reuse loadRecipe directly.
Looking at loadRecipe:
loadRecipe :: FilePath -> FilePath -> IO RecipeInfo
loadRecipe dir filename = do
content <- readFile (dir </> filename)
let parsed = parseCookFile (T.pack content)
title = case parsed of
Right r -> extractTitle r filename
Left _err -> T.pack (takeBaseName filename)
return RecipeInfo{ riFilename = filename, riTitle = title, riRecipe = parsed }
Perfect. I can either:
- Export
loadRecipefromRecipeIndexand call it fromServer.hs - Or keep the local
reReadRecipewith its own inline logic
Since loadRecipe is already tested and working, exporting it is cleaner. But actually, the plan says to keep changes minimal. Let me just add loadRecipe to the exports of RecipeIndex.hs and use it in Server.hs.
Let me update the Step 4 code:
reReadRecipe :: FilePath -> FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> IO ()
reReadRecipe dir path state = do
let fullPath = dir </> path
exists <- doesFileExist fullPath
if not exists
then do
let key = map toLower path
atomicModifyIORef' state $ \m ->
(Map.delete key m, ())
putStrLn $ "[roux] removed " <> path
else do
-- Use Idx.loadRecipe to read and parse
info <- Idx.loadRecipe dir path
let key = map toLower path
case Idx.riRecipe info of
Left err -> do
putStrLn $ "[roux] parse error in " <> path <> ": " <> err
putStrLn $ "[roux] keeping previous version"
Right _ -> do
atomicModifyIORef' state $ \m ->
(Map.insert key info m, ())
putStrLn $ "[roux] updated " <> path
This is much cleaner. I need to export loadRecipe from RecipeIndex.hs.
- Step 5: Update
handleRequestto acceptIORef
Replace handleRequest:
-- | Route an incoming request to the appropriate handler.
handleRequest :: IORef (Map FilePath Idx.RecipeInfo) -> Wai.Application
handleRequest state request respond = do
recipes <- readIORef state
let recipeList = Map.elems recipes
case Wai.pathInfo request of
-- Index page (default: alphabetical)
[] -> respond (htmlResponse (Html.indexPage Html.AlphaSort recipeList))
-- Index page (sorted by tags) — sort mode ignored, driven client-side
["sorted", "tags"] -> respond (htmlResponse (Html.indexPage Html.AlphaSort recipeList))
-- Index page (sorted by course/category) — sort mode ignored, driven client-side
["sorted", "course"] -> respond (htmlResponse (Html.indexPage Html.AlphaSort recipeList))
-- Recipe detail page
["recipes", rawPath] ->
case lookupRecipe (urlDecodePath rawPath) recipes of
Just info -> respond (htmlResponse (Html.recipePage info))
Nothing -> respond notFound
_ -> respond notFound
handleRequest is now an IO action — it reads from the IORef on each request. Wai.Application is Request -> (Response -> IO ResponseReceived) -> IO ResponseReceived, so returning IO is fine.
- Step 6: Simplify
lookupRecipeto useMap.lookup
Replace lookupRecipe:
-- | Look up a recipe by filename (case-insensitive, .cook extension optional).
lookupRecipe :: FilePath -> Map FilePath Idx.RecipeInfo -> Maybe Idx.RecipeInfo
lookupRecipe target recipes =
Map.lookup (map toLower (stripCookExt target)) recipes
- Step 7: Remove
stripCookExtandurlDecodePathhelpers
These are unchanged. Keep them.
- Step 8: Remove unused imports
Remove from imports:
Data.List (find)— no longer used
Keep:
-
Data.Char (toLower)— still used inlookupRecipeandreReadRecipe -
Step 9: Export
loadRecipefromRecipeIndex.hs
In src/Roux/RecipeIndex.hs, add loadRecipe to the export list:
module Roux.RecipeIndex (
RecipeInfo (..),
RecipeSearchEntry (..),
loadRecipe,
scanRecipes,
toSearchEntry,
) where
- Step 10: Build to verify compilation
Run: ./hs stack build
Expected: compiles without errors.
- Step 11: Run tests to verify nothing broke
Run: ./hs stack test
Expected: all tests pass.
- Step 12: Commit
git add src/Roux/Server.hs src/Roux/RecipeIndex.hs
git commit -m "feat: watch recipe directory for live updates via fsnotify"
Task 3: Manual smoke test
Files: (none, manual testing)
- Step 1: Start the server in the background
./hs stack exec roux-server -- --recipe-dir recipes &
sleep 2
- Step 2: Verify a recipe loads
curl -s http://localhost:8080/ | grep -i "recipe" | head -5
Expected: index page HTML with recipe titles.
- Step 3: Check server logs for watching confirmation
Should see: [roux] watching recipes for changes
- Step 4: Modify a recipe file and verify the change is picked up
# Pick a recipe, temporarily replace content
RECIPE=$(ls recipes/*.cook | head -1)
RECIPE_NAME=$(basename "$RECIPE" .cook)
echo "---" > /tmp/test_change.cook
echo "title: Test Live Update" >> /tmp/test_change.cook
echo "---" >> /tmp/test_change.cook
echo "Testing live file watching." >> /tmp/test_change.cook
cp "$RECIPE" /tmp/backup.cook
cp /tmp/test_change.cook "$RECIPE"
sleep 1
curl -s "http://localhost:8080/recipes/$RECIPE_NAME" | grep "Test Live Update"
# Restore original
cp /tmp/backup.cook "$RECIPE"
Expected: grep finds "Test Live Update" in the response.
- Step 5: Delete a recipe file and verify 404
RECIPE2=$(ls recipes/*.cook | grep -v "$(basename "$RECIPE")" | head -1)
RECIPE2_NAME=$(basename "$RECIPE2" .cook)
cp "$RECIPE2" /tmp/backup2.cook
rm "$RECIPE2"
sleep 1
curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/recipes/$RECIPE2_NAME"
# Restore
cp /tmp/backup2.cook "$RECIPE2"
Expected: HTTP 404.
- Step 6: Add a new recipe file and verify it appears
echo "---" > /tmp/new_recipe.cook
echo "title: New Test Recipe" >> /tmp/new_recipe.cook
echo "---" >> /tmp/new_recipe.cook
echo "A brand new recipe." >> /tmp/new_recipe.cook
cp /tmp/new_recipe.cook recipes/new_test_recipe.cook
sleep 1
curl -s "http://localhost:8080/recipes/new_test_recipe" | grep "New Test Recipe"
# Cleanup
rm recipes/new_test_recipe.cook
Expected: grep finds "New Test Recipe" in the response.
- Step 7: Kill the server
kill %1 2>/dev/null; wait 2>/dev/null