package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" "fn-registry/functions/infra" ) // agentRunnerEndpoint returns the agent_runner_api base URL. // Override with KANBAN_AGENT_RUNNER_API env var. func agentRunnerEndpoint() string { if v := strings.TrimSpace(os.Getenv("KANBAN_AGENT_RUNNER_API")); v != "" { return v } return "http://127.0.0.1:8486" } // allowedStatusForBoard returns the canonical statuses a PATCH can set on a // given board. Anything else returns 400 (taxonomy issue 0103). func allowedStatusForBoard(board string) []string { switch board { case "issues": return []string{"pendiente", "en-curso", "en-revisión", "en-revision", "done", "deferred"} case "flows": return []string{"pending", "running", "done", "deferred"} default: return nil } } func isAllowedStatus(board, status string) bool { allowed := allowedStatusForBoard(board) for _, a := range allowed { if a == status { return true } } return false } // dirAndCacheForBoard returns the filesystem directory + cache for a board // name. Unknown boards yield ("", nil). func dirAndCacheForBoard(board string) (string, *cardsCache, func(string) string) { switch board { case "issues": return issuesDir(), issuesCache, mapIssueStatusToColumn case "flows": return flowsDir(), flowsCache, mapFlowStatusToColumn default: return "", nil, nil } } // findCardFile locates the .md file in dir whose leading numeric id matches // the given card id. Returns "" if not found. func findCardFile(dir, id string) (string, error) { id = strings.TrimSpace(id) if id == "" { return "", nil } entries, err := os.ReadDir(dir) if err != nil { return "", err } for _, e := range entries { if e.IsDir() { continue } name := e.Name() if !strings.HasSuffix(strings.ToLower(name), ".md") { continue } if isSkippedMarkdown(name) { continue } if deriveIDFromFilename(name) == id { return filepath.Join(dir, name), nil } } return "", nil } // GET /api/boards/{board}/cards func handleListBoardCards() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { board := r.PathValue("board") dir, cache, _ := dirAndCacheForBoard(board) if dir == "" { notFound(w, "unknown board: "+board) return } if cached, ok := cache.get(); ok { infra.HTTPJSONResponse(w, http.StatusOK, cached) return } var ( cards []IssueCard err error ) switch board { case "issues": cards, err = loadIssueCards(dir) case "flows": cards, err = loadFlowCards(dir) } if err != nil { serverError(w, err) return } cache.set(cards) infra.HTTPJSONResponse(w, http.StatusOK, cards) } } // PATCH /api/boards/{board}/cards/{id} body: { status: "..." } func handlePatchBoardCard() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { board := r.PathValue("board") id := r.PathValue("id") dir, cache, _ := dirAndCacheForBoard(board) if dir == "" { notFound(w, "unknown board: "+board) return } var body struct { Status string `json:"status"` } if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { badRequest(w, err.Error()) return } status := strings.TrimSpace(body.Status) if status == "" { badRequest(w, "status required") return } if !isAllowedStatus(board, status) { badRequest(w, fmt.Sprintf("invalid status for board %q: %q (allowed: %s)", board, status, strings.Join(allowedStatusForBoard(board), ", "))) return } file, err := findCardFile(dir, id) if err != nil { serverError(w, err) return } if file == "" { notFound(w, fmt.Sprintf("card %q not found on board %q", id, board)) return } // Patch status; also bump updated to today (YYYY-MM-DD). if err := PatchFrontmatterField(file, "status", status); err != nil { serverError(w, err) return } _ = 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, "board": board, "status": status, "file": file, }) } } // POST /api/boards/{board}/cards/{id}/launch // Proxies to agent_runner_api at /api/runs with payload including the issue id // and the DoD items pulled from the .md frontmatter. If the runner is // unreachable, returns 502 with a suggestion. func handleLaunchBoardCard() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { board := r.PathValue("board") id := r.PathValue("id") dir, _, statusMapper := dirAndCacheForBoard(board) if dir == "" { notFound(w, "unknown board: "+board) return } file, err := findCardFile(dir, id) if err != nil { serverError(w, err) return } if file == "" { notFound(w, fmt.Sprintf("card %q not found on board %q", id, board)) return } card, err := parseCardFile(file, statusMapper) if err != nil { serverError(w, err) return } // Drain incoming body (optional overrides from client). We do not // forward it as-is to avoid trust issues; we build a clean payload. _, _ = io.Copy(io.Discard, r.Body) payload := map[string]any{ "board": board, "issue_id": card.ExternalID, "title": card.Title, "priority": card.Priority, "type": card.Type, "flow_id": card.FlowID, "dod_items": card.DoDItems, "file_path": card.FilePath, "launched_at": time.Now().UTC().Format(time.RFC3339), } buf, _ := json.Marshal(payload) url := strings.TrimRight(agentRunnerEndpoint(), "/") + "/api/runs" client := &http.Client{Timeout: 5 * time.Second} req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, url, bytes.NewReader(buf)) if err != nil { serverError(w, err) return } req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { infra.HTTPErrorResponse(w, infra.HTTPError{ Status: http.StatusBadGateway, Code: "agent_runner_unreachable", Message: fmt.Sprintf("could not reach agent_runner_api at %s: %v (suggestion: start agent_runner_api service)", url, err), }) return } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) // Forward status + body verbatim so the UI can show backend errors. w.Header().Set("Content-Type", "application/json") w.WriteHeader(resp.StatusCode) _, _ = w.Write(body) } } // 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= // // 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()}, } }