98bf278472
- backend/handlers.go - data.cpp - data.h - main.cpp - panel_board.cpp - panel_filters.cpp - appicon.ico - backend/kanban_cpp_backend.exe Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
418 lines
12 KiB
Go
418 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"fn-registry/functions/infra"
|
|
)
|
|
|
|
const agentRunnerBase = "http://127.0.0.1:8486"
|
|
|
|
func (s *Server) registerRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("/api/health", s.handleHealth)
|
|
mux.HandleFunc("/api/issues", s.handleIssues)
|
|
mux.HandleFunc("/api/issues/", s.handleIssueByID)
|
|
mux.HandleFunc("/api/flows", s.handleFlows)
|
|
mux.HandleFunc("/api/flows/", s.handleFlowByID)
|
|
mux.HandleFunc("/api/meta", s.handleMeta)
|
|
mux.HandleFunc("/api/sse", s.handleSSE)
|
|
mux.HandleFunc("/api/agent_status", s.handleAgentStatus)
|
|
mux.HandleFunc("/api/agent_launch", s.handleAgentLaunch)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeErr(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
var ni, nf int
|
|
s.db.QueryRow(`SELECT COUNT(*) FROM issues`).Scan(&ni)
|
|
s.db.QueryRow(`SELECT COUNT(*) FROM flows`).Scan(&nf)
|
|
writeJSON(w, 200, map[string]any{
|
|
"ok": true,
|
|
"version": "0.1.0",
|
|
"count_issues": ni,
|
|
"count_flows": nf,
|
|
"issues_dir": s.issuesDir,
|
|
"flows_dir": s.flowsDir,
|
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleIssues(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "GET" {
|
|
writeErr(w, 405, "method not allowed")
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
where := []string{"1=1"}
|
|
args := []any{}
|
|
if v := q.Get("status"); v != "" {
|
|
where = append(where, "status=?")
|
|
args = append(args, v)
|
|
}
|
|
if v := q.Get("priority"); v != "" {
|
|
where = append(where, "priority=?")
|
|
args = append(args, v)
|
|
}
|
|
if v := q.Get("scope"); v != "" {
|
|
where = append(where, "scope=?")
|
|
args = append(args, v)
|
|
}
|
|
if v := q.Get("domain"); v != "" {
|
|
where = append(where, "domain_json LIKE ?")
|
|
args = append(args, "%\""+v+"\"%")
|
|
}
|
|
if v := q.Get("tag"); v != "" {
|
|
where = append(where, "tags_json LIKE ?")
|
|
args = append(args, "%\""+v+"\"%")
|
|
}
|
|
if v := q.Get("completed"); v != "" {
|
|
if v == "true" || v == "1" {
|
|
where = append(where, "completed=1")
|
|
} else {
|
|
where = append(where, "completed=0")
|
|
}
|
|
}
|
|
sql := "SELECT id,title,status,type,scope,priority,domain_json,tags_json,depends_json,blocks_json,related_json,flow_id,file_path,completed,created_at,updated_at FROM issues WHERE " + strings.Join(where, " AND ") + " ORDER BY id ASC"
|
|
rows, err := s.db.Query(sql, args...)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
out := []map[string]any{}
|
|
for rows.Next() {
|
|
var (
|
|
id, title, status, typ, scope, priority string
|
|
domJ, tagJ, depJ, blkJ, relJ, flow, path string
|
|
completedInt int
|
|
createdAt, updatedAt string
|
|
)
|
|
if err := rows.Scan(&id, &title, &status, &typ, &scope, &priority, &domJ, &tagJ, &depJ, &blkJ, &relJ, &flow, &path, &completedInt, &createdAt, &updatedAt); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
out = append(out, map[string]any{
|
|
"id": id,
|
|
"title": title,
|
|
"status": status,
|
|
"type": typ,
|
|
"scope": scope,
|
|
"priority": priority,
|
|
"domain": parseJSONArr(domJ),
|
|
"tags": parseJSONArr(tagJ),
|
|
"depends": parseJSONArr(depJ),
|
|
"blocks": parseJSONArr(blkJ),
|
|
"related": parseJSONArr(relJ),
|
|
"flow": flow,
|
|
"file_path": path,
|
|
"completed": completedInt == 1,
|
|
"created": createdAt,
|
|
"updated": updatedAt,
|
|
})
|
|
}
|
|
writeJSON(w, 200, out)
|
|
}
|
|
|
|
func (s *Server) handleIssueByID(w http.ResponseWriter, r *http.Request) {
|
|
id := strings.TrimPrefix(r.URL.Path, "/api/issues/")
|
|
if id == "" {
|
|
writeErr(w, 400, "missing id")
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case "GET":
|
|
s.getIssue(w, id)
|
|
case "PATCH":
|
|
s.patchIssue(w, r, id)
|
|
default:
|
|
writeErr(w, 405, "method not allowed")
|
|
}
|
|
}
|
|
|
|
func (s *Server) getIssue(w http.ResponseWriter, id string) {
|
|
row := s.db.QueryRow(`SELECT id,title,status,type,scope,priority,domain_json,tags_json,depends_json,blocks_json,related_json,flow_id,body,file_path,completed,created_at,updated_at FROM issues WHERE id=?`, id)
|
|
var (
|
|
iid, title, status, typ, scope, priority string
|
|
domJ, tagJ, depJ, blkJ, relJ, flow, body, path string
|
|
completedInt int
|
|
createdAt, updatedAt string
|
|
)
|
|
if err := row.Scan(&iid, &title, &status, &typ, &scope, &priority, &domJ, &tagJ, &depJ, &blkJ, &relJ, &flow, &body, &path, &completedInt, &createdAt, &updatedAt); err != nil {
|
|
writeErr(w, 404, "not found")
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{
|
|
"id": iid, "title": title, "status": status, "type": typ, "scope": scope, "priority": priority,
|
|
"domain": parseJSONArr(domJ), "tags": parseJSONArr(tagJ),
|
|
"depends": parseJSONArr(depJ), "blocks": parseJSONArr(blkJ), "related": parseJSONArr(relJ),
|
|
"flow": flow, "body": body, "file_path": path, "completed": completedInt == 1,
|
|
"created": createdAt, "updated": updatedAt,
|
|
})
|
|
}
|
|
|
|
func (s *Server) patchIssue(w http.ResponseWriter, r *http.Request, id string) {
|
|
var patch map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
|
writeErr(w, 400, "bad json")
|
|
return
|
|
}
|
|
var filePath string
|
|
if err := s.db.QueryRow(`SELECT file_path FROM issues WHERE id=?`, id).Scan(&filePath); err != nil {
|
|
writeErr(w, 404, "not found")
|
|
return
|
|
}
|
|
iss, body, err := infra.ParseIssueMd(filePath)
|
|
if err != nil {
|
|
writeErr(w, 500, fmt.Sprintf("parse: %v", err))
|
|
return
|
|
}
|
|
applyPatch(&iss, patch)
|
|
iss.Updated = time.Now().UTC().Format("2006-01-02")
|
|
if err := infra.WriteIssueMd(filePath, iss, body); err != nil {
|
|
writeErr(w, 500, fmt.Sprintf("write: %v", err))
|
|
return
|
|
}
|
|
info, _ := os.Stat(filePath)
|
|
if info != nil {
|
|
iss.MtimeNs = info.ModTime().UnixNano()
|
|
}
|
|
iss.FilePath = filePath
|
|
iss.Completed = strings.Contains(filePath, "/completed/")
|
|
if err := s.upsertIssueRow(iss); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
s.hub.broadcast(SSEEvent{Type: "updated", ID: id, Path: filePath})
|
|
s.getIssue(w, id)
|
|
}
|
|
|
|
func applyPatch(iss *infra.Issue, patch map[string]any) {
|
|
if v, ok := patch["status"].(string); ok {
|
|
iss.Status = v
|
|
}
|
|
if v, ok := patch["priority"].(string); ok {
|
|
iss.Priority = v
|
|
}
|
|
if v, ok := patch["scope"].(string); ok {
|
|
iss.Scope = v
|
|
}
|
|
if v, ok := patch["title"].(string); ok {
|
|
iss.Title = v
|
|
}
|
|
if v, ok := patch["type"].(string); ok {
|
|
iss.Type = v
|
|
}
|
|
if v, ok := patch["flow"].(string); ok {
|
|
iss.Flow = v
|
|
}
|
|
for _, k := range []string{"domain", "tags", "depends", "blocks", "related"} {
|
|
if raw, ok := patch[k]; ok {
|
|
arr := []string{}
|
|
if xs, ok := raw.([]any); ok {
|
|
for _, x := range xs {
|
|
if s, ok := x.(string); ok {
|
|
arr = append(arr, s)
|
|
}
|
|
}
|
|
}
|
|
switch k {
|
|
case "domain":
|
|
iss.Domain = arr
|
|
case "tags":
|
|
iss.Tags = arr
|
|
case "depends":
|
|
iss.Depends = arr
|
|
case "blocks":
|
|
iss.Blocks = arr
|
|
case "related":
|
|
iss.Related = arr
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleFlows(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := s.db.Query(`SELECT id,title,status,kind,tags_json,file_path FROM flows ORDER BY id ASC`)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
out := []map[string]any{}
|
|
for rows.Next() {
|
|
var id, title, status, kind, tagJ, path string
|
|
rows.Scan(&id, &title, &status, &kind, &tagJ, &path)
|
|
out = append(out, map[string]any{
|
|
"id": id, "title": title, "status": status, "kind": kind,
|
|
"tags": parseJSONArr(tagJ), "file_path": path,
|
|
})
|
|
}
|
|
writeJSON(w, 200, out)
|
|
}
|
|
|
|
func (s *Server) handleFlowByID(w http.ResponseWriter, r *http.Request) {
|
|
id := strings.TrimPrefix(r.URL.Path, "/api/flows/")
|
|
if id == "" {
|
|
writeErr(w, 400, "missing id")
|
|
return
|
|
}
|
|
row := s.db.QueryRow(`SELECT id,title,status,kind,tags_json,body,file_path FROM flows WHERE id=?`, id)
|
|
var iid, title, status, kind, tagJ, body, path string
|
|
if err := row.Scan(&iid, &title, &status, &kind, &tagJ, &body, &path); err != nil {
|
|
writeErr(w, 404, "not found")
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{
|
|
"id": iid, "title": title, "status": status, "kind": kind,
|
|
"tags": parseJSONArr(tagJ), "body": body, "file_path": path,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, 200, map[string]any{
|
|
"statuses": []string{"ideas", "pendiente", "in-progress", "bloqueado", "completado", "deferred", "descartado"},
|
|
"board_columns": []string{"ideas", "pendiente", "in-progress", "completado"},
|
|
"priorities": []string{"critica", "alta", "media", "baja"},
|
|
"scopes": []string{"registry-only", "app-scoped", "multi-app", "cross-stack"},
|
|
"types": []string{"feature", "bugfix", "refactor", "docs", "chore", "research", "infra", "app", "spike", "epic", "planning"},
|
|
})
|
|
}
|
|
|
|
// GET /api/agent_status — proxies agent_runner_api running runs, returns map issue_id -> run_id
|
|
func (s *Server) handleAgentStatus(w http.ResponseWriter, r *http.Request) {
|
|
resp, err := http.Get(agentRunnerBase + "/api/runs?status=running")
|
|
if err != nil {
|
|
writeJSON(w, 200, map[string]any{"available": false, "active": map[string]string{}})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
var runs []map[string]any
|
|
if err := json.Unmarshal(body, &runs); err != nil {
|
|
writeJSON(w, 200, map[string]any{"available": false, "active": map[string]string{}})
|
|
return
|
|
}
|
|
active := map[string]string{}
|
|
for _, run := range runs {
|
|
issueID, _ := run["issue_id"].(string)
|
|
runID, _ := run["id"].(string)
|
|
if issueID != "" && runID != "" {
|
|
active[issueID] = runID
|
|
}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"available": true, "active": active})
|
|
}
|
|
|
|
// POST /api/agent_launch {"issue_id":"NNNN"} — forwards to agent_runner_api
|
|
func (s *Server) handleAgentLaunch(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
writeErr(w, 405, "method not allowed")
|
|
return
|
|
}
|
|
var req struct {
|
|
IssueID string `json:"issue_id"`
|
|
Mode string `json:"mode"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, 400, "bad json")
|
|
return
|
|
}
|
|
if req.IssueID == "" {
|
|
writeErr(w, 400, "issue_id required")
|
|
return
|
|
}
|
|
if req.Mode == "" {
|
|
req.Mode = "fix-issue"
|
|
}
|
|
payload, _ := json.Marshal(map[string]string{
|
|
"issue_id": req.IssueID,
|
|
"mode": req.Mode,
|
|
"kanban_app": "kanban_cpp",
|
|
})
|
|
resp, err := http.Post(agentRunnerBase+"/api/runs", "application/json", bytes.NewReader(payload))
|
|
if err != nil {
|
|
writeErr(w, 502, fmt.Sprintf("agent_runner_api unreachable: %v", err))
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 400 {
|
|
writeErr(w, resp.StatusCode, string(body))
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(resp.StatusCode)
|
|
w.Write(body)
|
|
}
|
|
|
|
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
writeErr(w, 500, "streaming unsupported")
|
|
return
|
|
}
|
|
ch := s.hub.subscribe()
|
|
defer s.hub.unsubscribe(ch)
|
|
|
|
pingTick := time.NewTicker(15 * time.Second)
|
|
defer pingTick.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
case ev := <-ch:
|
|
b, _ := json.Marshal(ev)
|
|
fmt.Fprintf(w, "data: %s\n\n", b)
|
|
flusher.Flush()
|
|
case <-pingTick.C:
|
|
fmt.Fprintf(w, ": ping\n\n")
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseJSONArr(s string) []string {
|
|
if s == "" {
|
|
return []string{}
|
|
}
|
|
var arr []string
|
|
if err := json.Unmarshal([]byte(s), &arr); err != nil {
|
|
return []string{}
|
|
}
|
|
return arr
|
|
}
|
|
|
|
func withMiddleware(h http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization")
|
|
if r.Method == "OPTIONS" {
|
|
w.WriteHeader(204)
|
|
return
|
|
}
|
|
start := time.Now()
|
|
h.ServeHTTP(w, r)
|
|
fmt.Printf("[%s] %s %s %s\n", time.Now().Format("15:04:05"), r.Method, r.URL.Path, time.Since(start))
|
|
})
|
|
}
|