feat: wire sse_client_cpp_core for live updates from /api/boards/issues/stream
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user