package main import ( "log" "os" "path/filepath" "strings" "time" "github.com/fsnotify/fsnotify" ) // startBoardsWatcher launches a goroutine that watches dev/issues and // dev/flows (recursively, one level deep — completed/ subdir) for .md // changes and broadcasts ServerEvent messages via the hub. It also // invalidates the relevant cardsCache so the next /cards GET reflects // disk state. // // On fsnotify errors the watcher logs + retries every 30s. func startBoardsWatcher(hub *Hub) { go func() { for { if err := runBoardsWatcher(hub); err != nil { log.Printf("sse_watcher: %v — retrying in 30s", err) time.Sleep(30 * time.Second) } } }() } func runBoardsWatcher(hub *Hub) error { w, err := fsnotify.NewWatcher() if err != nil { return err } defer w.Close() roots := map[string]string{ "issues": issuesDir(), "flows": flowsDir(), } for board, dir := range roots { if err := addRecursive(w, dir); err != nil { log.Printf("sse_watcher: watch %s (%s): %v", board, dir, err) } else { log.Printf("sse_watcher: watching %s -> %s", board, dir) } } for { select { case ev, ok := <-w.Events: if !ok { return nil } handleFsEvent(hub, ev) case err, ok := <-w.Errors: if !ok { return nil } log.Printf("sse_watcher: fsnotify error: %v", err) } } } // addRecursive adds dir and its immediate subdirectories (e.g. // dev/issues/completed/) to the watcher. We do NOT follow symlinks. func addRecursive(w *fsnotify.Watcher, root string) error { if err := w.Add(root); err != nil { return err } entries, err := readDirNoSymlink(root) if err != nil { return nil // root added, subdirs best-effort } for _, name := range entries { full := filepath.Join(root, name) // best-effort, ignore errors on subdirs _ = w.Add(full) } return nil } // handleFsEvent translates an fsnotify event into a ServerEvent (if // applicable) and broadcasts it. Non-md files and skipped names // (README/INDEX/...) are ignored. func handleFsEvent(hub *Hub, ev fsnotify.Event) { board, cardID, action := classifyEvent(ev) if board == "" { return } // Invalidate the right cache so the next GET re-scans disk. switch board { case "issues": if issuesCache != nil { issuesCache.invalidate() } case "flows": if flowsCache != nil { flowsCache.invalidate() } } if hub == nil { return } hub.Broadcast(ServerEvent{ Board: board, CardID: cardID, Action: action, EventType: sseEventForAction(action), }) } // classifyEvent inspects a raw fsnotify event and returns // (board, cardID, action) — board is "" if the event is irrelevant. func classifyEvent(ev fsnotify.Event) (board, cardID, action string) { name := filepath.Base(ev.Name) if !strings.HasSuffix(strings.ToLower(name), ".md") { return "", "", "" } if isSkippedMarkdown(name) { return "", "", "" } // Determine board from the path. lower := strings.ToLower(filepath.ToSlash(ev.Name)) switch { case strings.Contains(lower, "/dev/issues/"): board = "issues" case strings.Contains(lower, "/dev/flows/"): board = "flows" default: return "", "", "" } cardID = deriveIDFromFilename(name) switch { case ev.Op&fsnotify.Create != 0: action = "created" case ev.Op&fsnotify.Remove != 0: action = "deleted" case ev.Op&fsnotify.Rename != 0: // Treat rename as updated — the file likely moved between // canonical dir and completed/. action = "updated" case ev.Op&fsnotify.Write != 0: action = "updated" default: return "", "", "" } return board, cardID, action } func sseEventForAction(action string) string { switch action { case "created": return "card_added" case "deleted": return "card_removed" default: return "card_changed" } } // readDirNoSymlink lists subdirectory names under root, skipping symlinks // and hidden entries. func readDirNoSymlink(root string) ([]string, error) { entries, err := os.ReadDir(root) if err != nil { return nil, err } out := []string{} for _, e := range entries { if !e.IsDir() { continue } name := e.Name() if strings.HasPrefix(name, ".") { continue } // Detect symlinks: lstat the full path. full := filepath.Join(root, name) info, err := os.Lstat(full) if err != nil { continue } if info.Mode()&os.ModeSymlink != 0 { continue } out = append(out, name) } return out, nil }