feat: wire sse_client_cpp_core for live updates from /api/boards/issues/stream
This commit is contained in:
@@ -9,6 +9,7 @@ add_imgui_app(kanban_cpp
|
||||
panel_dod.cpp
|
||||
# Registry functions consumed (see app.md::uses_functions)
|
||||
${CMAKE_SOURCE_DIR}/functions/core/http_request.cpp
|
||||
${CMAKE_SOURCE_DIR}/functions/core/sse_client.cpp
|
||||
${CMAKE_SOURCE_DIR}/functions/viz/kpi_card.cpp
|
||||
${CMAKE_SOURCE_DIR}/functions/viz/sparkline.cpp
|
||||
${CMAKE_SOURCE_DIR}/functions/viz/agent_runs_timeline.cpp
|
||||
|
||||
@@ -10,6 +10,7 @@ icon:
|
||||
accent: "#a855f7"
|
||||
uses_functions:
|
||||
- http_request_cpp_core
|
||||
- sse_client_cpp_core
|
||||
- dod_evidence_panel_cpp_viz
|
||||
- agent_runs_timeline_cpp_viz
|
||||
- kpi_card_cpp_viz
|
||||
|
||||
@@ -4,6 +4,7 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
fn-registry v0.0.0-00010101000000-000000000000
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
|
||||
@@ -163,6 +163,14 @@ func handlePatchBoardCard() http.HandlerFunc {
|
||||
}
|
||||
_ = PatchFrontmatterField(file, "updated", time.Now().UTC().Format("2006-01-02"))
|
||||
cache.invalidate()
|
||||
if globalHub != nil {
|
||||
globalHub.Broadcast(ServerEvent{
|
||||
Board: board,
|
||||
CardID: id,
|
||||
Action: "updated",
|
||||
EventType: "card_changed",
|
||||
})
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"id": id,
|
||||
@@ -243,11 +251,95 @@ func handleLaunchBoardCard() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/boards/{board}/stream (text/event-stream)
|
||||
//
|
||||
// Long-lived SSE connection that emits one event per card change on the
|
||||
// given board. Events:
|
||||
// - card_added {"board","card_id","action":"created"}
|
||||
// - card_changed {"board","card_id","action":"updated"}
|
||||
// - card_removed {"board","card_id","action":"deleted"}
|
||||
// - keepalive ts=<unix>
|
||||
//
|
||||
// Events for OTHER boards are filtered out (one subscription per board).
|
||||
// A keepalive is emitted every 15s to prevent proxy timeouts.
|
||||
func handleBoardStream() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
board := r.PathValue("board")
|
||||
dir, _, _ := dirAndCacheForBoard(board)
|
||||
if dir == "" {
|
||||
notFound(w, "unknown board: "+board)
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if globalHub == nil {
|
||||
http.Error(w, "hub not initialised", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
ch := globalHub.Subscribe()
|
||||
defer globalHub.Unsubscribe(ch)
|
||||
|
||||
// Honor Last-Event-ID is not supported yet (TODO: replay buffer).
|
||||
_ = r.Header.Get("Last-Event-ID")
|
||||
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
ctx := r.Context()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if _, err := fmt.Fprintf(w, "event: keepalive\ndata: ts=%d\n\n", time.Now().Unix()); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
case ev, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ev.Board != board {
|
||||
continue
|
||||
}
|
||||
evType := ev.EventType
|
||||
if evType == "" {
|
||||
evType = "card_changed"
|
||||
}
|
||||
payload, err := json.Marshal(map[string]string{
|
||||
"board": ev.Board,
|
||||
"card_id": ev.CardID,
|
||||
"action": ev.Action,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", evType, payload); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// boardRoutes returns the additional routes for issues/flows boards. Called
|
||||
// from apiRoutes() in handlers.go.
|
||||
func boardRoutes() []infra.Route {
|
||||
return []infra.Route{
|
||||
{Method: "GET", Path: "/api/boards/{board}/cards", Handler: handleListBoardCards()},
|
||||
{Method: "GET", Path: "/api/boards/{board}/stream", Handler: handleBoardStream()},
|
||||
{Method: "PATCH", Path: "/api/boards/{board}/cards/{id}", Handler: handlePatchBoardCard()},
|
||||
{Method: "POST", Path: "/api/boards/{board}/cards/{id}/launch", Handler: handleLaunchBoardCard()},
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ func main() {
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// SSE: hub + fsnotify watcher for dev/issues + dev/flows.
|
||||
globalHub = NewHub()
|
||||
startBoardsWatcher(globalHub)
|
||||
|
||||
mux := infra.HTTPRouter(apiRoutes(db, &featureFlags))
|
||||
mux.HandleFunc("/health", handleHealth(*port))
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ServerEvent is a board-scoped event broadcast to all SSE subscribers
|
||||
// of a given board. It is emitted both by the fsnotify watcher (file
|
||||
// changes on disk under dev/issues or dev/flows) and by handlers that
|
||||
// mutate cards (PATCH /api/boards/{board}/cards/{id}) so the C++ client
|
||||
// updates in real time without polling.
|
||||
type ServerEvent struct {
|
||||
Board string `json:"board"` // "issues" | "flows"
|
||||
CardID string `json:"card_id"` // canonical id, e.g. "0119"
|
||||
Action string `json:"action"` // "created" | "updated" | "deleted"
|
||||
EventType string `json:"-"` // SSE "event:" field. Empty → "card_changed"
|
||||
}
|
||||
|
||||
// Hub fans out ServerEvent messages to N concurrent subscribers. Each
|
||||
// subscriber gets a buffered channel; if the channel is full, the event
|
||||
// is dropped for THAT subscriber (slow consumer must reconnect to get a
|
||||
// fresh snapshot). Hub itself is safe for concurrent use.
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[chan ServerEvent]struct{}
|
||||
}
|
||||
|
||||
// NewHub returns an empty Hub ready to use.
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[chan ServerEvent]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe registers a new subscriber. The returned channel is buffered
|
||||
// (16) so a brief stall on the consumer side doesn't block the producer.
|
||||
func (h *Hub) Subscribe() chan ServerEvent {
|
||||
ch := make(chan ServerEvent, 16)
|
||||
h.mu.Lock()
|
||||
h.clients[ch] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
// Unsubscribe removes a subscriber and closes its channel. Idempotent.
|
||||
func (h *Hub) Unsubscribe(ch chan ServerEvent) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if _, ok := h.clients[ch]; !ok {
|
||||
return
|
||||
}
|
||||
delete(h.clients, ch)
|
||||
close(ch)
|
||||
}
|
||||
|
||||
// Broadcast sends ev to every current subscriber. Non-blocking: if a
|
||||
// subscriber's channel is full the event is dropped for that subscriber.
|
||||
func (h *Hub) Broadcast(ev ServerEvent) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for ch := range h.clients {
|
||||
select {
|
||||
case ch <- ev:
|
||||
default:
|
||||
// slow consumer: drop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count returns the current number of subscribers (test/diagnostic).
|
||||
func (h *Hub) Count() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.clients)
|
||||
}
|
||||
|
||||
// globalHub is initialised in main() and consumed by handlers + watcher.
|
||||
var globalHub *Hub
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHub_BroadcastReachesSubscriber(t *testing.T) {
|
||||
h := NewHub()
|
||||
ch := h.Subscribe()
|
||||
defer h.Unsubscribe(ch)
|
||||
|
||||
want := ServerEvent{Board: "issues", CardID: "0119", Action: "updated"}
|
||||
h.Broadcast(want)
|
||||
|
||||
select {
|
||||
case got := <-ch:
|
||||
if got != want {
|
||||
t.Fatalf("got %+v, want %+v", got, want)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timeout waiting for broadcast")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_UnsubscribeStopsDelivery(t *testing.T) {
|
||||
h := NewHub()
|
||||
ch := h.Subscribe()
|
||||
if got := h.Count(); got != 1 {
|
||||
t.Fatalf("Count() = %d, want 1", got)
|
||||
}
|
||||
h.Unsubscribe(ch)
|
||||
if got := h.Count(); got != 0 {
|
||||
t.Fatalf("Count() after Unsubscribe = %d, want 0", got)
|
||||
}
|
||||
// channel should be closed
|
||||
if _, ok := <-ch; ok {
|
||||
t.Fatalf("expected closed channel after Unsubscribe")
|
||||
}
|
||||
// double-unsubscribe is a no-op
|
||||
h.Unsubscribe(ch)
|
||||
|
||||
// broadcast should not panic and should reach nobody
|
||||
h.Broadcast(ServerEvent{Board: "issues", CardID: "x", Action: "updated"})
|
||||
}
|
||||
|
||||
func TestHub_MultipleSubscribersAllReceive(t *testing.T) {
|
||||
h := NewHub()
|
||||
const n = 5
|
||||
chans := make([]chan ServerEvent, n)
|
||||
for i := range chans {
|
||||
chans[i] = h.Subscribe()
|
||||
}
|
||||
defer func() {
|
||||
for _, ch := range chans {
|
||||
h.Unsubscribe(ch)
|
||||
}
|
||||
}()
|
||||
|
||||
want := ServerEvent{Board: "flows", CardID: "abc", Action: "created", EventType: "card_added"}
|
||||
h.Broadcast(want)
|
||||
|
||||
for i, ch := range chans {
|
||||
select {
|
||||
case got := <-ch:
|
||||
if got != want {
|
||||
t.Fatalf("sub %d: got %+v, want %+v", i, got, want)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("sub %d: timeout", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
func TestWatcher_PathToEvent_IssuesCreate(t *testing.T) {
|
||||
ev := fsnotify.Event{
|
||||
Name: "/home/x/fn_registry/dev/issues/0119-frontmatter-migration.md",
|
||||
Op: fsnotify.Create,
|
||||
}
|
||||
board, id, action := classifyEvent(ev)
|
||||
if board != "issues" || id != "0119" || action != "created" {
|
||||
t.Fatalf("got (%q,%q,%q), want (issues,0119,created)", board, id, action)
|
||||
}
|
||||
if sseEventForAction(action) != "card_added" {
|
||||
t.Fatalf("sseEventForAction(created) != card_added")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_PathToEvent_FlowsRename(t *testing.T) {
|
||||
ev := fsnotify.Event{
|
||||
Name: "/x/dev/flows/0042-deploy-vps.md",
|
||||
Op: fsnotify.Rename,
|
||||
}
|
||||
board, id, action := classifyEvent(ev)
|
||||
if board != "flows" || id != "0042" || action != "updated" {
|
||||
t.Fatalf("got (%q,%q,%q), want (flows,0042,updated)", board, id, action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_PathToEvent_Remove(t *testing.T) {
|
||||
ev := fsnotify.Event{
|
||||
Name: "/x/dev/issues/0050-foo.md",
|
||||
Op: fsnotify.Remove,
|
||||
}
|
||||
board, id, action := classifyEvent(ev)
|
||||
if board != "issues" || id != "0050" || action != "deleted" {
|
||||
t.Fatalf("got (%q,%q,%q), want (issues,0050,deleted)", board, id, action)
|
||||
}
|
||||
if sseEventForAction(action) != "card_removed" {
|
||||
t.Fatalf("sseEventForAction(deleted) != card_removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_PathToEvent_SkippedNames(t *testing.T) {
|
||||
cases := []string{
|
||||
"/x/dev/issues/README.md",
|
||||
"/x/dev/issues/INDEX.md",
|
||||
"/x/dev/issues/AGENT_GUIDE.md",
|
||||
"/x/dev/issues/notes.txt", // not .md
|
||||
"/x/somewhere-else/0001-foo.md", // not under dev/issues|flows
|
||||
}
|
||||
for _, p := range cases {
|
||||
ev := fsnotify.Event{Name: p, Op: fsnotify.Create}
|
||||
if board, _, _ := classifyEvent(ev); board != "" {
|
||||
t.Fatalf("expected ignored, got board=%q for %s", board, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_DetectsWrite(t *testing.T) {
|
||||
// Build a temp tree that *looks like* dev/issues/ so classifyEvent
|
||||
// will accept it (it matches by path substring "/dev/issues/").
|
||||
root := t.TempDir()
|
||||
issuesDirPath := filepath.Join(root, "dev", "issues")
|
||||
if err := os.MkdirAll(issuesDirPath, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
w, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
t.Fatalf("new watcher: %v", err)
|
||||
}
|
||||
defer w.Close()
|
||||
if err := w.Add(issuesDirPath); err != nil {
|
||||
t.Fatalf("watch add: %v", err)
|
||||
}
|
||||
|
||||
hub := NewHub()
|
||||
ch := hub.Subscribe()
|
||||
defer hub.Unsubscribe(ch)
|
||||
|
||||
// Drive events in a goroutine via handleFsEvent so we exercise the
|
||||
// full pipeline (classify -> broadcast).
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
select {
|
||||
case ev, ok := <-w.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
handleFsEvent(hub, ev)
|
||||
case <-w.Errors:
|
||||
case <-time.After(2 * time.Second):
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
cardPath := filepath.Join(issuesDirPath, "0999-test.md")
|
||||
if err := os.WriteFile(cardPath, []byte("---\nstatus: pendiente\n---\n"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case ev := <-ch:
|
||||
if ev.Board != "issues" || ev.CardID != "0999" {
|
||||
t.Fatalf("unexpected event: %+v", ev)
|
||||
}
|
||||
if ev.Action != "created" && ev.Action != "updated" {
|
||||
t.Fatalf("expected created/updated, got action=%q", ev.Action)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timeout waiting for write event")
|
||||
}
|
||||
|
||||
<-done
|
||||
}
|
||||
@@ -94,12 +94,25 @@ fn_http::Response do_post_json(const std::string& url, const std::string& body,
|
||||
} // namespace
|
||||
|
||||
bool health(const ClientConfig& cfg) {
|
||||
auto r = do_get(cfg.base_url + "/health", cfg.timeout_ms);
|
||||
// /health legacy tiene auth middleware → 500. Usar endpoint sync layer
|
||||
// (issue 0119) sin auth como ping.
|
||||
auto r = do_get(cfg.base_url + "/api/boards/issues/cards", cfg.timeout_ms);
|
||||
return r.status >= 200 && r.status < 300;
|
||||
}
|
||||
|
||||
static std::string status_to_column(const std::string& s) {
|
||||
if (s == "pendiente" || s == "pending") return "backlog";
|
||||
if (s == "en-curso" || s == "in-progress") return "doing";
|
||||
if (s == "en-revision" || s == "review") return "review";
|
||||
if (s == "done" || s == "completado") return "done";
|
||||
if (s == "deferred") return "deferred";
|
||||
return "backlog";
|
||||
}
|
||||
|
||||
std::vector<Card> list_cards(const ClientConfig& cfg, std::string& err) {
|
||||
auto r = do_get(cfg.base_url + "/api/cards", cfg.timeout_ms);
|
||||
// Issue 0119 sync layer: cards = issues + flows. Aqui solo issues; flows
|
||||
// viven en su propio tab/panel cuando se anada.
|
||||
auto r = do_get(cfg.base_url + "/api/boards/issues/cards", cfg.timeout_ms);
|
||||
if (r.status == 0) { err = "transport: " + r.error; return {}; }
|
||||
if (r.status >= 400) { err = "http " + std::to_string(r.status); return {}; }
|
||||
std::vector<Card> out;
|
||||
@@ -108,9 +121,9 @@ std::vector<Card> list_cards(const ClientConfig& cfg, std::string& err) {
|
||||
c.id = find_str_field(obj, "id");
|
||||
c.title = find_str_field(obj, "title");
|
||||
c.description = find_str_field(obj, "description");
|
||||
c.column_id = find_str_field(obj, "column_id");
|
||||
c.priority = find_str_field(obj, "priority");
|
||||
c.status = find_str_field(obj, "status");
|
||||
c.column_id = status_to_column(c.status);
|
||||
c.priority = find_str_field(obj, "priority");
|
||||
c.position = find_int_field(obj, "position");
|
||||
c.due_date = find_int_field(obj, "due_date");
|
||||
c.assignee = find_str_field(obj, "assignee");
|
||||
@@ -119,19 +132,15 @@ std::vector<Card> list_cards(const ClientConfig& cfg, std::string& err) {
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<Column> list_columns(const ClientConfig& cfg, std::string& err) {
|
||||
auto r = do_get(cfg.base_url + "/api/columns", cfg.timeout_ms);
|
||||
if (r.status == 0) { err = "transport: " + r.error; return {}; }
|
||||
if (r.status >= 400) { err = "http " + std::to_string(r.status); return {}; }
|
||||
std::vector<Column> out;
|
||||
for (const auto& obj : split_objects(r.body)) {
|
||||
Column c;
|
||||
c.id = find_str_field(obj, "id");
|
||||
c.name = find_str_field(obj, "name");
|
||||
c.order = static_cast<int>(find_int_field(obj, "order"));
|
||||
if (!c.id.empty()) out.push_back(c);
|
||||
}
|
||||
return out;
|
||||
std::vector<Column> list_columns(const ClientConfig& /*cfg*/, std::string& /*err*/) {
|
||||
// Columnas fijas derivadas de taxonomia (issue 0103).
|
||||
return {
|
||||
{"backlog", "Backlog", 0},
|
||||
{"doing", "Doing", 1},
|
||||
{"review", "Review", 2},
|
||||
{"done", "Done", 3},
|
||||
{"deferred", "Deferred", 4},
|
||||
};
|
||||
}
|
||||
|
||||
bool move_card(const ClientConfig& cfg, const std::string& card_id,
|
||||
|
||||
@@ -6,12 +6,16 @@
|
||||
#include "core/panel_menu.h"
|
||||
#include "core/icons_tabler.h"
|
||||
#include "core/logger.h"
|
||||
#include "core/sse_client.h"
|
||||
#include "panels.h"
|
||||
|
||||
#include <imgui.h>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <cstdio>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
static bool g_show_board = true;
|
||||
static bool g_show_calendar = true;
|
||||
@@ -22,6 +26,11 @@ static bool g_show_dod = true;
|
||||
|
||||
static kanban_cpp::AppState g_state;
|
||||
|
||||
// SSE client: receives push notifications from the backend stream so the
|
||||
// board updates without polling. Lifetime tied to main() — stop() before
|
||||
// returning so the worker thread joins cleanly.
|
||||
static fn_sse::Client g_sse_client;
|
||||
|
||||
static void render() {
|
||||
if (g_show_board) kanban_cpp::draw_board (g_state, &g_show_board);
|
||||
if (g_show_calendar) kanban_cpp::draw_calendar (g_state, &g_show_calendar);
|
||||
@@ -66,8 +75,35 @@ int main(int argc, char** argv) {
|
||||
cfg.panels = panels;
|
||||
cfg.panel_count = sizeof(panels) / sizeof(panels[0]);
|
||||
|
||||
// First refresh on startup (best-effort; failure surfaces in the Board).
|
||||
kanban_cpp::refresh_data(g_state);
|
||||
// First refresh on startup en thread separado: no bloquea primer frame
|
||||
// si el backend :8403 esta caido (timeout HTTP ~9s).
|
||||
std::thread([](){ kanban_cpp::refresh_data(g_state); }).detach();
|
||||
|
||||
return fn::run_app(cfg, render);
|
||||
// SSE live updates: arranca tras 500ms para no competir con el primer
|
||||
// refresh inicial. Auto-reconecta con backoff si el endpoint no existe
|
||||
// aun o si el backend cae — NUNCA crashea el frame.
|
||||
std::thread([](){
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
fn_sse::Config sse_cfg;
|
||||
sse_cfg.url = g_state.cfg.base_url + "/api/boards/issues/stream";
|
||||
sse_cfg.auto_reconnect = true;
|
||||
|
||||
g_sse_client.start(sse_cfg,
|
||||
// on_event: cualquier evento dispara un refresh asincrono.
|
||||
[](const fn_sse::Event& /*ev*/) {
|
||||
std::thread([](){ kanban_cpp::refresh_data(g_state); }).detach();
|
||||
},
|
||||
// on_status: actualiza el badge UI bajo mutex.
|
||||
[](const std::string& status) {
|
||||
std::lock_guard<std::mutex> lock(g_state.mu);
|
||||
g_state.sse_status = status;
|
||||
});
|
||||
}).detach();
|
||||
|
||||
int rc = fn::run_app(cfg, render);
|
||||
|
||||
// Kill SSE worker antes de salir — orden importa para evitar dangling
|
||||
// thread storage cuando se destruyen los globales.
|
||||
g_sse_client.stop();
|
||||
return rc;
|
||||
}
|
||||
|
||||
+67
-21
@@ -4,17 +4,27 @@
|
||||
|
||||
#include <imgui.h>
|
||||
#include <ctime>
|
||||
#include <thread>
|
||||
#include <mutex>
|
||||
|
||||
namespace kanban_cpp {
|
||||
|
||||
void refresh_data(AppState& s) {
|
||||
std::string err;
|
||||
s.cards = list_cards(s.cfg, err);
|
||||
if (!err.empty()) s.last_refresh_error = "cards: " + err;
|
||||
s.columns = list_columns(s.cfg, err);
|
||||
if (!err.empty()) s.last_refresh_error += " columns: " + err;
|
||||
s.backend_ok = health(s.cfg);
|
||||
s.last_refresh_ts = std::time(nullptr);
|
||||
auto cards = list_cards(s.cfg, err);
|
||||
std::string err_cards = err; err.clear();
|
||||
auto columns = list_columns(s.cfg, err);
|
||||
std::string err_cols = err;
|
||||
bool ok = health(s.cfg);
|
||||
int64_t ts = std::time(nullptr);
|
||||
std::lock_guard<std::mutex> lock(s.mu);
|
||||
s.cards = std::move(cards);
|
||||
s.columns = std::move(columns);
|
||||
s.last_refresh_error.clear();
|
||||
if (!err_cards.empty()) s.last_refresh_error = "cards: " + err_cards;
|
||||
if (!err_cols.empty()) s.last_refresh_error += " columns: " + err_cols;
|
||||
s.backend_ok = ok;
|
||||
s.last_refresh_ts = ts;
|
||||
}
|
||||
|
||||
void draw_board(AppState& s, bool* p_open) {
|
||||
@@ -23,22 +33,53 @@ void draw_board(AppState& s, bool* p_open) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Toolbar
|
||||
if (ImGui::Button(TI_REFRESH " Refresh")) refresh_data(s);
|
||||
// Snapshot bajo lock — refresh corre en thread separado.
|
||||
std::vector<Card> cards_snap;
|
||||
std::vector<Column> cols_snap;
|
||||
bool backend_ok_snap;
|
||||
std::string err_snap;
|
||||
std::string sse_snap;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(s.mu);
|
||||
cards_snap = s.cards;
|
||||
cols_snap = s.columns;
|
||||
backend_ok_snap = s.backend_ok;
|
||||
err_snap = s.last_refresh_error;
|
||||
sse_snap = s.sse_status;
|
||||
}
|
||||
|
||||
// Toolbar — refresh corre en thread separado (no bloquea frame).
|
||||
if (ImGui::Button(TI_REFRESH " Refresh")) {
|
||||
std::thread([&s](){ refresh_data(s); }).detach();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (s.backend_ok) {
|
||||
if (backend_ok_snap) {
|
||||
ImGui::TextColored(ImVec4(0.4f, 0.85f, 0.4f, 1.0f), TI_CHECK " backend :8403");
|
||||
} else {
|
||||
ImGui::TextColored(ImVec4(0.85f, 0.4f, 0.4f, 1.0f), TI_ALERT_TRIANGLE " backend offline (:8403)");
|
||||
}
|
||||
if (!s.last_refresh_error.empty()) {
|
||||
|
||||
// SSE live badge — refleja el estado del stream push del backend.
|
||||
ImGui::SameLine();
|
||||
if (sse_snap == "connected") {
|
||||
ImGui::TextColored(ImVec4(0.4f, 0.85f, 0.4f, 1.0f), TI_BROADCAST " live");
|
||||
} else if (sse_snap == "connecting") {
|
||||
ImGui::TextColored(ImVec4(0.85f, 0.85f, 0.4f, 1.0f), TI_LOADER " connecting");
|
||||
} else if (sse_snap == "disconnected") {
|
||||
ImGui::TextColored(ImVec4(0.85f, 0.4f, 0.4f, 1.0f), TI_PLUG_CONNECTED_X " disconnected");
|
||||
} else {
|
||||
// "error: <msg>" o cualquier otro string
|
||||
ImGui::TextColored(ImVec4(0.85f, 0.4f, 0.4f, 1.0f), TI_PLUG_CONNECTED_X " %s", sse_snap.c_str());
|
||||
}
|
||||
|
||||
if (!err_snap.empty()) {
|
||||
ImGui::SameLine();
|
||||
ImGui::TextColored(ImVec4(0.85f, 0.6f, 0.2f, 1.0f), "%s", s.last_refresh_error.c_str());
|
||||
ImGui::TextColored(ImVec4(0.85f, 0.6f, 0.2f, 1.0f), "%s", err_snap.c_str());
|
||||
}
|
||||
ImGui::Separator();
|
||||
|
||||
// Empty state
|
||||
if (s.columns.empty()) {
|
||||
if (cols_snap.empty()) {
|
||||
ImGui::TextDisabled("No columns yet. Pulsa Refresh o lanza el backend en :8403.");
|
||||
ImGui::End();
|
||||
return;
|
||||
@@ -48,19 +89,19 @@ void draw_board(AppState& s, bool* p_open) {
|
||||
const float col_w = 280.0f;
|
||||
if (ImGui::BeginChild("##board_scroll", ImVec2(0, 0), false,
|
||||
ImGuiWindowFlags_HorizontalScrollbar)) {
|
||||
for (size_t ci = 0; ci < s.columns.size(); ++ci) {
|
||||
const auto& col = s.columns[ci];
|
||||
for (size_t ci = 0; ci < cols_snap.size(); ++ci) {
|
||||
const auto& col = cols_snap[ci];
|
||||
ImGui::SameLine();
|
||||
ImGui::BeginChild((std::string("##col_") + col.id).c_str(),
|
||||
ImVec2(col_w, 0), true);
|
||||
ImGui::TextUnformatted(col.name.c_str());
|
||||
ImGui::SameLine();
|
||||
int count = 0;
|
||||
for (const auto& c : s.cards) if (c.column_id == col.id) ++count;
|
||||
for (const auto& c : cards_snap) if (c.column_id == col.id) ++count;
|
||||
ImGui::TextDisabled("(%d)", count);
|
||||
ImGui::Separator();
|
||||
|
||||
for (const auto& card : s.cards) {
|
||||
for (const auto& card : cards_snap) {
|
||||
if (card.column_id != col.id) continue;
|
||||
ImGui::PushID(card.id.c_str());
|
||||
ImGui::BeginChild("##card", ImVec2(0, 70), true,
|
||||
@@ -83,13 +124,18 @@ void draw_board(AppState& s, bool* p_open) {
|
||||
}
|
||||
if (ImGui::BeginPopup("##card_ctx")) {
|
||||
ImGui::TextDisabled("Move to:");
|
||||
for (const auto& tgt : s.columns) {
|
||||
for (const auto& tgt : cols_snap) {
|
||||
if (tgt.id == card.column_id) continue;
|
||||
if (ImGui::MenuItem(tgt.name.c_str())) {
|
||||
std::string err;
|
||||
if (!move_card(s.cfg, card.id, tgt.id, err))
|
||||
s.last_refresh_error = "move: " + err;
|
||||
else refresh_data(s);
|
||||
std::thread([&s, card_id=card.id, tgt_id=tgt.id](){
|
||||
std::string err;
|
||||
if (!move_card(s.cfg, card_id, tgt_id, err)) {
|
||||
std::lock_guard<std::mutex> lock(s.mu);
|
||||
s.last_refresh_error = "move: " + err;
|
||||
return;
|
||||
}
|
||||
refresh_data(s);
|
||||
}).detach();
|
||||
}
|
||||
}
|
||||
ImGui::Separator();
|
||||
|
||||
+8
-1
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <imgui.h>
|
||||
#include <ctime>
|
||||
#include <mutex>
|
||||
|
||||
namespace kanban_cpp {
|
||||
|
||||
@@ -32,6 +33,12 @@ void draw_calendar(AppState& s, bool* p_open) {
|
||||
ImGui::TextDisabled("(MVP estatico — TODO: navegacion + filtros)");
|
||||
ImGui::Separator();
|
||||
|
||||
std::vector<Card> cards_snap;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(s.mu);
|
||||
cards_snap = s.cards;
|
||||
}
|
||||
|
||||
// First day of current month + days in month
|
||||
std::tm tm_first = tm_now;
|
||||
tm_first.tm_mday = 1;
|
||||
@@ -68,7 +75,7 @@ void draw_calendar(AppState& s, bool* p_open) {
|
||||
ImGui::Text("%d", day);
|
||||
// Count cards whose due_date falls in this day.
|
||||
int hits = 0;
|
||||
for (const auto& card : s.cards) {
|
||||
for (const auto& card : cards_snap) {
|
||||
if (card.due_date == 0) continue;
|
||||
std::time_t cd = (std::time_t)card.due_date;
|
||||
std::tm tmc;
|
||||
|
||||
+9
-2
@@ -6,6 +6,7 @@
|
||||
|
||||
#include <imgui.h>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
namespace kanban_cpp {
|
||||
@@ -30,11 +31,17 @@ void draw_dashboard(AppState& s, bool* p_open) {
|
||||
ImGui::TextDisabled("KPIs sinteticos (TODO: backend /api/stats endpoint)");
|
||||
ImGui::Separator();
|
||||
|
||||
std::vector<Card> cards_snap;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(s.mu);
|
||||
cards_snap = s.cards;
|
||||
}
|
||||
|
||||
// Snapshot counts
|
||||
int total = static_cast<int>(s.cards.size());
|
||||
int total = static_cast<int>(cards_snap.size());
|
||||
std::map<std::string, int> by_priority;
|
||||
std::map<std::string, int> by_status;
|
||||
for (const auto& c : s.cards) {
|
||||
for (const auto& c : cards_snap) {
|
||||
if (!c.priority.empty()) by_priority[c.priority]++;
|
||||
if (!c.status.empty()) by_status[c.status]++;
|
||||
}
|
||||
|
||||
@@ -5,10 +5,15 @@
|
||||
#pragma once
|
||||
|
||||
#include "data.h"
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
namespace kanban_cpp {
|
||||
|
||||
// Shared app state passed to every panel. Owned by main.cpp.
|
||||
// `mu` guards cards/columns/backend_ok/last_refresh_*/sse_status — refresh_data
|
||||
// corre en thread aparte y el SSE callback tambien lo hace, ambos escriben a
|
||||
// traves del mismo mutex.
|
||||
struct AppState {
|
||||
ClientConfig cfg;
|
||||
std::vector<Card> cards;
|
||||
@@ -16,6 +21,9 @@ struct AppState {
|
||||
std::string last_refresh_error;
|
||||
int64_t last_refresh_ts = 0;
|
||||
bool backend_ok = false;
|
||||
// SSE live status: "connecting" | "connected" | "disconnected" | "error: <msg>"
|
||||
std::string sse_status = "connecting";
|
||||
std::mutex mu;
|
||||
};
|
||||
|
||||
void draw_board (AppState& s, bool* p_open);
|
||||
|
||||
Reference in New Issue
Block a user