From c7bff7937146024c223ab9c83b8c2c8558524ab5 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Tue, 19 May 2026 22:53:55 -0400 Subject: [PATCH 1/8] Add design doc for live recipe watching via fsnotify --- .../2026-05-19-live-recipe-watching-design.md | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/specs/2026-05-19-live-recipe-watching-design.md diff --git a/docs/specs/2026-05-19-live-recipe-watching-design.md b/docs/specs/2026-05-19-live-recipe-watching-design.md new file mode 100644 index 0000000..ad55f14 --- /dev/null +++ b/docs/specs/2026-05-19-live-recipe-watching-design.md @@ -0,0 +1,118 @@ +# Live Recipe Watching + +**Date:** 2026-05-19 +**Status:** Approved + +## Problem + +The server loads all recipes once at startup and serves them read-only for the +duration of the server process. Since the recipe directory is the source of +truth, any edits to `.cook` files on disk require a server restart to be +reflected on the site. + +## Goal + +Watch the recipe directory for file changes (additions, modifications, deletions) +and update the in-memory recipe index live, without a restart. + +## Non-Goals + +- Real-time push to open browser tabs (a page refresh will show new content). +- Watching subdirectories (all recipes are flat in one directory). +- Watcher restart on failure (log and let it die; restart the container if needed). + +## Architecture + +### State + +Replace the current `[RecipeInfo]` closure with an `IORef (Map FilePath RecipeInfo)`: + +- **`IORef`** — provides atomic reads and swaps; in `base`, no extra dependency. +- **`Map`** — `Data.Map.Strict`, already a project dependency. +- **Key** — lowercased filename (e.g., `"pad-thai.cook"`) for O(log n) case-insensitive + lookups, matching the current `lookupRecipe` behavior. +- **Value** — the existing `RecipeInfo`, which includes the original (mixed-case) + filename in `riFilename`. + +### Watcher Thread + +A `forkIO`'d thread uses the `fsnotify` package to subscribe to all events on the +recipe directory. Three event types are handled: + +| Event | Action | +|------------|-----------------------------------------------------| +| `Added` | Read & parse the file → upsert in the Map | +| `Modified` | Read & parse the file → upsert in the Map | +| `Removed` | Delete from the Map | + +All mutations go through `atomicModifyIORef'` so HTTP handlers always see a +consistent snapshot. + +### Debouncing + +Text editors fire multiple filesystem events per save (e.g., delete+create for +atomic saves, multiple metadata events). A simple 200ms debounce collects +events from `fsnotify`'s callback into a set, then processes them after 200ms of +silence. This prevents redundant re-parses. + +### Startup Flow + +1. Scan the recipe directory with `Idx.scanRecipes` (same as today). +2. Build the initial `IORef (Map FilePath RecipeInfo)`. +3. `forkIO` the watcher thread. +4. Return the WAI `Application` (now closing over the `IORef`). + +### Request Handling + +- `handleRequest` reads from the `IORef` to get the current `[RecipeInfo]` + (via `Map.elems`) or a single `RecipeInfo` (via `Map.lookup`). +- The index page continues to receive `[RecipeInfo]` and re-renders on each + request — no caching changes needed. +- `lookupRecipe` becomes a `Map.lookup` with a normalized key instead of a + linear scan. + +### Error Handling + +- **Parse error on modification:** log the error (with `[roux]` prefix), keep + the old recipe in the Map. The last valid version stays accessible. +- **File read error (permissions, race):** log and skip. +- **Watcher thread crash:** log and let it die. The server continues serving + the last known state. A container restart would restore watching. + +## Dependencies + +Add `fsnotify` to `package.yaml` (and the `.cabal` file via `hpack`). + +No new concurrency libraries needed — `IORef` and `forkIO` are in `base`. + +## Changes by File + +### `package.yaml` +- Add `fsnotify` to library dependencies. + +### `src/Roux/RecipeIndex.hs` +- No changes (the scanning/loading functions are already correct). + +### `src/Roux/Server.hs` +- `app :: FilePath -> IO Wai.Application` — same signature, new internals: + - Return type is already `IO Wai.Application`, so signature stays the same. + - Scan recipes → populate `IORef`. + - Fork watcher thread (with debounce). + - Return handler closing over the `IORef`. +- `handleRequest :: IORef (Map FilePath RecipeInfo) -> Wai.Application` + — updated to accept the `IORef`. +- `lookupRecipe :: FilePath -> Map FilePath RecipeInfo -> Maybe RecipeInfo` + — simplified to a `Map.lookup` with normalized key. +- New module-level imports: `Data.IORef`, `Control.Concurrent (forkIO)`, + `System.FSNotify`, `Data.Map.Strict`. + +### `app/Main.hs` +- No changes needed (it just calls `Roux.app` as before). + +## Testing + +- **Manual test:** edit a `.cook` file while the server runs and confirm the + change is reflected on a page refresh. +- **Invalid recipe test:** introduce a parse error and confirm the old version + stays live. +- **Delete test:** remove a `.cook` file and confirm a 404 on refresh. From 042bcc9e5171dbb0cce2952887e30f6fc31761f3 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Tue, 19 May 2026 22:58:07 -0400 Subject: [PATCH 2/8] Add implementation plan for live recipe watching --- docs/plans/2026-05-19-live-recipe-watching.md | 473 ++++++++++++++++++ 1 file changed, 473 insertions(+) create mode 100644 docs/plans/2026-05-19-live-recipe-watching.md diff --git a/docs/plans/2026-05-19-live-recipe-watching.md b/docs/plans/2026-05-19-live-recipe-watching.md new file mode 100644 index 0000000..cfc94cf --- /dev/null +++ b/docs/plans/2026-05-19-live-recipe-watching.md @@ -0,0 +1,473 @@ +# 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`: + +```yaml +extra-deps: + - fsnotify-0.4.4.0 +``` + +- [ ] **Step 2: Add fsnotify to package.yaml library dependencies** + +```yaml + - 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** + +```bash +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: + +```haskell +{-# 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 `app` to use `IORef` and fork the watcher** + +Replace the entire `app` function body: + +```haskell +-- | 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 `watchRecipes` function** + +Add after `app` (before `handleRequest`): + +```haskell +-- | 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 `defaultConfig` with `WatchModeOS` (inotify on Linux) +- `watchTreeChan` takes an `ActionPredicate` (`const True` means watch everything) +- `readChan` blocks until events arrive +- Debouncing: if the same path was processed within 0.2 seconds, skip +- Directory events are filtered out + +- [ ] **Step 4: Add the `reReadRecipe` helper** + +Add after `watchRecipes`: + +```haskell +-- | 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: +1. Export `extractTitle` from `Roux.RecipeIndex` +2. Import `Data.CookLang` and 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: + +```haskell +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`: + +```haskell +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 `loadRecipe` from `RecipeIndex` and call it from `Server.hs` +- Or keep the local `reReadRecipe` with 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: + +```haskell +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 `handleRequest` to accept `IORef`** + +Replace `handleRequest`: + +```haskell +-- | 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 `lookupRecipe` to use `Map.lookup`** + +Replace `lookupRecipe`: + +```haskell +-- | 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 `stripCookExt` and `urlDecodePath` helpers** + +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 in `lookupRecipe` and `reReadRecipe` + +- [ ] **Step 9: Export `loadRecipe` from `RecipeIndex.hs`** + +In `src/Roux/RecipeIndex.hs`, add `loadRecipe` to the export list: + +```haskell +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** + +```bash +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** + +```bash +./hs stack exec roux-server -- --recipe-dir recipes & +sleep 2 +``` + +- [ ] **Step 2: Verify a recipe loads** + +```bash +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** + +```bash +# 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** + +```bash +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** + +```bash +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** + +```bash +kill %1 2>/dev/null; wait 2>/dev/null +``` From a8402cf831a63f8595222805dfca9040249944a1 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Tue, 19 May 2026 23:08:02 -0400 Subject: [PATCH 3/8] chore: add fsnotify dependency for live recipe watching --- package.yaml | 1 + roux-server.cabal | 6 ++++++ stack.yaml | 3 +++ stack.yaml.lock | 9 ++++++++- 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/package.yaml b/package.yaml index d21c6d4..e75cb48 100644 --- a/package.yaml +++ b/package.yaml @@ -35,6 +35,7 @@ dependencies: - containers - directory - filepath + - fsnotify - http-types - optparse-applicative - parsec diff --git a/roux-server.cabal b/roux-server.cabal index c7869ea..bf4f78c 100644 --- a/roux-server.cabal +++ b/roux-server.cabal @@ -22,6 +22,7 @@ library Roux.NYTimes Roux.Parser Roux.RecipeIndex + Roux.SchemaOrg Roux.Server Roux.Types other-modules: @@ -45,6 +46,7 @@ library , containers , directory , filepath + , fsnotify , http-types , optparse-applicative , parsec @@ -77,6 +79,7 @@ executable check-recipes , containers , directory , filepath + , fsnotify , http-types , optparse-applicative , parsec @@ -110,6 +113,7 @@ executable roux-server , containers , directory , filepath + , fsnotify , http-types , optparse-applicative , parsec @@ -127,6 +131,7 @@ test-suite roux-server-test other-modules: Roux.NYTimesSpec Roux.ParserSpec + Roux.SchemaOrgSpec Paths_roux_server autogen-modules: Paths_roux_server @@ -147,6 +152,7 @@ test-suite roux-server-test , containers , directory , filepath + , fsnotify , hspec , http-types , optparse-applicative diff --git a/stack.yaml b/stack.yaml index 007bafe..1e7c2da 100644 --- a/stack.yaml +++ b/stack.yaml @@ -2,3 +2,6 @@ resolver: lts-24.38 packages: - . + +extra-deps: + - fsnotify-0.4.4.0 diff --git a/stack.yaml.lock b/stack.yaml.lock index d025909..89b8320 100644 --- a/stack.yaml.lock +++ b/stack.yaml.lock @@ -3,7 +3,14 @@ # For more information, please see the documentation at: # https://docs.haskellstack.org/en/stable/topics/lock_files -packages: [] +packages: +- completed: + hackage: fsnotify-0.4.4.0@sha256:21cc696377bf01bc9b0a2e1edf2e03e43f6655d52d0622bdb493192820e1a5ba,3624 + pantry-tree: + sha256: 1f7fb52c136e080355803941c0b772640a19c4fd418eb56fa62cc24859092b36 + size: 1408 + original: + hackage: fsnotify-0.4.4.0 snapshots: - completed: sha256: abc790b571e0c70e929db74b329e3c18d7e76a6e173e8bdf94f1ba20770d4c24 From a2a8ea7b9911e5bbe984d6be6081aa9d8d933be2 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Tue, 19 May 2026 23:14:24 -0400 Subject: [PATCH 4/8] feat: watch recipe directory for live updates via fsnotify --- src/Roux/RecipeIndex.hs | 6 ++- src/Roux/Server.hs | 109 +++++++++++++++++++++++++++++++++------- 2 files changed, 96 insertions(+), 19 deletions(-) diff --git a/src/Roux/RecipeIndex.hs b/src/Roux/RecipeIndex.hs index c295625..ef59d51 100644 --- a/src/Roux/RecipeIndex.hs +++ b/src/Roux/RecipeIndex.hs @@ -4,6 +4,7 @@ module Roux.RecipeIndex ( RecipeInfo (..), RecipeSearchEntry (..), + loadRecipe, scanRecipes, toSearchEntry, ) where @@ -81,8 +82,9 @@ toSearchEntry info = , rseTags = tags } --- | Extract a display title from the recipe's metadata or fall back to the --- filename. +{- | Extract a display title from the recipe's metadata or fall back to the +filename. +-} extractTitle :: Recipe -> FilePath -> Text extractTitle recipe filename = case metaTitle (recipeMetadata recipe) of diff --git a/src/Roux/Server.hs b/src/Roux/Server.hs index a268f96..d4449eb 100644 --- a/src/Roux/Server.hs +++ b/src/Roux/Server.hs @@ -3,26 +3,42 @@ {- | 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.List (find) +import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef) +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as Map import Data.Text (Text) +import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime) import qualified Network.HTTP.Types as HTTP import qualified Network.Wai as Wai +import System.Directory (doesFileExist) +import System.FSNotify ( + Event (..), + EventIsDirectory (..), + WatchConfig (..), + WatchMode (..), + defaultConfig, + watchTreeChan, + withManagerConf, + ) +import System.FilePath (()) import qualified Roux.Html as Html import qualified Roux.RecipeIndex as Idx {- | Build the WAI 'Application' given a path to the recipe directory. -Recipes are scanned once at startup. A future improvement could watch -the directory for changes and rebuild the index. +Recipes are scanned at startup and then watched live via fsnotify. -} app :: FilePath -> IO Wai.Application app recipeDir = do @@ -35,18 +51,83 @@ app recipeDir = do when (errs > 0) $ putStrLn $ "[roux] " <> show errs <> " had parse errors" - pure (handleRequest recipes) + -- 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 + +{- | 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 + debounceLoop chan state dir Map.empty + where + debounceLoop :: Chan Event -> IORef (Map FilePath Idx.RecipeInfo) -> FilePath -> Map FilePath UTCTime -> IO () + debounceLoop chan st dr lastSeen = do + ev <- readChan chan + let isDir = eventIsDirectory ev + now <- getCurrentTime + if isDir == IsDirectory + then debounceLoop chan st dr 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 + debounceLoop chan st dr lastSeen + _ -> do + reReadRecipe dr path st + debounceLoop chan st dr (Map.insert key now lastSeen) + +{- | 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 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 + 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 -- | Route an incoming request to the appropriate handler. -handleRequest :: [Idx.RecipeInfo] -> Wai.Application -handleRequest recipes request respond = +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 recipes)) + [] -> 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 recipes)) + ["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 recipes)) + ["sorted", "course"] -> respond (htmlResponse (Html.indexPage Html.AlphaSort recipeList)) -- Recipe detail page ["recipes", rawPath] -> case lookupRecipe (urlDecodePath rawPath) recipes of @@ -55,10 +136,9 @@ handleRequest recipes request respond = _ -> respond notFound -- | Look up a recipe by filename (case-insensitive, .cook extension optional). -lookupRecipe :: FilePath -> [Idx.RecipeInfo] -> Maybe Idx.RecipeInfo +lookupRecipe :: FilePath -> Map FilePath Idx.RecipeInfo -> Maybe Idx.RecipeInfo lookupRecipe target recipes = - let normalised = map toLower (stripCookExt target) - in find (\(Idx.RecipeInfo fp _ _) -> map toLower (stripCookExt fp) == normalised) recipes + Map.lookup (map toLower (stripCookExt target)) recipes -- | Strip the .cook extension if present. stripCookExt :: FilePath -> FilePath @@ -86,8 +166,3 @@ notFound = HTTP.status404 [("Content-Type", "text/plain")] "Not found" - --- | Check if an 'Either' is 'Right'. -isRight :: Either a b -> Bool -isRight (Right _) = True -isRight (Left _) = False From d8e2cc101d8aed77ec6c76ddbde3c1785e35cde9 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Tue, 19 May 2026 23:23:04 -0400 Subject: [PATCH 5/8] fix: correct path handling in live recipe watching - Strip .cook from map keys to match lookupRecipe behavior - Use makeRelative for event paths to match relative map keys - Filter watcher to only .cook files - Log watcher thread exceptions - Use imported isSuffixOf from Data.List --- src/Roux/Server.hs | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/Roux/Server.hs b/src/Roux/Server.hs index d4449eb..55ddb30 100644 --- a/src/Roux/Server.hs +++ b/src/Roux/Server.hs @@ -11,10 +11,12 @@ module Roux.Server ( import Control.Concurrent (forkIO) import Control.Concurrent.Chan (Chan, newChan, readChan) +import Control.Exception (SomeException, catch) import Control.Monad (when) import Data.ByteString.Lazy (ByteString) import Data.Char (toLower) import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef) +import Data.List (isSuffixOf) import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map import Data.Text (Text) @@ -31,7 +33,7 @@ import System.FSNotify ( watchTreeChan, withManagerConf, ) -import System.FilePath (()) +import System.FilePath (makeRelative, ()) import qualified Roux.Html as Html import qualified Roux.RecipeIndex as Idx @@ -51,11 +53,14 @@ app recipeDir = do 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] + -- Build initial map keyed by lowercased filename (without .cook extension) + let recipeMap = Map.fromList [(map toLower (stripCookExt (Idx.riFilename r)), r) | r <- recipes] state <- newIORef recipeMap -- Start the filesystem watcher - _ <- forkIO (watchRecipes recipeDir state) + _ <- + forkIO $ + watchRecipes recipeDir state `catch` \e -> + putStrLn $ "[roux] watcher thread crashed: " <> show (e :: SomeException) pure (handleRequest state) where isRight (Right _) = True @@ -70,23 +75,23 @@ watchRecipes dir state = do let cfg = defaultConfig{confWatchMode = WatchModeOS} withManagerConf cfg $ \mgr -> do chan <- newChan - _stopListening <- watchTreeChan mgr dir (const True) chan + _stopListening <- watchTreeChan mgr dir ((".cook" `isSuffixOf`) . eventPath) chan debounceLoop chan state dir Map.empty where debounceLoop :: Chan Event -> IORef (Map FilePath Idx.RecipeInfo) -> FilePath -> Map FilePath UTCTime -> IO () debounceLoop chan st dr lastSeen = do ev <- readChan chan - let isDir = eventIsDirectory ev - now <- getCurrentTime - if isDir == IsDirectory + if eventIsDirectory ev == IsDirectory then debounceLoop chan st dr lastSeen else do - let path = eventPath ev - let key = map toLower path + let path = makeRelative dr (eventPath ev) + let key = map toLower (stripCookExt path) + now <- getCurrentTime let lastTime = Map.lookup key lastSeen case lastTime of - Just t | diffUTCTime now t < 0.2 -> do - debounceLoop chan st dr lastSeen + Just t + | diffUTCTime now t < 0.2 -> + debounceLoop chan st dr lastSeen _ -> do reReadRecipe dr path st debounceLoop chan st dr (Map.insert key now lastSeen) @@ -96,17 +101,16 @@ 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 (stripCookExt path) 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 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 @@ -145,8 +149,6 @@ stripCookExt :: FilePath -> FilePath stripCookExt fp | ".cook" `isSuffixOf` fp = take (length fp - 5) fp | otherwise = fp - where - isSuffixOf suffix s = suffix == drop (length s - length suffix) s -- | Decode a percent-encoded URL path segment to a FilePath. urlDecodePath :: Text -> FilePath From 5bfd3f2308ebf23fa9bd1b6c9989bcd695f87a7c Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Tue, 19 May 2026 23:28:51 -0400 Subject: [PATCH 6/8] fix: use absolute dir path for watcher makeRelative consistency --- src/Roux/Server.hs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Roux/Server.hs b/src/Roux/Server.hs index 55ddb30..56fc54b 100644 --- a/src/Roux/Server.hs +++ b/src/Roux/Server.hs @@ -23,7 +23,7 @@ import Data.Text (Text) import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime) import qualified Network.HTTP.Types as HTTP import qualified Network.Wai as Wai -import System.Directory (doesFileExist) +import System.Directory (doesFileExist, makeAbsolute) import System.FSNotify ( Event (..), EventIsDirectory (..), @@ -56,10 +56,12 @@ app recipeDir = do -- Build initial map keyed by lowercased filename (without .cook extension) let recipeMap = Map.fromList [(map toLower (stripCookExt (Idx.riFilename r)), r) | r <- recipes] state <- newIORef recipeMap + -- Resolve to absolute path so makeRelative works with fsnotify events + absDir <- makeAbsolute recipeDir -- Start the filesystem watcher _ <- forkIO $ - watchRecipes recipeDir state `catch` \e -> + watchRecipes absDir state `catch` \e -> putStrLn $ "[roux] watcher thread crashed: " <> show (e :: SomeException) pure (handleRequest state) where From 956a4bc7d7db4029160d7bcdc650c83877755966 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Tue, 19 May 2026 23:35:32 -0400 Subject: [PATCH 7/8] fix: use watchDir callback instead of watchTreeChan for reliable event delivery --- src/Roux/Server.hs | 38 +++++++++++--------------------------- 1 file changed, 11 insertions(+), 27 deletions(-) diff --git a/src/Roux/Server.hs b/src/Roux/Server.hs index 56fc54b..a5d2cc7 100644 --- a/src/Roux/Server.hs +++ b/src/Roux/Server.hs @@ -9,10 +9,9 @@ module Roux.Server ( app, ) where -import Control.Concurrent (forkIO) -import Control.Concurrent.Chan (Chan, newChan, readChan) +import Control.Concurrent (forkIO, threadDelay) import Control.Exception (SomeException, catch) -import Control.Monad (when) +import Control.Monad (forever, when) import Data.ByteString.Lazy (ByteString) import Data.Char (toLower) import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef) @@ -20,7 +19,7 @@ import Data.List (isSuffixOf) import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map import Data.Text (Text) -import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime) + import qualified Network.HTTP.Types as HTTP import qualified Network.Wai as Wai import System.Directory (doesFileExist, makeAbsolute) @@ -30,7 +29,7 @@ import System.FSNotify ( WatchConfig (..), WatchMode (..), defaultConfig, - watchTreeChan, + watchDir, withManagerConf, ) import System.FilePath (makeRelative, ()) @@ -69,34 +68,19 @@ app recipeDir = do isRight (Left _) = False {- | Watch a directory for .cook file changes and update the shared state. -Implements debouncing: events for the same path within 200ms are skipped. +Uses watchDir callback for simplicity. -} 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 ((".cook" `isSuffixOf`) . eventPath) chan - debounceLoop chan state dir Map.empty - where - debounceLoop :: Chan Event -> IORef (Map FilePath Idx.RecipeInfo) -> FilePath -> Map FilePath UTCTime -> IO () - debounceLoop chan st dr lastSeen = do - ev <- readChan chan - if eventIsDirectory ev == IsDirectory - then debounceLoop chan st dr lastSeen - else do - let path = makeRelative dr (eventPath ev) - let key = map toLower (stripCookExt path) - now <- getCurrentTime - let lastTime = Map.lookup key lastSeen - case lastTime of - Just t - | diffUTCTime now t < 0.2 -> - debounceLoop chan st dr lastSeen - _ -> do - reReadRecipe dr path st - debounceLoop chan st dr (Map.insert key now lastSeen) + _stopListening <- watchDir mgr dir (const True) $ \ev -> + when (eventIsDirectory ev == IsFile && ".cook" `isSuffixOf` eventPath ev) $ do + let path = makeRelative dir (eventPath ev) + reReadRecipe dir path state + -- Keep the thread alive (watchDir runs the callback on the manager thread) + forever $ threadDelay 1000000 {- | Re-read a single recipe file and update the shared state. On read/parse errors, logs the error and keeps the previous version. From ce9062a4b375cab19d46ea4c11087ed32187a511 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Tue, 19 May 2026 23:40:13 -0400 Subject: [PATCH 8/8] fix: catch IOExceptions in reReadRecipe and wrap watchDir callback for resilience --- src/Roux/Server.hs | 47 +++++++++++++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/src/Roux/Server.hs b/src/Roux/Server.hs index a5d2cc7..7cfc764 100644 --- a/src/Roux/Server.hs +++ b/src/Roux/Server.hs @@ -1,4 +1,5 @@ {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} {- | WAI application for the Roux recipe server. @@ -10,7 +11,7 @@ module Roux.Server ( ) where import Control.Concurrent (forkIO, threadDelay) -import Control.Exception (SomeException, catch) +import Control.Exception (IOException, SomeException, catch, try) import Control.Monad (forever, when) import Data.ByteString.Lazy (ByteString) import Data.Char (toLower) @@ -76,9 +77,16 @@ watchRecipes dir state = do let cfg = defaultConfig{confWatchMode = WatchModeOS} withManagerConf cfg $ \mgr -> do _stopListening <- watchDir mgr dir (const True) $ \ev -> - when (eventIsDirectory ev == IsFile && ".cook" `isSuffixOf` eventPath ev) $ do - let path = makeRelative dir (eventPath ev) - reReadRecipe dir path state + catch + ( do + when (eventIsDirectory ev == IsFile && ".cook" `isSuffixOf` eventPath ev) $ do + let path = makeRelative dir (eventPath ev) + reReadRecipe dir path state + ) + ( \e -> + putStrLn $ + "[roux] watchDir callback error: " <> show (e :: SomeException) + ) -- Keep the thread alive (watchDir runs the callback on the manager thread) forever $ threadDelay 1000000 @@ -89,22 +97,27 @@ reReadRecipe :: FilePath -> FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> I reReadRecipe dir path state = do let key = map toLower (stripCookExt path) let fullPath = dir path - exists <- doesFileExist fullPath - if not exists - then do + result <- try (doesFileExist fullPath) + case result of + Left (_ :: IOException) -> do + putStrLn $ "[roux] IO error checking " <> path <> ", skipping" + Right False -> do atomicModifyIORef' state $ \m -> (Map.delete key m, ()) putStrLn $ "[roux] removed " <> path - else do - info <- Idx.loadRecipe dir 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 + Right True -> do + content <- try (Idx.loadRecipe dir path) + case content of + Left (_ :: IOException) -> do + putStrLn $ "[roux] IO error reading " <> path <> ", keeping previous version" + Right info -> 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 -- | Route an incoming request to the appropriate handler. handleRequest :: IORef (Map FilePath Idx.RecipeInfo) -> Wai.Application