From 264c5939f3ce0b168a499b9172801f2a2b60f19e Mon Sep 17 00:00:00 2001 From: agent Date: Mon, 18 May 2026 19:48:46 +0200 Subject: [PATCH] =?UTF-8?q?refactor(backend):=20trim=20kanban=5Fweb=20bloa?= =?UTF-8?q?t=20(auth/chat/stickers/mcp)=20=E2=80=94=20keep=20sync=20layer?= =?UTF-8?q?=20+=20cards=20core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/auth.go | 156 ---------- backend/chat.go | 269 ----------------- backend/chat_log.go | 86 ------ backend/chat_ws_test.go | 296 ------------------- backend/go.mod | 6 +- backend/handlers.go | 146 +--------- backend/internal_tool.go | 60 ---- backend/main.go | 124 +------- backend/mcp.go | 302 -------------------- backend/metrics.go | 603 --------------------------------------- backend/stickers_test.go | 94 ------ backend/tools.go | 355 ----------------------- backend/tools_test.go | 399 -------------------------- backend/users.go | 129 --------- 14 files changed, 33 insertions(+), 2992 deletions(-) delete mode 100644 backend/auth.go delete mode 100644 backend/chat.go delete mode 100644 backend/chat_log.go delete mode 100644 backend/chat_ws_test.go delete mode 100644 backend/internal_tool.go delete mode 100644 backend/mcp.go delete mode 100644 backend/metrics.go delete mode 100644 backend/stickers_test.go delete mode 100644 backend/tools.go delete mode 100644 backend/tools_test.go delete mode 100644 backend/users.go diff --git a/backend/auth.go b/backend/auth.go deleted file mode 100644 index 88704c6..0000000 --- a/backend/auth.go +++ /dev/null @@ -1,156 +0,0 @@ -package main - -import ( - "errors" - "net/http" - "time" - - "fn-registry/functions/infra" -) - -const ( - cookieName = "kanban_session" - sessionTTL = 7 * 24 * time.Hour -) - -type ctxKey string - -const userCtxKey ctxKey = "kanban_user_id" - -func setSessionCookie(w http.ResponseWriter, token string, expiresAt int64) { - infra.SessionCookieSet(w, cookieName, token, expiresAt) -} - -func clearSessionCookie(w http.ResponseWriter) { - infra.SessionCookieClear(w, cookieName) -} - -func tokenFromRequest(r *http.Request) string { - return infra.SessionTokenExtract(r, cookieName) -} - -// POST /api/auth/register {username, password, display_name?} -func handleRegister(db *DB, flags *FeatureFlags) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if !flags.Enabled("registration-enabled") { - infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusForbidden, Code: "registration_disabled", Message: "user registration is disabled on this instance"}) - return - } - var body struct { - Username string `json:"username"` - Password string `json:"password"` - DisplayName string `json:"display_name"` - } - if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { - badRequest(w, err.Error()) - return - } - u, err := db.CreateUser(body.Username, body.Password, body.DisplayName) - if err != nil { - if errors.Is(err, errUserAlreadyExists) { - infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusConflict, Code: "user_exists", Message: err.Error()}) - return - } - badRequest(w, err.Error()) - return - } - infra.HTTPJSONResponse(w, http.StatusCreated, u) - } -} - -// POST /api/auth/login {username, password} -func handleLogin(db *DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - var body struct { - Username string `json:"username"` - Password string `json:"password"` - } - if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { - badRequest(w, err.Error()) - return - } - u, err := db.Authenticate(body.Username, body.Password) - if err != nil { - infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "invalid_credentials", Message: "invalid username or password"}) - return - } - sess, err := infra.SessionCreate(db.conn, u.ID, sessionTTL, map[string]any{"username": u.Username}) - if err != nil { - serverError(w, err) - return - } - setSessionCookie(w, sess.Token, sess.ExpiresAt) - infra.HTTPJSONResponse(w, http.StatusOK, u) - } -} - -// POST /api/auth/logout -func handleLogout(db *DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - token := tokenFromRequest(r) - if token != "" { - _ = db.DeleteSessionByToken(token) - } - clearSessionCookie(w) - w.WriteHeader(http.StatusNoContent) - } -} - -// GET /api/me -func handleMe(db *DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - uid, ok := infra.UserIDFromContext(r.Context(), userCtxKey) - if !ok { - infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "no session"}) - return - } - u, err := db.GetUserByID(uid) - if err != nil { - serverError(w, err) - return - } - infra.HTTPJSONResponse(w, http.StatusOK, u) - } -} - -// PATCH /api/me { color? } -func handlePatchMe(db *DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - uid, ok := infra.UserIDFromContext(r.Context(), userCtxKey) - if !ok { - infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "no session"}) - return - } - var body struct { - Color *string `json:"color"` - } - if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { - badRequest(w, err.Error()) - return - } - if body.Color != nil { - if err := db.UpdateUserColor(uid, *body.Color); err != nil { - serverError(w, err) - return - } - } - u, err := db.GetUserByID(uid) - if err != nil { - serverError(w, err) - return - } - infra.HTTPJSONResponse(w, http.StatusOK, u) - } -} - -// GET /api/users -func handleListUsers(db *DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - users, err := db.ListUsers() - if err != nil { - serverError(w, err) - return - } - infra.HTTPJSONResponse(w, http.StatusOK, users) - } -} diff --git a/backend/chat.go b/backend/chat.go deleted file mode 100644 index cecafb1..0000000 --- a/backend/chat.go +++ /dev/null @@ -1,269 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "os" - "path/filepath" - "strings" - "time" - - "fn-registry/functions/core" - "fn-registry/functions/infra" - "nhooyr.io/websocket" -) - -const chatSystemPrompt = `Asistente del tablero kanban. Modifica el tablero llamando a tools MCP cuando el usuario pida cambios. Responde texto en markdown cuando solo informe. - -Tools (MCP server "kanban"): -- Lectura: list_board, find_cards, card_history, list_users -- Columnas: create_column, update_column, delete_column, reorder_columns -- Tarjetas: create_card, update_card, delete_card, move_card, assign_card - -El estado actual del tablero viene en al final del mensaje. Usa esos IDs directamente — NO llames list_board si ya tienes lo que necesitas. NUNCA inventes IDs. - -Cuando termines, responde texto natural sin mas llamadas — eso cierra la conversacion.` - -const claudeTimeout = 300 * time.Second - -func claudeBinary() string { - if b := os.Getenv("KANBAN_CLAUDE_BIN"); b != "" { - return b - } - return "claude" -} - -func claudeModel() string { - if m := os.Getenv("KANBAN_CLAUDE_MODEL"); m != "" { - return m - } - return "claude-haiku-4-5-20251001" -} - -type chatMessage struct { - Role string `json:"role"` - Content string `json:"content"` -} - -type chatRequest struct { - Messages []chatMessage `json:"messages"` -} - -// wsEvent is the envelope sent to the browser. Type discriminates the payload. -type wsEvent struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - ToolID string `json:"tool_id,omitempty"` - Tool string `json:"tool,omitempty"` - Input json.RawMessage `json:"input,omitempty"` - Result string `json:"result,omitempty"` - IsError bool `json:"is_error,omitempty"` - BoardChanged bool `json:"board_changed,omitempty"` - Error string `json:"error,omitempty"` -} - -// handleChatWS upgrades the request to WebSocket and streams claude events. -// -// Wire protocol: -// client → server (one message): { "messages": [{role, content}, ...] } -// server → client (many): wsEvent ndjson-style messages -// types: "delta" (assistant text), "tool_use", "tool_result", "result", "error" -// server closes connection at end. -func handleChatWS(db *DB, workdir string, logger *ChatLogger, internalToken string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - conn, err := infra.WSUpgrader(w, r, []string{"*"}) - if err != nil { - return - } - defer conn.Close(websocket.StatusInternalError, "internal") - - ctx, cancel := context.WithTimeout(r.Context(), claudeTimeout) - defer cancel() - - // Read the initial chat request. - _, raw, err := conn.Read(ctx) - if err != nil { - return - } - var req chatRequest - if err := json.Unmarshal(raw, &req); err != nil { - sendWS(ctx, conn, wsEvent{Type: "error", Error: "invalid chat request: " + err.Error()}) - return - } - if len(req.Messages) == 0 { - sendWS(ctx, conn, wsEvent{Type: "error", Error: "messages required"}) - return - } - - boardChanged, err := streamChat(ctx, conn, db, workdir, internalToken, req.Messages, logger) - if err != nil { - sendWS(ctx, conn, wsEvent{Type: "error", Error: err.Error()}) - return - } - sendWS(ctx, conn, wsEvent{Type: "done", BoardChanged: boardChanged}) - conn.Close(websocket.StatusNormalClosure, "") - } -} - -func streamChat(ctx context.Context, conn *websocket.Conn, db *DB, workdir, token string, msgs []chatMessage, logger *ChatLogger) (bool, error) { - binPath, err := os.Executable() - if err != nil { - return false, fmt.Errorf("locate kanban binary: %w", err) - } - - // Backend URL: trust X-Forwarded or fall back to localhost (kanban listens - // on its main port). The MCP subprocess hits the loopback interface. - backendURL := os.Getenv("KANBAN_PUBLIC_URL") - if backendURL == "" { - port := os.Getenv("KANBAN_LISTEN_PORT") - if port == "" { - port = "8095" - } - backendURL = "http://127.0.0.1:" + port - } - - mcpPath, err := writeMCPConfig(binPath, backendURL, token) - if err != nil { - return false, fmt.Errorf("write mcp config: %w", err) - } - defer os.Remove(mcpPath) - - prompt := flattenMessages(msgs) - if board, err := boardSnapshot(db); err == nil && board != "" { - prompt += "\n\n\n" + board + "\n\n" - } - - stdin := strings.NewReader(prompt) - events, err := core.StreamClaude(ctx, core.ClaudeStreamOpts{ - Bin: claudeBinary(), - Args: []string{ - "--model", claudeModel(), - "--no-session-persistence", - "--mcp-config", mcpPath, - "--strict-mcp-config", - "--system-prompt", chatSystemPrompt, - "--allowedTools", - "mcp__kanban__list_board,mcp__kanban__create_column,mcp__kanban__update_column,mcp__kanban__rename_column,mcp__kanban__delete_column,mcp__kanban__reorder_columns,mcp__kanban__create_card,mcp__kanban__update_card,mcp__kanban__delete_card,mcp__kanban__move_card,mcp__kanban__card_history,mcp__kanban__find_cards,mcp__kanban__list_users,mcp__kanban__assign_card", - }, - Stdin: stdin, - Workdir: workdir, - }) - if err != nil { - return false, fmt.Errorf("spawn claude: %w", err) - } - - boardChanged := false - for ev := range events { - switch ev.Type { - case core.ClaudeEventTextDelta: - sendWS(ctx, conn, wsEvent{Type: "delta", Text: ev.Text}) - case core.ClaudeEventToolUse: - toolName := stripMCPPrefix(ev.ToolName) - sendWS(ctx, conn, wsEvent{ - Type: "tool_use", - ToolID: ev.ToolUseID, - Tool: toolName, - Input: ev.ToolInput, - }) - if toolMutates(toolName) { - boardChanged = true - } - case core.ClaudeEventToolResult: - sendWS(ctx, conn, wsEvent{ - Type: "tool_result", - ToolID: ev.ToolResultID, - Result: ev.ToolResultContent, - IsError: ev.ToolResultIsError, - }) - case core.ClaudeEventResult: - sendWS(ctx, conn, wsEvent{ - Type: "result", - Text: ev.Result, - IsError: ev.IsError, - }) - case core.ClaudeEventError: - sendWS(ctx, conn, wsEvent{Type: "error", Error: ev.Error}) - } - } - return boardChanged, nil -} - -// stripMCPPrefix removes the "mcp____" prefix added by claude when -// tools come from an MCP server, leaving the bare tool name. -func stripMCPPrefix(name string) string { - const pre = "mcp__kanban__" - if strings.HasPrefix(name, pre) { - return name[len(pre):] - } - return name -} - -func sendWS(ctx context.Context, conn *websocket.Conn, ev wsEvent) { - b, err := json.Marshal(ev) - if err != nil { - return - } - wctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - _ = conn.Write(wctx, websocket.MessageText, b) -} - -// flattenMessages converts chat history into a single prompt for `claude -p`. -func flattenMessages(msgs []chatMessage) string { - var b strings.Builder - for _, m := range msgs { - role := "Usuario" - if m.Role == "assistant" { - role = "Asistente" - } - b.WriteString(role) - b.WriteString(": ") - b.WriteString(m.Content) - b.WriteString("\n\n") - } - return b.String() -} - -// boardSnapshot returns a JSON dump of columns + cards to inject in the -// initial prompt, saving a list_board round-trip. -func boardSnapshot(db *DB) (string, error) { - cols, err := db.ListColumns() - if err != nil { - return "", err - } - cards, err := db.ListCardsWithTime() - if err != nil { - return "", err - } - b, err := json.Marshal(map[string]any{"columns": cols, "cards": cards}) - if err != nil { - return "", err - } - return string(b), nil -} - -// chatWorkdir resolves an absolute working directory for `claude -p`. -func chatWorkdir(dbPath string) string { - abs, err := filepath.Abs(dbPath) - if err != nil { - return "." - } - return filepath.Dir(abs) -} - -// --- Legacy handleChat retained as a thin shim that returns 410 Gone. ------- -// Kept so existing clients see a clear error instead of a 404 while they -// migrate to the WebSocket endpoint. - -func handleChat(_ *DB, _ string, _ *ChatLogger) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - infra.HTTPErrorResponse(w, infra.HTTPError{ - Status: http.StatusGone, - Code: "deprecated", - Message: "POST /api/chat removed; use WebSocket at /api/chat/ws", - }) - } -} - diff --git a/backend/chat_log.go b/backend/chat_log.go deleted file mode 100644 index e3edf36..0000000 --- a/backend/chat_log.go +++ /dev/null @@ -1,86 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "sync" - "time" -) - -// ChatLogger appends one JSON line per tool invocation to a file. Thread-safe. -// Format per line: {"ts":"...","tool":"...","input":{...},"ok":bool,"error":"...","result_summary":"..."} -type ChatLogger struct { - path string - mu sync.Mutex -} - -func newChatLogger(path string) *ChatLogger { - return &ChatLogger{path: path} -} - -type ChatLogEntry struct { - TS string `json:"ts"` - Tool string `json:"tool"` - Input json.RawMessage `json:"input"` - OK bool `json:"ok"` - Error string `json:"error,omitempty"` - ResultSummary string `json:"result_summary,omitempty"` -} - -func (l *ChatLogger) Log(tool string, input json.RawMessage, res ToolResult) { - if l == nil || l.path == "" { - return - } - entry := ChatLogEntry{ - TS: time.Now().UTC().Format(time.RFC3339Nano), - Tool: tool, - Input: input, - OK: res.OK, - Error: res.Error, - } - if res.OK && res.Result != nil { - entry.ResultSummary = summarizeResult(res.Result) - } - line, err := json.Marshal(entry) - if err != nil { - return - } - l.mu.Lock() - defer l.mu.Unlock() - f, err := os.OpenFile(l.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return - } - defer f.Close() - f.Write(line) - f.Write([]byte("\n")) -} - -// summarizeResult produces a short description of a tool result for the log. -// Keeps the log line compact: full payloads can be reconstructed from operations.db. -func summarizeResult(v any) string { - switch r := v.(type) { - case *Column: - return fmt.Sprintf("column %s name=%q", r.ID, r.Name) - case *Card: - return fmt.Sprintf("card %s title=%q col=%s", r.ID, r.Title, r.ColumnID) - case []Card: - return fmt.Sprintf("%d cards", len(r)) - case []HistoryEntry: - return fmt.Sprintf("%d history entries", len(r)) - case map[string]any: - // list_board shape - cols, _ := r["columns"].([]Column) - cards, _ := r["cards"].([]Card) - return fmt.Sprintf("board: %d cols, %d cards", len(cols), len(cards)) - } - b, err := json.Marshal(v) - if err != nil || len(b) == 0 { - return "" - } - if len(b) > 200 { - return string(b[:200]) + "..." - } - return string(b) -} diff --git a/backend/chat_ws_test.go b/backend/chat_ws_test.go deleted file mode 100644 index 153f8e6..0000000 --- a/backend/chat_ws_test.go +++ /dev/null @@ -1,296 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "nhooyr.io/websocket" -) - -// fakeClaudeScript writes a bash script that emits NDJSON stream-json events -// to stdout and exits 0. Returns the absolute path of the script. -func fakeClaudeScript(t *testing.T, payload string) string { - t.Helper() - if _, err := os.Stat("/bin/bash"); err != nil { - t.Skip("/bin/bash not available") - } - dir := t.TempDir() - path := filepath.Join(dir, "claude") - body := "#!/bin/bash\nset -e\ncat <<'__EOF__'\n" + payload + "\n__EOF__\n" - if err := os.WriteFile(path, []byte(body), 0o755); err != nil { - t.Fatalf("write fake claude: %v", err) - } - return path -} - -// chatWSTestServer wires the WebSocket chat handler in front of a test DB. -func chatWSTestServer(t *testing.T) (*httptest.Server, *DB, string) { - t.Helper() - db := setupTestDB(t) - dir := t.TempDir() - logger := newChatLogger(filepath.Join(dir, "chat.log")) - token := generateInternalToken() - srv := httptest.NewServer(handleChatWS(db, dir, logger, token)) - t.Cleanup(srv.Close) - return srv, db, token -} - -func dialChatWS(t *testing.T, srv *httptest.Server) *websocket.Conn { - t.Helper() - u, _ := url.Parse(srv.URL) - wsURL := "ws://" + u.Host - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - c, _, err := websocket.Dial(ctx, wsURL, nil) - if err != nil { - t.Fatalf("dial %s: %v", wsURL, err) - } - return c -} - -func readWSEvent(t *testing.T, conn *websocket.Conn) wsEvent { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _, data, err := conn.Read(ctx) - if err != nil { - t.Fatalf("read: %v", err) - } - var ev wsEvent - if err := json.Unmarshal(data, &ev); err != nil { - t.Fatalf("unmarshal %q: %v", string(data), err) - } - return ev -} - -func sendInitial(t *testing.T, conn *websocket.Conn, msgs []chatMessage) { - t.Helper() - body, _ := json.Marshal(chatRequest{Messages: msgs}) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := conn.Write(ctx, websocket.MessageText, body); err != nil { - t.Fatalf("write: %v", err) - } -} - -// --- WS streaming tests --------------------------------------------------- - -func TestChatWS_StreamsTextDelta(t *testing.T) { - payload := `{"type":"system","subtype":"init","session_id":"s1","model":"test"} -{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hola "}]}} -{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"mundo"}]}} -{"type":"result","subtype":"success","is_error":false,"result":"Hola mundo","stop_reason":"end_turn"}` - - t.Setenv("KANBAN_CLAUDE_BIN", fakeClaudeScript(t, payload)) - - srv, _, _ := chatWSTestServer(t) - conn := dialChatWS(t, srv) - defer conn.Close(websocket.StatusNormalClosure, "") - - sendInitial(t, conn, []chatMessage{{Role: "user", Content: "saluda"}}) - - var deltas []string - var sawResult, sawDone bool - for i := 0; i < 12 && !sawDone; i++ { - ev := readWSEvent(t, conn) - switch ev.Type { - case "delta": - deltas = append(deltas, ev.Text) - case "result": - sawResult = true - case "done": - sawDone = true - case "error": - t.Fatalf("unexpected error event: %s", ev.Error) - } - } - if !sawDone { - t.Fatalf("never received done event") - } - if !sawResult { - t.Fatalf("never received result event") - } - if got := strings.Join(deltas, ""); got != "Hola mundo" { - t.Fatalf("expected 'Hola mundo' from deltas, got %q", got) - } -} - -func TestChatWS_StreamsToolUseAndResult(t *testing.T) { - payload := `{"type":"system","subtype":"init"} -{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"mcp__kanban__create_column","input":{"name":"Backlog"}}]}} -{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"{\"ok\":true,\"result\":{\"id\":\"col_x\"}}","is_error":false}]}} -{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Listo"}]}} -{"type":"result","subtype":"success","is_error":false,"result":"Listo","stop_reason":"end_turn"}` - - t.Setenv("KANBAN_CLAUDE_BIN", fakeClaudeScript(t, payload)) - - srv, _, _ := chatWSTestServer(t) - conn := dialChatWS(t, srv) - defer conn.Close(websocket.StatusNormalClosure, "") - - sendInitial(t, conn, []chatMessage{{Role: "user", Content: "crea Backlog"}}) - - var sawToolUse, sawToolResult, sawDelta, sawDone bool - var doneEv wsEvent - for i := 0; i < 16 && !sawDone; i++ { - ev := readWSEvent(t, conn) - switch ev.Type { - case "tool_use": - sawToolUse = true - if ev.Tool != "create_column" { - t.Errorf("tool name not stripped: %q", ev.Tool) - } - if !strings.Contains(string(ev.Input), "Backlog") { - t.Errorf("input missing Backlog: %s", ev.Input) - } - case "tool_result": - sawToolResult = true - if ev.IsError { - t.Errorf("tool_result is_error true") - } - case "delta": - sawDelta = true - case "done": - sawDone = true - doneEv = ev - case "error": - t.Fatalf("unexpected error: %s", ev.Error) - } - } - if !sawToolUse || !sawToolResult || !sawDelta || !sawDone { - t.Fatalf("missing events: tool_use=%v tool_result=%v delta=%v done=%v", - sawToolUse, sawToolResult, sawDelta, sawDone) - } - if !doneEv.BoardChanged { - t.Errorf("expected board_changed=true (create_column is a mutator)") - } -} - -func TestChatWS_RejectsEmptyMessages(t *testing.T) { - t.Setenv("KANBAN_CLAUDE_BIN", fakeClaudeScript(t, - `{"type":"result","subtype":"success","is_error":false,"result":""}`)) - - srv, _, _ := chatWSTestServer(t) - conn := dialChatWS(t, srv) - defer conn.Close(websocket.StatusNormalClosure, "") - - sendInitial(t, conn, []chatMessage{}) - ev := readWSEvent(t, conn) - if ev.Type != "error" { - t.Fatalf("expected error event, got %+v", ev) - } - if !strings.Contains(ev.Error, "messages required") { - t.Fatalf("unexpected error: %s", ev.Error) - } -} - -func TestChatWS_PropagatesClaudeFailure(t *testing.T) { - dir := t.TempDir() - bin := filepath.Join(dir, "claude") - body := "#!/bin/bash\necho 'broken' >&2\nexit 7\n" - if err := os.WriteFile(bin, []byte(body), 0o755); err != nil { - t.Fatalf("write: %v", err) - } - t.Setenv("KANBAN_CLAUDE_BIN", bin) - - srv, _, _ := chatWSTestServer(t) - conn := dialChatWS(t, srv) - defer conn.Close(websocket.StatusNormalClosure, "") - - sendInitial(t, conn, []chatMessage{{Role: "user", Content: "hola"}}) - - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - ev := readWSEvent(t, conn) - switch ev.Type { - case "error": - if !strings.Contains(ev.Error, "claude exit") { - t.Fatalf("expected claude exit error, got: %s", ev.Error) - } - return - case "done": - t.Fatalf("done received before error") - } - } - t.Fatalf("never received error event") -} - -// --- /api/tool internal endpoint tests ------------------------------------ - -func internalToolServer(t *testing.T) (*httptest.Server, *DB, string) { - t.Helper() - db := setupTestDB(t) - logger := newChatLogger(filepath.Join(t.TempDir(), "log")) - token := generateInternalToken() - mux := http.NewServeMux() - mux.Handle("POST /api/tool/{name}", handleInternalTool(db, token, logger)) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - return srv, db, token -} - -func TestInternalTool_CreateColumnRoundtrip(t *testing.T) { - srv, db, token := internalToolServer(t) - req, _ := http.NewRequest("POST", srv.URL+"/api/tool/create_column", strings.NewReader(`{"name":"Backlog"}`)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set(internalTokenHeader, token) - resp, err := srv.Client().Do(req) - if err != nil { - t.Fatalf("do: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - t.Fatalf("status %d", resp.StatusCode) - } - var tr ToolResult - if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { - t.Fatalf("decode: %v", err) - } - if !tr.OK { - t.Fatalf("create_column failed: %s", tr.Error) - } - cols, err := db.ListColumns() - if err != nil { - t.Fatalf("list: %v", err) - } - if len(cols) != 1 || cols[0].Name != "Backlog" { - t.Fatalf("expected 1 col Backlog, got %+v", cols) - } -} - -func TestInternalTool_RejectsMissingToken(t *testing.T) { - srv, _, _ := internalToolServer(t) - req, _ := http.NewRequest("POST", srv.URL+"/api/tool/create_column", strings.NewReader(`{"name":"X"}`)) - req.Header.Set("Content-Type", "application/json") - resp, err := srv.Client().Do(req) - if err != nil { - t.Fatalf("do: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != 401 { - t.Fatalf("expected 401, got %d", resp.StatusCode) - } -} - -func TestInternalTool_UnknownTool(t *testing.T) { - srv, _, token := internalToolServer(t) - req, _ := http.NewRequest("POST", srv.URL+"/api/tool/no_such", strings.NewReader(`{}`)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set(internalTokenHeader, token) - resp, err := srv.Client().Do(req) - if err != nil { - t.Fatalf("do: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != 404 { - t.Fatalf("expected 404, got %d", resp.StatusCode) - } -} diff --git a/backend/go.mod b/backend/go.mod index 9df7726..a4ff32c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,7 +2,10 @@ module kanban go 1.25.0 -require fn-registry v0.0.0-00010101000000-000000000000 +require ( + fn-registry v0.0.0-00010101000000-000000000000 + gopkg.in/yaml.v3 v3.0.1 +) require ( github.com/ClickHouse/ch-go v0.71.0 // indirect @@ -42,7 +45,6 @@ require ( golang.org/x/text v0.36.0 // indirect golang.org/x/tools v0.43.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect nhooyr.io/websocket v1.8.17 // indirect ) diff --git a/backend/handlers.go b/backend/handlers.go index 3f5a74c..5d82390 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -44,7 +44,9 @@ func handleGetBoard(db *DB) http.HandlerFunc { // POST /api/columns { name } func handleCreateColumn(db *DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var body struct{ Name string `json:"name"` } + var body struct { + Name string `json:"name"` + } if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { badRequest(w, err.Error()) return @@ -101,7 +103,9 @@ func handleDeleteColumn(db *DB) http.HandlerFunc { // POST /api/columns/reorder { ids: [...] } func handleReorderColumns(db *DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var body struct{ IDs []string `json:"ids"` } + var body struct { + IDs []string `json:"ids"` + } if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { badRequest(w, err.Error()) return @@ -133,17 +137,16 @@ func handleCreateCard(db *DB) http.HandlerFunc { badRequest(w, "column_id and title required") return } - actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey) - c, err := db.CreateCard(body.ColumnID, body.Requester, body.Title, body.Description, actor) + c, err := db.CreateCard(body.ColumnID, body.Requester, body.Title, body.Description, "") if err == nil && body.AssigneeID != nil && *body.AssigneeID != "" { - err = db.UpdateCardWithActor(c.ID, CardPatch{AssigneeID: body.AssigneeID, HasAssignee: true}, actor) + err = db.UpdateCardWithActor(c.ID, CardPatch{AssigneeID: body.AssigneeID, HasAssignee: true}, "") if err == nil { c.AssigneeID = body.AssigneeID } } if err == nil && len(body.Tags) > 0 { tags := body.Tags - err = db.UpdateCardWithActor(c.ID, CardPatch{Tags: &tags}, actor) + err = db.UpdateCardWithActor(c.ID, CardPatch{Tags: &tags}, "") if err == nil { c.Tags = tags } @@ -210,27 +213,7 @@ func handleUpdateCard(db *DB) http.HandlerFunc { } patch.Tags = &tags } - actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey) - if err := db.UpdateCardWithActor(id, patch, actor); err != nil { - serverError(w, err) - return - } - w.WriteHeader(http.StatusNoContent) - } -} - -// PUT /api/cards/{id}/stickers { stickers: [{emoji,x,y}, ...] } -func handleUpdateCardStickers(db *DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - var body struct { - Stickers []Sticker `json:"stickers"` - } - if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { - badRequest(w, err.Error()) - return - } - if err := db.UpdateStickers(id, body.Stickers); err != nil { + if err := db.UpdateCardWithActor(id, patch, ""); err != nil { serverError(w, err) return } @@ -242,8 +225,7 @@ func handleUpdateCardStickers(db *DB) http.HandlerFunc { func handleDeleteCard(db *DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey) - if err := db.DeleteCardWithActor(id, actor); err != nil { + if err := db.DeleteCardWithActor(id, ""); err != nil { serverError(w, err) return } @@ -267,8 +249,7 @@ func handleMoveCard(db *DB) http.HandlerFunc { badRequest(w, "column_id required") return } - actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey) - if err := db.MoveCard(id, body.ColumnID, body.OrderedIDs, actor); err != nil { + if err := db.MoveCard(id, body.ColumnID, body.OrderedIDs, ""); err != nil { if strings.Contains(err.Error(), "not found") { notFound(w, "card not found") return @@ -280,79 +261,11 @@ func handleMoveCard(db *DB) http.HandlerFunc { } } -// GET /api/cards/{id}/messages → [CardMessage, ...] -func handleListCardMessages(db *DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - msgs, err := db.ListCardMessages(id) - if err != nil { - serverError(w, err) - return - } - infra.HTTPJSONResponse(w, http.StatusOK, msgs) - } -} - -// POST /api/cards/{id}/messages { body } -func handleCreateCardMessage(db *DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - var body struct { - Body string `json:"body"` - } - if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { - badRequest(w, err.Error()) - return - } - if strings.TrimSpace(body.Body) == "" { - badRequest(w, "body required") - return - } - actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey) - if actor == "" { - infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"}) - return - } - m, err := db.CreateCardMessage(id, actor, body.Body) - if err != nil { - if strings.Contains(err.Error(), "not found") { - notFound(w, err.Error()) - return - } - serverError(w, err) - return - } - infra.HTTPJSONResponse(w, http.StatusCreated, m) - } -} - -// DELETE /api/cards/{cid}/messages/{mid} -func handleDeleteCardMessage(db *DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - mid := r.PathValue("mid") - actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey) - if actor == "" { - infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"}) - return - } - if err := db.DeleteCardMessage(mid, actor); err != nil { - if strings.Contains(err.Error(), "not found") { - notFound(w, err.Error()) - return - } - serverError(w, err) - return - } - w.WriteHeader(http.StatusNoContent) - } -} - // POST /api/cards/{id}/duplicate func handleDuplicateCard(db *DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey) - c, err := db.DuplicateCard(id, actor) + c, err := db.DuplicateCard(id, "") if err != nil { if strings.Contains(err.Error(), "not found") { notFound(w, "card not found") @@ -365,19 +278,6 @@ func handleDuplicateCard(db *DB) http.HandlerFunc { } } -// GET /api/cards/{id}/history → [HistoryEntry, ...] -func handleCardHistory(db *DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - entries, err := db.CardHistory(id) - if err != nil { - serverError(w, err) - return - } - infra.HTTPJSONResponse(w, http.StatusOK, entries) - } -} - // GET /api/trash func handleListTrash(db *DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -394,8 +294,7 @@ func handleListTrash(db *DB) http.HandlerFunc { func handleRestoreCard(db *DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey) - if err := db.RestoreCardWithActor(id, actor); err != nil { + if err := db.RestoreCardWithActor(id, ""); err != nil { serverError(w, err) return } @@ -415,15 +314,9 @@ func handlePurgeCard(db *DB) http.HandlerFunc { } } -func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken string, flags *FeatureFlags) []infra.Route { +func apiRoutes(db *DB, flags *FeatureFlags) []infra.Route { routes := []infra.Route{ {Method: "GET", Path: "/api/flags", Handler: handleListFlags(flags)}, - {Method: "POST", Path: "/api/auth/register", Handler: handleRegister(db, flags)}, - {Method: "POST", Path: "/api/auth/login", Handler: handleLogin(db)}, - {Method: "POST", Path: "/api/auth/logout", Handler: handleLogout(db)}, - {Method: "GET", Path: "/api/me", Handler: handleMe(db)}, - {Method: "PATCH", Path: "/api/me", Handler: handlePatchMe(db)}, - {Method: "GET", Path: "/api/users", Handler: handleListUsers(db)}, {Method: "GET", Path: "/api/board", Handler: handleGetBoard(db)}, {Method: "POST", Path: "/api/columns", Handler: handleCreateColumn(db)}, {Method: "POST", Path: "/api/columns/reorder", Handler: handleReorderColumns(db)}, @@ -431,21 +324,12 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str {Method: "DELETE", Path: "/api/columns/{id}", Handler: handleDeleteColumn(db)}, {Method: "POST", Path: "/api/cards", Handler: handleCreateCard(db)}, {Method: "PATCH", Path: "/api/cards/{id}", Handler: handleUpdateCard(db)}, - {Method: "PUT", Path: "/api/cards/{id}/stickers", Handler: handleUpdateCardStickers(db)}, {Method: "DELETE", Path: "/api/cards/{id}", Handler: handleDeleteCard(db)}, {Method: "POST", Path: "/api/cards/{id}/move", Handler: handleMoveCard(db)}, {Method: "POST", Path: "/api/cards/{id}/duplicate", Handler: handleDuplicateCard(db)}, - {Method: "GET", Path: "/api/cards/{id}/messages", Handler: handleListCardMessages(db)}, - {Method: "POST", Path: "/api/cards/{id}/messages", Handler: handleCreateCardMessage(db)}, - {Method: "DELETE", Path: "/api/cards/{id}/messages/{mid}", Handler: handleDeleteCardMessage(db)}, - {Method: "GET", Path: "/api/cards/{id}/history", Handler: handleCardHistory(db)}, {Method: "GET", Path: "/api/trash", Handler: handleListTrash(db)}, {Method: "POST", Path: "/api/cards/{id}/restore", Handler: handleRestoreCard(db)}, {Method: "DELETE", Path: "/api/cards/{id}/purge", Handler: handlePurgeCard(db)}, - {Method: "POST", Path: "/api/chat", Handler: handleChat(db, chatWorkdir, logger)}, - {Method: "GET", Path: "/api/chat/ws", Handler: handleChatWS(db, chatWorkdir, logger, internalToken)}, - {Method: "POST", Path: "/api/tool/{name}", Handler: handleInternalTool(db, internalToken, logger)}, - {Method: "GET", Path: "/api/metrics", Handler: handleMetrics(db)}, {Method: "GET", Path: "/api/tags", Handler: handleListTags(db)}, {Method: "GET", Path: "/api/requesters", Handler: handleListRequesters(db)}, } diff --git a/backend/internal_tool.go b/backend/internal_tool.go deleted file mode 100644 index 521228e..0000000 --- a/backend/internal_tool.go +++ /dev/null @@ -1,60 +0,0 @@ -package main - -import ( - "crypto/rand" - "crypto/subtle" - "encoding/hex" - "encoding/json" - "io" - "net/http" - - "fn-registry/functions/infra" -) - -const internalTokenHeader = "X-Internal-Token" - -// generateInternalToken returns a 32-byte hex token used by the kanban-mcp -// subprocess to call back into /api/tool/{name}. Generated fresh per process. -func generateInternalToken() string { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - panic("rand.Read: " + err.Error()) - } - return hex.EncodeToString(b) -} - -// handleInternalTool exposes executeTool via HTTP for the MCP subprocess. -// Auth: shared internal token in X-Internal-Token header. Constant-time compare. -func handleInternalTool(db *DB, expectedToken string, logger *ChatLogger) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - got := r.Header.Get(internalTokenHeader) - if subtle.ConstantTimeCompare([]byte(got), []byte(expectedToken)) != 1 { - infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "invalid internal token"}) - return - } - name := r.PathValue("name") - if name == "" { - infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusBadRequest, Code: "bad_request", Message: "tool name required"}) - return - } - body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes)) - if err != nil { - infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusBadRequest, Code: "bad_request", Message: err.Error()}) - return - } - if len(body) == 0 { - body = []byte("{}") - } - input := json.RawMessage(body) - if err := validateToolName(name); err != nil { - infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusNotFound, Code: "unknown_tool", Message: err.Error()}) - return - } - res := executeTool(db, name, input) - if logger != nil { - logger.Log(name, input, res) - } - // Always 200 — MCP-side maps res.OK to MCP isError. - infra.HTTPJSONResponse(w, http.StatusOK, res) - } -} diff --git a/backend/main.go b/backend/main.go index d2f8a16..73b7167 100644 --- a/backend/main.go +++ b/backend/main.go @@ -2,39 +2,24 @@ package main import ( "context" - "embed" + "encoding/json" "flag" "fmt" - "io/fs" "log" "net/http" "os" "os/signal" - "path/filepath" - "strings" "syscall" - "time" "fn-registry/functions/infra" ) -//go:embed all:dist -var frontendDist embed.FS +const syncLayerVersion = "v0.1.0" func main() { - // Subcommand `kanban mcp` runs as MCP server over stdio (spawned by claude -p). - if len(os.Args) > 1 && os.Args[1] == "mcp" { - if err := runMCPServer(os.Args[2:]); err != nil { - fmt.Fprintf(os.Stderr, "kanban mcp: %v\n", err) - os.Exit(1) - } - return - } - - flags := flag.NewFlagSet("kanban", flag.ExitOnError) + flags := flag.NewFlagSet("kanban_cpp_backend", flag.ExitOnError) port := flags.Int("port", 8403, "HTTP port") dbPath := flags.String("db", "operations.db", "SQLite database path") - initialAdmin := flags.String("initial-admin", os.Getenv("KANBAN_INITIAL_ADMIN"), "Bootstrap admin in user:pass form (only if no users yet)") flagsPath := flags.String("flags", "dev/feature_flags.json", "Feature flags JSON path (missing file → all disabled)") flags.Parse(os.Args[1:]) @@ -52,43 +37,17 @@ func main() { } defer db.Close() - bootstrapAdmin(db, *initialAdmin) - startSessionCleanup(db) - - internalToken := os.Getenv("KANBAN_INTERNAL_TOKEN") - if internalToken == "" { - internalToken = generateInternalToken() - } - - wd := chatWorkdir(*dbPath) - logger := newChatLogger(filepath.Join(wd, "chat.log")) - log.Printf("chat tool log: %s", logger.path) - mux := infra.HTTPRouter(apiRoutes(db, wd, logger, internalToken, &featureFlags)) - - feHandler := frontendHandler() - if feHandler != nil { - mux.Handle("/", feHandler) - log.Printf("serving frontend from embedded dist/") - } else { - log.Printf("no frontend build found, API-only mode") - } - - authMW := infra.HTTPSessionCookieMiddleware(infra.SessionCookieConfig{ - DB: db.conn, - CookieName: cookieName, - SkipPaths: []string{"/api/auth/", "/api/tool/", "/api/flags", "/api/boards/", "/health", "/assets/", "/index.html"}, - UserCtxKey: userCtxKey, - }) + mux := infra.HTTPRouter(apiRoutes(db, &featureFlags)) + mux.HandleFunc("/health", handleHealth(*port)) chain := infra.HTTPMiddlewareChain( infra.HTTPLoggerMiddleware(os.Stdout), infra.HTTPCORSMiddleware([]string{"*"}, []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"}), - apiOnlyAuth(authMW), ) handler := chain(mux) addr := fmt.Sprintf(":%d", *port) - log.Printf("kanban server starting on http://0.0.0.0%s", addr) + log.Printf("kanban_cpp_backend starting on http://0.0.0.0%s (sync layer %s)", addr, syncLayerVersion) log.Printf("database: %s", *dbPath) ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) @@ -99,69 +58,14 @@ func main() { } } -// apiOnlyAuth applies auth middleware only to /api/* paths so the SPA shell -// can be served without a session (the SPA itself handles login UI). -func apiOnlyAuth(mw infra.Middleware) infra.Middleware { - return func(next http.Handler) http.Handler { - gated := mw(next) - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/api/") { - gated.ServeHTTP(w, r) - return - } - next.ServeHTTP(w, r) +// handleHealth returns 200 with a small JSON describing the service. No auth. +func handleHealth(port int) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "port": port, + "sync_layer": syncLayerVersion, }) } } - -func bootstrapAdmin(db *DB, spec string) { - spec = strings.TrimSpace(spec) - if spec == "" { - return - } - count, err := db.CountUsers() - if err != nil { - log.Printf("bootstrap admin: count users: %v", err) - return - } - if count > 0 { - return - } - parts := strings.SplitN(spec, ":", 2) - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - log.Printf("bootstrap admin: invalid spec, expected user:pass") - return - } - u, err := db.CreateUser(parts[0], parts[1], parts[0]) - if err != nil { - log.Printf("bootstrap admin: %v", err) - return - } - log.Printf("bootstrap admin: created user %q", u.Username) -} - -func startSessionCleanup(db *DB) { - go func() { - t := time.NewTicker(1 * time.Hour) - defer t.Stop() - for range t.C { - if n, err := infra.SessionCleanup(db.conn); err != nil { - log.Printf("session cleanup: %v", err) - } else if n > 0 { - log.Printf("session cleanup: purged %d expired", n) - } - } - }() -} - -func frontendHandler() http.Handler { - sub, err := fs.Sub(frontendDist, "dist") - if err != nil { - return nil - } - entries, _ := fs.ReadDir(sub, ".") - if len(entries) == 0 { - return nil - } - return infra.SPAHandler(sub, "index.html") -} diff --git a/backend/mcp.go b/backend/mcp.go deleted file mode 100644 index a07f394..0000000 --- a/backend/mcp.go +++ /dev/null @@ -1,302 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "flag" - "fmt" - "io" - "net/http" - "os" - "os/signal" - "syscall" - "time" - - "fn-registry/functions/infra" -) - -// runMCPServer is the entry point for the `kanban mcp` subcommand. It runs -// stdio JSON-RPC and forwards each tool call to the kanban backend's -// /api/tool/{name} endpoint, authenticated with a shared internal token. -// -// Required env vars (set by the parent kanban process when generating mcp.json): -// KANBAN_BACKEND_URL — e.g. http://127.0.0.1:8095 -// KANBAN_INTERNAL_TOKEN — token to send in X-Internal-Token header -func runMCPServer(args []string) error { - fs := flag.NewFlagSet("kanban mcp", flag.ContinueOnError) - urlFlag := fs.String("url", os.Getenv("KANBAN_BACKEND_URL"), "kanban backend URL") - tokenFlag := fs.String("token", os.Getenv("KANBAN_INTERNAL_TOKEN"), "internal token") - if err := fs.Parse(args); err != nil { - return err - } - if *urlFlag == "" { - return fmt.Errorf("--url or KANBAN_BACKEND_URL required") - } - if *tokenFlag == "" { - return fmt.Errorf("--token or KANBAN_INTERNAL_TOKEN required") - } - - httpClient := &http.Client{Timeout: 30 * time.Second} - - tools := mcpToolDefs() - handler := func(ctx context.Context, name string, input json.RawMessage) (any, bool, error) { - body := []byte(input) - if len(body) == 0 { - body = []byte("{}") - } - req, err := http.NewRequestWithContext(ctx, "POST", *urlFlag+"/api/tool/"+name, bytes.NewReader(body)) - if err != nil { - return nil, false, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set(internalTokenHeader, *tokenFlag) - resp, err := httpClient.Do(req) - if err != nil { - return nil, false, err - } - defer resp.Body.Close() - buf, err := io.ReadAll(resp.Body) - if err != nil { - return nil, false, err - } - if resp.StatusCode >= 500 { - return nil, false, fmt.Errorf("backend %d: %s", resp.StatusCode, string(buf)) - } - // 4xx and 2xx both serialize as ToolResult JSON. Decode and map. - var tr ToolResult - if err := json.Unmarshal(buf, &tr); err != nil { - // Non-ToolResult body (e.g. unauthorized error envelope from infra). - return string(buf), resp.StatusCode >= 400, nil - } - if !tr.OK { - return tr.Error, true, nil - } - return tr.Result, false, nil - } - - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer cancel() - - return infra.ServeMCP(ctx, infra.MCPServerOpts{ - Name: "kanban", - Version: "1.0.0", - Tools: tools, - Handler: handler, - In: os.Stdin, - Out: os.Stdout, - Logger: os.Stderr, - }) -} - -// mcpToolDefs returns the JSON-Schema definitions for every kanban tool. -// Names match the executeTool dispatch table in tools.go. -func mcpToolDefs() []infra.MCPToolDef { - return []infra.MCPToolDef{ - { - Name: "list_board", - Description: "Lista columnas y tarjetas del tablero. Sin argumentos. Devuelve {columns, cards}.", - InputSchema: rawSchema(map[string]any{"type": "object", "properties": map[string]any{}}), - }, - { - Name: "create_column", - Description: "Crea una columna nueva. Devuelve la columna creada con su id.", - InputSchema: rawSchema(map[string]any{ - "type": "object", - "properties": map[string]any{ - "name": map[string]any{"type": "string", "description": "Nombre de la columna"}, - }, - "required": []string{"name"}, - }), - }, - { - Name: "update_column", - Description: "Modifica una columna existente. Pasa al menos uno: name, location ('board'|'sidebar'), width (200..800 px), wip_limit (0=sin limite), is_done (terminal: cards cuentan como completadas).", - InputSchema: rawSchema(map[string]any{ - "type": "object", - "properties": map[string]any{ - "id": map[string]any{"type": "string"}, - "name": map[string]any{"type": "string"}, - "location": map[string]any{"type": "string", "enum": []string{"board", "sidebar"}}, - "width": map[string]any{"type": "integer"}, - "wip_limit": map[string]any{"type": "integer"}, - "is_done": map[string]any{"type": "boolean"}, - }, - "required": []string{"id"}, - }), - }, - { - Name: "rename_column", - Description: "Alias de update_column con solo {id, name}.", - InputSchema: rawSchema(map[string]any{ - "type": "object", - "properties": map[string]any{ - "id": map[string]any{"type": "string"}, - "name": map[string]any{"type": "string"}, - }, - "required": []string{"id", "name"}, - }), - }, - { - Name: "delete_column", - Description: "Elimina una columna y todas sus tarjetas (las envia a la papelera).", - InputSchema: rawSchema(map[string]any{ - "type": "object", - "properties": map[string]any{ - "id": map[string]any{"type": "string"}, - }, - "required": []string{"id"}, - }), - }, - { - Name: "reorder_columns", - Description: "Reordena columnas. ids es el array completo de columnas en el nuevo orden.", - InputSchema: rawSchema(map[string]any{ - "type": "object", - "properties": map[string]any{ - "ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, - }, - "required": []string{"ids"}, - }), - }, - { - Name: "create_card", - Description: "Crea una tarjeta en una columna. column_id y title obligatorios.", - InputSchema: rawSchema(map[string]any{ - "type": "object", - "properties": map[string]any{ - "column_id": map[string]any{"type": "string"}, - "requester": map[string]any{"type": "string"}, - "title": map[string]any{"type": "string"}, - "description": map[string]any{"type": "string"}, - }, - "required": []string{"column_id", "title"}, - }), - }, - { - Name: "update_card", - Description: "Edita campos de una tarjeta. Color: blue|teal|violet|pink|orange|green|yellow|red|''. locked bloquea movimiento. assignee_id null para desasignar.", - InputSchema: rawSchema(map[string]any{ - "type": "object", - "properties": map[string]any{ - "id": map[string]any{"type": "string"}, - "requester": map[string]any{"type": "string"}, - "title": map[string]any{"type": "string"}, - "description": map[string]any{"type": "string"}, - "color": map[string]any{"type": "string"}, - "locked": map[string]any{"type": "boolean"}, - "assignee_id": map[string]any{"type": []string{"string", "null"}}, - }, - "required": []string{"id"}, - }), - }, - { - Name: "delete_card", - Description: "Envia una tarjeta a la papelera.", - InputSchema: rawSchema(map[string]any{ - "type": "object", - "properties": map[string]any{ - "id": map[string]any{"type": "string"}, - }, - "required": []string{"id"}, - }), - }, - { - Name: "move_card", - Description: "Mueve una tarjeta a otra columna. Si omites ordered_ids, se anade al final.", - InputSchema: rawSchema(map[string]any{ - "type": "object", - "properties": map[string]any{ - "id": map[string]any{"type": "string"}, - "column_id": map[string]any{"type": "string"}, - "ordered_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, - }, - "required": []string{"id", "column_id"}, - }), - }, - { - Name: "card_history", - Description: "Devuelve el historial de cambios de una tarjeta.", - InputSchema: rawSchema(map[string]any{ - "type": "object", - "properties": map[string]any{ - "id": map[string]any{"type": "string"}, - }, - "required": []string{"id"}, - }), - }, - { - Name: "find_cards", - Description: "Busca tarjetas. query (texto en title/description/requester), column_id (filtra por columna), requester (filtra por solicitante).", - InputSchema: rawSchema(map[string]any{ - "type": "object", - "properties": map[string]any{ - "query": map[string]any{"type": "string"}, - "column_id": map[string]any{"type": "string"}, - "requester": map[string]any{"type": "string"}, - }, - }), - }, - { - Name: "list_users", - Description: "Lista usuarios disponibles para asignar tarjetas.", - InputSchema: rawSchema(map[string]any{"type": "object", "properties": map[string]any{}}), - }, - { - Name: "assign_card", - Description: "Asigna o desasigna una tarjeta. assignee_id null para desasignar.", - InputSchema: rawSchema(map[string]any{ - "type": "object", - "properties": map[string]any{ - "id": map[string]any{"type": "string"}, - "assignee_id": map[string]any{"type": []string{"string", "null"}}, - }, - "required": []string{"id"}, - }), - }, - } -} - -func rawSchema(s map[string]any) json.RawMessage { - b, err := json.Marshal(s) - if err != nil { - panic(err) - } - return b -} - -// writeMCPConfig writes a temporary mcp.json that points to this binary's -// `mcp` subcommand with the given URL and token. Returns the absolute path of -// the file created. Caller is responsible for removing it. -func writeMCPConfig(binPath, backendURL, token string) (string, error) { - cfg := map[string]any{ - "mcpServers": map[string]any{ - "kanban": map[string]any{ - "command": binPath, - "args": []string{"mcp"}, - "env": map[string]string{ - "KANBAN_BACKEND_URL": backendURL, - "KANBAN_INTERNAL_TOKEN": token, - }, - }, - }, - } - b, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return "", err - } - f, err := os.CreateTemp("", "kanban-mcp-*.json") - if err != nil { - return "", err - } - if _, err := f.Write(b); err != nil { - f.Close() - os.Remove(f.Name()) - return "", err - } - if err := f.Close(); err != nil { - os.Remove(f.Name()) - return "", err - } - return f.Name(), nil -} diff --git a/backend/metrics.go b/backend/metrics.go deleted file mode 100644 index 87ae881..0000000 --- a/backend/metrics.go +++ /dev/null @@ -1,603 +0,0 @@ -package main - -import ( - "net/http" - "sort" - "strings" - "time" - - "fn-registry/functions/core" - "fn-registry/functions/datascience" - "fn-registry/functions/infra" -) - -type DurationStats = datascience.DurationStats - -type Metrics struct { - Range DateRange `json:"range"` - Totals Totals `json:"totals"` - ByColumn []ColumnCount `json:"by_column"` - ThroughputDaily []DailyCount `json:"throughput_daily"` - CreatedDaily []DailyCount `json:"created_daily"` - LeadTime DurationStats `json:"lead_time"` - CycleTimeColumn []ColumnDuration `json:"cycle_time_per_column"` - TopAssignees []AssigneeStat `json:"top_assignees"` - TopRequesters []RequesterStat `json:"top_requesters"` - MovementsByUser []MovementStat `json:"movements_by_user"` - LockTotalMs int64 `json:"lock_total_ms"` - LockActiveCount int `json:"lock_active_count"` - CumulativeFlow []CumulativePoint `json:"cumulative_flow"` -} - -type CumulativePoint struct { - Date string `json:"date"` - Total int `json:"total"` - Done int `json:"done"` -} - -type DateRange struct { - From string `json:"from"` - To string `json:"to"` -} - -type Totals struct { - Cards int `json:"cards"` - CardsCompleted int `json:"cards_completed_in_range"` - CardsCreated int `json:"cards_created_in_range"` - CardsActive int `json:"cards_active"` - CardsDone int `json:"cards_done"` - Columns int `json:"columns"` - Users int `json:"users"` - ActiveLocks int `json:"active_locks"` -} - -type ColumnCount struct { - ColumnID string `json:"column_id"` - Name string `json:"name"` - IsDone bool `json:"is_done"` - Count int `json:"count"` -} - -type DailyCount struct { - Date string `json:"date"` - Count int `json:"count"` -} - -type ColumnDuration struct { - ColumnID string `json:"column_id"` - Name string `json:"name"` - IsDone bool `json:"is_done"` - Stats DurationStats `json:"stats"` -} - -type AssigneeStat struct { - UserID string `json:"user_id"` - Username string `json:"username"` - DisplayName string `json:"display_name"` - Active int `json:"active"` - Completed int `json:"completed_in_range"` -} - -type RequesterStat struct { - Requester string `json:"requester"` - Total int `json:"total"` - Active int `json:"active"` - Completed int `json:"completed_in_range"` -} - -type MovementStat struct { - UserID string `json:"user_id"` - Username string `json:"username"` - DisplayName string `json:"display_name"` - Moves int `json:"moves"` -} - -func computeStats(durations []int64) DurationStats { - return datascience.DurationStatsFrom(durations) -} - -func parseDateOrDefault(s string, dflt time.Time) time.Time { - return core.ParseDateOrDefault(s, dflt, false) -} - -func parseEndDateOrDefault(s string, dflt time.Time) time.Time { - return core.ParseDateOrDefault(s, dflt, true) -} - -// GET /api/metrics?from=YYYY-MM-DD&to=YYYY-MM-DD&assignee_id=...&requester=... -func handleMetrics(db *DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - now := time.Now().UTC() - from := parseDateOrDefault(r.URL.Query().Get("from"), now.AddDate(0, 0, -30)) - to := parseEndDateOrDefault(r.URL.Query().Get("to"), now) - assignee := r.URL.Query().Get("assignee_id") - requester := r.URL.Query().Get("requester") - tagsRaw := r.URL.Query().Get("tags") - var tags []string - if tagsRaw != "" { - for _, t := range strings.Split(tagsRaw, ",") { - if t = strings.TrimSpace(t); t != "" { - tags = append(tags, t) - } - } - } - - m, err := computeMetrics(db, from, to, assignee, requester, tags) - if err != nil { - serverError(w, err) - return - } - infra.HTTPJSONResponse(w, http.StatusOK, m) - } -} - -func computeMetrics(db *DB, from, to time.Time, assignee, requester string, tags []string) (*Metrics, error) { - fromStr := from.Format(time.RFC3339Nano) - toStr := to.Format(time.RFC3339Nano) - - m := &Metrics{ - Range: DateRange{From: from.Format("2006-01-02"), To: to.Format("2006-01-02")}, - ByColumn: []ColumnCount{}, - ThroughputDaily: []DailyCount{}, - CreatedDaily: []DailyCount{}, - CycleTimeColumn: []ColumnDuration{}, - TopAssignees: []AssigneeStat{}, - TopRequesters: []RequesterStat{}, - MovementsByUser: []MovementStat{}, - CumulativeFlow: []CumulativePoint{}, - } - - cardWhere := "WHERE deleted_at IS NULL" - args := []any{} - if assignee != "" { - cardWhere += " AND assignee_id=?" - args = append(args, assignee) - } - if requester != "" { - cardWhere += " AND requester=?" - args = append(args, requester) - } - for _, t := range tags { - cardWhere += " AND tags LIKE ?" - args = append(args, `%"`+t+`"%`) - } - - if err := db.conn.QueryRow(`SELECT COUNT(*) FROM cards `+cardWhere, args...).Scan(&m.Totals.Cards); err != nil { - return nil, err - } - completedArgs := append([]any{fromStr, toStr}, args...) - if err := db.conn.QueryRow( - `SELECT COUNT(*) FROM cards `+cardWhere+` AND completed_at IS NOT NULL AND completed_at>=? AND completed_at<=?`, - append(args, fromStr, toStr)..., - ).Scan(&m.Totals.CardsCompleted); err != nil { - return nil, err - } - if err := db.conn.QueryRow( - `SELECT COUNT(*) FROM cards `+cardWhere+` AND created_at>=? AND created_at<=?`, - append(args, fromStr, toStr)..., - ).Scan(&m.Totals.CardsCreated); err != nil { - return nil, err - } - if err := db.conn.QueryRow( - `SELECT COUNT(*) FROM cards `+cardWhere+` AND (completed_at IS NULL OR completed_at='')`, - args..., - ).Scan(&m.Totals.CardsActive); err != nil { - return nil, err - } - if err := db.conn.QueryRow( - `SELECT COUNT(*) FROM cards `+cardWhere+` AND completed_at IS NOT NULL AND completed_at!=''`, - args..., - ).Scan(&m.Totals.CardsDone); err != nil { - return nil, err - } - _ = completedArgs - - if err := db.conn.QueryRow(`SELECT COUNT(*) FROM columns`).Scan(&m.Totals.Columns); err != nil { - return nil, err - } - if err := db.conn.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&m.Totals.Users); err != nil { - return nil, err - } - lockActiveQ := `SELECT COUNT(*) FROM card_lock_history h JOIN cards c ON c.id=h.card_id WHERE h.unlocked_at IS NULL AND c.deleted_at IS NULL` - if assignee != "" { - lockActiveQ += ` AND c.assignee_id=?` - } - if requester != "" { - lockActiveQ += ` AND c.requester=?` - } - if err := db.conn.QueryRow(lockActiveQ, args...).Scan(&m.Totals.ActiveLocks); err != nil { - return nil, err - } - - // By column. - rows, err := db.conn.Query( - `SELECT col.id, col.name, col.is_done, COUNT(c.id) - FROM columns col - LEFT JOIN cards c ON c.column_id=col.id`+ - condFromCard(assignee, requester, "c", "WHERE")+ - ` GROUP BY col.id ORDER BY col.position`, - colArgs(assignee, requester)..., - ) - if err != nil { - return nil, err - } - for rows.Next() { - var cc ColumnCount - var isDone int - if err := rows.Scan(&cc.ColumnID, &cc.Name, &isDone, &cc.Count); err != nil { - rows.Close() - return nil, err - } - cc.IsDone = isDone != 0 - m.ByColumn = append(m.ByColumn, cc) - } - rows.Close() - - // Throughput daily (completed_at within range). - m.ThroughputDaily, err = dailyBucket(db, "completed_at", fromStr, toStr, assignee, requester, true) - if err != nil { - return nil, err - } - m.CreatedDaily, err = dailyBucket(db, "created_at", fromStr, toStr, assignee, requester, false) - if err != nil { - return nil, err - } - - // Lead time (cards completed in range, completed_at - created_at). - leadDurs, err := collectDurations(db, - `SELECT (julianday(completed_at) - julianday(created_at)) * 86400000 FROM cards `+ - cardWhere+` AND completed_at IS NOT NULL AND completed_at>=? AND completed_at<=?`, - append(args, fromStr, toStr)..., - ) - if err != nil { - return nil, err - } - m.LeadTime = computeStats(leadDurs) - - // Cycle time per column. - colRows, err := db.conn.Query(`SELECT id, name, is_done FROM columns ORDER BY position`) - if err != nil { - return nil, err - } - type colInfo struct { - id, name string - isDone bool - } - var cols []colInfo - for colRows.Next() { - var ci colInfo - var d int - if err := colRows.Scan(&ci.id, &ci.name, &d); err != nil { - colRows.Close() - return nil, err - } - ci.isDone = d != 0 - cols = append(cols, ci) - } - colRows.Close() - - now := time.Now().UTC() - cap := to - if now.Before(cap) { - cap = now - } - capStr := cap.Format(time.RFC3339Nano) - for _, ci := range cols { - histArgs := []any{ci.id, fromStr, toStr} - histQ := `SELECT (julianday(COALESCE(h.exited_at, ?)) - julianday(h.entered_at)) * 86400000 - FROM card_column_history h JOIN cards c ON c.id=h.card_id - WHERE h.column_id=? AND h.entered_at>=? AND h.entered_at<=?` - histArgs = append([]any{capStr}, histArgs...) - if assignee != "" { - histQ += ` AND c.assignee_id=?` - histArgs = append(histArgs, assignee) - } - if requester != "" { - histQ += ` AND c.requester=?` - histArgs = append(histArgs, requester) - } - durs, err := collectDurations(db, histQ, histArgs...) - if err != nil { - return nil, err - } - m.CycleTimeColumn = append(m.CycleTimeColumn, ColumnDuration{ - ColumnID: ci.id, Name: ci.name, IsDone: ci.isDone, - Stats: computeStats(durs), - }) - } - - // Top assignees. - asRows, err := db.conn.Query( - `SELECT u.id, u.username, u.display_name, - SUM(CASE WHEN c.completed_at IS NULL OR c.completed_at='' THEN 1 ELSE 0 END) as active, - SUM(CASE WHEN c.completed_at IS NOT NULL AND c.completed_at>=? AND c.completed_at<=? THEN 1 ELSE 0 END) as completed - FROM users u - LEFT JOIN cards c ON c.assignee_id=u.id` + cardJoinFilter(requester) + - ` GROUP BY u.id ORDER BY completed DESC, active DESC`, - topAssigneeArgs(fromStr, toStr, requester)..., - ) - if err != nil { - return nil, err - } - for asRows.Next() { - var s AssigneeStat - if err := asRows.Scan(&s.UserID, &s.Username, &s.DisplayName, &s.Active, &s.Completed); err != nil { - asRows.Close() - return nil, err - } - m.TopAssignees = append(m.TopAssignees, s) - } - asRows.Close() - - // Top requesters. - reqRows, err := db.conn.Query( - `SELECT requester, - COUNT(*) as total, - SUM(CASE WHEN completed_at IS NULL OR completed_at='' THEN 1 ELSE 0 END) as active, - SUM(CASE WHEN completed_at IS NOT NULL AND completed_at>=? AND completed_at<=? THEN 1 ELSE 0 END) as completed - FROM cards - WHERE deleted_at IS NULL AND requester != ''`+ - condFromCard(assignee, "", "", "AND")+ - ` GROUP BY requester ORDER BY total DESC LIMIT 10`, - topReqArgs(fromStr, toStr, assignee)..., - ) - if err != nil { - return nil, err - } - for reqRows.Next() { - var s RequesterStat - if err := reqRows.Scan(&s.Requester, &s.Total, &s.Active, &s.Completed); err != nil { - reqRows.Close() - return nil, err - } - m.TopRequesters = append(m.TopRequesters, s) - } - reqRows.Close() - - // Movements by user. - mvRows, err := db.conn.Query( - `SELECT u.id, u.username, u.display_name, COUNT(h.id) as moves - FROM users u - LEFT JOIN card_column_history h ON h.actor_id=u.id AND h.entered_at>=? AND h.entered_at<=? - GROUP BY u.id ORDER BY moves DESC`, - fromStr, toStr, - ) - if err != nil { - return nil, err - } - for mvRows.Next() { - var s MovementStat - if err := mvRows.Scan(&s.UserID, &s.Username, &s.DisplayName, &s.Moves); err != nil { - mvRows.Close() - return nil, err - } - m.MovementsByUser = append(m.MovementsByUser, s) - } - mvRows.Close() - - // Lock total in range. - var lockMs float64 - if err := db.conn.QueryRow( - `SELECT COALESCE(SUM( - (julianday(COALESCE(h.unlocked_at, ?)) - julianday(h.locked_at)) * 86400000 - ), 0) FROM card_lock_history h JOIN cards c ON c.id=h.card_id - WHERE h.locked_at>=? AND h.locked_at<=?`+condFromCard(assignee, requester, "c", "AND"), - append([]any{toStr, fromStr, toStr}, colArgs(assignee, requester)...)..., - ).Scan(&lockMs); err != nil { - return nil, err - } - m.LockTotalMs = int64(lockMs) - - // Cumulative flow: walk daily from→to, count cards created<=day and done<=day. - cfd, err := computeCumulativeFlow(db, from, to, assignee, requester) - if err != nil { - return nil, err - } - m.CumulativeFlow = cfd - - return m, nil -} - -func computeCumulativeFlow(db *DB, from, to time.Time, assignee, requester string) ([]CumulativePoint, error) { - creates := map[string]int{} - dones := map[string]int{} - - cardWhere := "WHERE deleted_at IS NULL" - args := []any{} - if assignee != "" { - cardWhere += " AND assignee_id=?" - args = append(args, assignee) - } - if requester != "" { - cardWhere += " AND requester=?" - args = append(args, requester) - } - - rows, err := db.conn.Query(`SELECT substr(created_at,1,10), COUNT(*) FROM cards `+cardWhere+` GROUP BY substr(created_at,1,10)`, args...) - if err != nil { - return nil, err - } - for rows.Next() { - var d string - var n int - if err := rows.Scan(&d, &n); err != nil { - rows.Close() - return nil, err - } - creates[d] = n - } - rows.Close() - - rows, err = db.conn.Query(`SELECT substr(completed_at,1,10), COUNT(*) FROM cards `+cardWhere+` AND completed_at IS NOT NULL AND completed_at != '' GROUP BY substr(completed_at,1,10)`, args...) - if err != nil { - return nil, err - } - for rows.Next() { - var d string - var n int - if err := rows.Scan(&d, &n); err != nil { - rows.Close() - return nil, err - } - dones[d] = n - } - rows.Close() - - out := []CumulativePoint{} - totalAcc := 0 - doneAcc := 0 - day := from - end := to - if end.Before(day) { - return out, nil - } - for d := day; !d.After(end); d = d.AddDate(0, 0, 1) { - ds := d.Format("2006-01-02") - // Sum all creates with key <= ds, all dones with key <= ds. - // Optimize: track keys already accounted; here we just do once per loop using map sums. - _ = ds - } - // Simpler: collect and sort all create/done dates, sweep. - type ev struct { - date string - creates int - dones int - } - all := map[string]*ev{} - for d, n := range creates { - all[d] = &ev{date: d, creates: n} - } - for d, n := range dones { - if e, ok := all[d]; ok { - e.dones = n - } else { - all[d] = &ev{date: d, dones: n} - } - } - dates := make([]string, 0, len(all)) - for d := range all { - dates = append(dates, d) - } - sort.Strings(dates) - - // Accumulate up to `from` first. - fromS := from.Format("2006-01-02") - idx := 0 - for idx < len(dates) && dates[idx] < fromS { - totalAcc += all[dates[idx]].creates - doneAcc += all[dates[idx]].dones - idx++ - } - for d := from; !d.After(to); d = d.AddDate(0, 0, 1) { - ds := d.Format("2006-01-02") - for idx < len(dates) && dates[idx] <= ds { - totalAcc += all[dates[idx]].creates - doneAcc += all[dates[idx]].dones - idx++ - } - out = append(out, CumulativePoint{Date: ds, Total: totalAcc, Done: doneAcc}) - } - return out, nil -} - -func condFromCard(assignee, requester, alias, leadKw string) string { - pref := alias - if pref != "" { - pref += "." - } - out := "" - if assignee != "" { - out += " " + leadKw + " " + pref + "assignee_id=?" - leadKw = "AND" - } - if requester != "" { - out += " " + leadKw + " " + pref + "requester=?" - } - return out -} - -func colArgs(assignee, requester string) []any { - args := []any{} - if assignee != "" { - args = append(args, assignee) - } - if requester != "" { - args = append(args, requester) - } - return args -} - -func cardJoinFilter(requester string) string { - if requester != "" { - return " AND c.requester=?" - } - return "" -} - -func topAssigneeArgs(fromStr, toStr, requester string) []any { - args := []any{fromStr, toStr} - if requester != "" { - args = append(args, requester) - } - return args -} - -func topReqArgs(fromStr, toStr, assignee string) []any { - args := []any{fromStr, toStr} - if assignee != "" { - args = append(args, assignee) - } - return args -} - -func collectDurations(db *DB, query string, args ...any) ([]int64, error) { - rows, err := db.conn.Query(query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - out := []int64{} - for rows.Next() { - var v float64 - if err := rows.Scan(&v); err != nil { - return nil, err - } - if v < 0 { - v = 0 - } - out = append(out, int64(v)) - } - return out, rows.Err() -} - -func dailyBucket(db *DB, dateCol, fromStr, toStr, assignee, requester string, requireNonNull bool) ([]DailyCount, error) { - cardWhere := "deleted_at IS NULL" - if requireNonNull { - cardWhere += " AND " + dateCol + " IS NOT NULL AND " + dateCol + " != ''" - } - cardWhere += " AND " + dateCol + ">=? AND " + dateCol + "<=?" - args := []any{fromStr, toStr} - if assignee != "" { - cardWhere += " AND assignee_id=?" - args = append(args, assignee) - } - if requester != "" { - cardWhere += " AND requester=?" - args = append(args, requester) - } - q := `SELECT substr(` + dateCol + `, 1, 10) as d, COUNT(*) FROM cards WHERE ` + cardWhere + ` GROUP BY d ORDER BY d` - rows, err := db.conn.Query(q, args...) - if err != nil { - return nil, err - } - defer rows.Close() - out := []DailyCount{} - for rows.Next() { - var dc DailyCount - if err := rows.Scan(&dc.Date, &dc.Count); err != nil { - return nil, err - } - out = append(out, dc) - } - return out, rows.Err() -} diff --git a/backend/stickers_test.go b/backend/stickers_test.go deleted file mode 100644 index 43587a6..0000000 --- a/backend/stickers_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package main - -import ( - "encoding/json" - "testing" -) - -func TestUpdateStickers_PersistsAndRoundTrips(t *testing.T) { - db := setupTestDB(t) - col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column) - card := executeTool(db, "create_card", mustJSON(t, map[string]string{"column_id": col.ID, "title": "T"})).Result.(*Card) - - if card.Stickers == nil || len(card.Stickers) != 0 { - t.Fatalf("expected empty stickers on new card, got %+v", card.Stickers) - } - - stickers := []Sticker{ - {Emoji: "🔥", X: 0.25, Y: 0.5}, - {Emoji: "✅", X: 0.9, Y: 0.1}, - } - if err := db.UpdateStickers(card.ID, stickers); err != nil { - t.Fatalf("UpdateStickers: %v", err) - } - - cards, err := db.ListCardsWithTime() - if err != nil { - t.Fatalf("ListCardsWithTime: %v", err) - } - if len(cards) != 1 { - t.Fatalf("expected 1 card, got %d", len(cards)) - } - got := cards[0].Stickers - if len(got) != 2 || got[0].Emoji != "🔥" || got[1].Emoji != "✅" { - t.Fatalf("sticker round-trip failed: %+v", got) - } - if got[0].X != 0.25 || got[0].Y != 0.5 { - t.Fatalf("coords lost: %+v", got[0]) - } -} - -func TestUpdateStickers_ClampAndDropEmpty(t *testing.T) { - db := setupTestDB(t) - col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column) - card := executeTool(db, "create_card", mustJSON(t, map[string]string{"column_id": col.ID, "title": "T"})).Result.(*Card) - - in := []Sticker{ - {Emoji: " 🚀 ", X: -0.5, Y: 1.5}, - {Emoji: "", X: 0.5, Y: 0.5}, - {Emoji: "💀", X: 0.3, Y: 0.7}, - } - if err := db.UpdateStickers(card.ID, in); err != nil { - t.Fatalf("UpdateStickers: %v", err) - } - cards, _ := db.ListCardsWithTime() - got := cards[0].Stickers - if len(got) != 2 { - t.Fatalf("expected empty emoji dropped, got %+v", got) - } - if got[0].Emoji != "🚀" || got[0].X != 0 || got[0].Y != 1 { - t.Fatalf("clamp failed: %+v", got[0]) - } - if got[1].Emoji != "💀" { - t.Fatalf("expected 💀 second, got %+v", got[1]) - } -} - -func TestUpdateStickers_OverwriteAndClear(t *testing.T) { - db := setupTestDB(t) - col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column) - card := executeTool(db, "create_card", mustJSON(t, map[string]string{"column_id": col.ID, "title": "T"})).Result.(*Card) - - if err := db.UpdateStickers(card.ID, []Sticker{{Emoji: "🔥", X: 0.5, Y: 0.5}}); err != nil { - t.Fatalf("set: %v", err) - } - if err := db.UpdateStickers(card.ID, []Sticker{}); err != nil { - t.Fatalf("clear: %v", err) - } - cards, _ := db.ListCardsWithTime() - if len(cards[0].Stickers) != 0 { - t.Fatalf("expected cleared, got %+v", cards[0].Stickers) - } -} - -func TestSticker_JSONShape(t *testing.T) { - s := Sticker{Emoji: "🎯", X: 0.1, Y: 0.2} - b, err := json.Marshal(s) - if err != nil { - t.Fatalf("marshal: %v", err) - } - want := `{"emoji":"🎯","x":0.1,"y":0.2}` - if string(b) != want { - t.Fatalf("got %s want %s", b, want) - } -} diff --git a/backend/tools.go b/backend/tools.go deleted file mode 100644 index 68d6896..0000000 --- a/backend/tools.go +++ /dev/null @@ -1,355 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "sort" - "strings" -) - -// ToolResult is the uniform shape returned to the chat loop after a tool call. -type ToolResult struct { - OK bool `json:"ok"` - Result any `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -func okResult(v any) ToolResult { return ToolResult{OK: true, Result: v} } -func errResult(err error) ToolResult { return ToolResult{OK: false, Error: err.Error()} } -func errMsg(msg string) ToolResult { return ToolResult{OK: false, Error: msg} } - -// executeTool dispatches a tool by name with raw JSON input and returns a ToolResult. -// Tools that mutate the board return ok=true on success; read-only tools include their data in result. -func executeTool(db *DB, name string, input json.RawMessage) ToolResult { - switch name { - case "list_board": - return toolListBoard(db) - case "create_column": - return toolCreateColumn(db, input) - case "update_column": - return toolUpdateColumn(db, input) - case "rename_column": // alias for backwards compat - return toolUpdateColumn(db, input) - case "delete_column": - return toolDeleteColumn(db, input) - case "reorder_columns": - return toolReorderColumns(db, input) - case "create_card": - return toolCreateCard(db, input) - case "update_card": - return toolUpdateCard(db, input) - case "delete_card": - return toolDeleteCard(db, input) - case "move_card": - return toolMoveCard(db, input) - case "card_history": - return toolCardHistory(db, input) - case "find_cards": - return toolFindCards(db, input) - case "list_users": - return toolListUsers(db) - case "assign_card": - return toolAssignCard(db, input) - default: - return errMsg("unknown tool: " + name) - } -} - -// toolMutates reports whether a successful invocation modifies the board state. -func toolMutates(name string) bool { - switch name { - case "create_column", "update_column", "rename_column", "delete_column", "reorder_columns", - "create_card", "update_card", "delete_card", "move_card", "assign_card": - return true - } - return false -} - -func toolListBoard(db *DB) ToolResult { - cols, err := db.ListColumns() - if err != nil { - return errResult(err) - } - cards, err := db.ListCardsWithTime() - if err != nil { - return errResult(err) - } - return okResult(map[string]any{"columns": cols, "cards": cards}) -} - -func toolCreateColumn(db *DB, input json.RawMessage) ToolResult { - var in struct{ Name string `json:"name"` } - if err := json.Unmarshal(input, &in); err != nil { - return errResult(err) - } - if strings.TrimSpace(in.Name) == "" { - return errMsg("name required") - } - c, err := db.CreateColumn(in.Name) - if err != nil { - return errResult(err) - } - return okResult(c) -} - -func toolUpdateColumn(db *DB, input json.RawMessage) ToolResult { - var in struct { - ID string `json:"id"` - Name *string `json:"name"` - Location *string `json:"location"` - Width *int `json:"width"` - WIPLimit *int `json:"wip_limit"` - IsDone *bool `json:"is_done"` - } - if err := json.Unmarshal(input, &in); err != nil { - return errResult(err) - } - if in.ID == "" { - return errMsg("id required") - } - if in.Name == nil && in.Location == nil && in.Width == nil && in.WIPLimit == nil && in.IsDone == nil { - return errMsg("at least one of name/location/width/wip_limit/is_done required") - } - if err := db.UpdateColumn(in.ID, ColumnPatch{Name: in.Name, Location: in.Location, Width: in.Width, WIPLimit: in.WIPLimit, IsDone: in.IsDone}); err != nil { - return errResult(err) - } - return okResult(nil) -} - -func toolDeleteColumn(db *DB, input json.RawMessage) ToolResult { - var in struct{ ID string } - if err := json.Unmarshal(input, &in); err != nil { - return errResult(err) - } - if in.ID == "" { - return errMsg("id required") - } - if err := db.DeleteColumn(in.ID); err != nil { - return errResult(err) - } - return okResult(nil) -} - -func toolReorderColumns(db *DB, input json.RawMessage) ToolResult { - var in struct{ IDs []string } - if err := json.Unmarshal(input, &in); err != nil { - return errResult(err) - } - if len(in.IDs) == 0 { - return errMsg("ids required") - } - if err := db.ReorderColumns(in.IDs); err != nil { - return errResult(err) - } - return okResult(nil) -} - -func toolCreateCard(db *DB, input json.RawMessage) ToolResult { - var in struct { - ColumnID string `json:"column_id"` - Requester string `json:"requester"` - Title string `json:"title"` - Description string `json:"description"` - } - if err := json.Unmarshal(input, &in); err != nil { - return errResult(err) - } - if in.ColumnID == "" || strings.TrimSpace(in.Title) == "" { - return errMsg("column_id and title required") - } - c, err := db.CreateCard(in.ColumnID, in.Requester, in.Title, in.Description, "") - if err != nil { - return errResult(err) - } - return okResult(c) -} - -func toolUpdateCard(db *DB, input json.RawMessage) ToolResult { - var raw map[string]any - if err := json.Unmarshal(input, &raw); err != nil { - return errResult(err) - } - id, _ := raw["id"].(string) - if id == "" { - return errMsg("id required") - } - patch := CardPatch{} - if v, ok := raw["requester"].(string); ok { - patch.Requester = &v - } - if v, ok := raw["title"].(string); ok { - patch.Title = &v - } - if v, ok := raw["description"].(string); ok { - patch.Description = &v - } - if v, ok := raw["color"].(string); ok { - patch.Color = &v - } - if v, ok := raw["locked"].(bool); ok { - patch.Locked = &v - } - if v, present := raw["assignee_id"]; present { - patch.HasAssignee = true - if v == nil { - empty := "" - patch.AssigneeID = &empty - } else if s, ok := v.(string); ok { - patch.AssigneeID = &s - } - } - if err := db.UpdateCard(id, patch); err != nil { - return errResult(err) - } - return okResult(nil) -} - -func toolListUsers(db *DB) ToolResult { - users, err := db.ListUsers() - if err != nil { - return errResult(err) - } - return okResult(users) -} - -func toolAssignCard(db *DB, input json.RawMessage) ToolResult { - var in struct { - ID string `json:"id"` - AssigneeID *string `json:"assignee_id"` - } - if err := json.Unmarshal(input, &in); err != nil { - return errResult(err) - } - if in.ID == "" { - return errMsg("id required") - } - patch := CardPatch{HasAssignee: true} - if in.AssigneeID == nil { - empty := "" - patch.AssigneeID = &empty - } else { - patch.AssigneeID = in.AssigneeID - } - if err := db.UpdateCard(in.ID, patch); err != nil { - return errResult(err) - } - return okResult(nil) -} - -func toolDeleteCard(db *DB, input json.RawMessage) ToolResult { - var in struct{ ID string } - if err := json.Unmarshal(input, &in); err != nil { - return errResult(err) - } - if in.ID == "" { - return errMsg("id required") - } - if err := db.DeleteCard(in.ID); err != nil { - return errResult(err) - } - return okResult(nil) -} - -// toolMoveCard accepts {id, column_id, ordered_ids?}. If ordered_ids is missing, -// the card is appended to the end of the destination column. -func toolMoveCard(db *DB, input json.RawMessage) ToolResult { - var in struct { - ID string `json:"id"` - ColumnID string `json:"column_id"` - OrderedIDs []string `json:"ordered_ids"` - } - if err := json.Unmarshal(input, &in); err != nil { - return errResult(err) - } - if in.ID == "" || in.ColumnID == "" { - return errMsg("id and column_id required") - } - if len(in.OrderedIDs) == 0 { - cards, err := db.ListCardsWithTime() - if err != nil { - return errResult(err) - } - var dest []Card - for _, c := range cards { - if c.ColumnID == in.ColumnID && c.ID != in.ID { - dest = append(dest, c) - } - } - sort.Slice(dest, func(i, j int) bool { return dest[i].Position < dest[j].Position }) - ids := make([]string, 0, len(dest)+1) - for _, c := range dest { - ids = append(ids, c.ID) - } - ids = append(ids, in.ID) - in.OrderedIDs = ids - } - if err := db.MoveCard(in.ID, in.ColumnID, in.OrderedIDs, ""); err != nil { - return errResult(err) - } - return okResult(nil) -} - -func toolCardHistory(db *DB, input json.RawMessage) ToolResult { - var in struct{ ID string } - if err := json.Unmarshal(input, &in); err != nil { - return errResult(err) - } - if in.ID == "" { - return errMsg("id required") - } - hist, err := db.CardHistory(in.ID) - if err != nil { - return errResult(err) - } - return okResult(hist) -} - -func toolFindCards(db *DB, input json.RawMessage) ToolResult { - var in struct { - Query string `json:"query"` - ColumnID string `json:"column_id"` - Requester string `json:"requester"` - } - if err := json.Unmarshal(input, &in); err != nil { - return errResult(err) - } - cards, err := db.ListCardsWithTime() - if err != nil { - return errResult(err) - } - q := strings.ToLower(strings.TrimSpace(in.Query)) - col := in.ColumnID - req := strings.ToLower(strings.TrimSpace(in.Requester)) - out := make([]Card, 0, len(cards)) - for _, c := range cards { - if col != "" && c.ColumnID != col { - continue - } - if req != "" && !strings.Contains(strings.ToLower(c.Requester), req) { - continue - } - if q != "" { - hay := strings.ToLower(c.Title + " " + c.Description + " " + c.Requester) - if !strings.Contains(hay, q) { - continue - } - } - out = append(out, c) - } - return okResult(out) -} - -// validateToolName fails fast with clearer error than the dispatch's default. -func validateToolName(name string) error { - known := map[string]bool{ - "list_board": true, "create_column": true, "update_column": true, "rename_column": true, - "delete_column": true, "reorder_columns": true, "create_card": true, - "update_card": true, "delete_card": true, "move_card": true, - "card_history": true, "find_cards": true, - "list_users": true, "assign_card": true, - } - if !known[name] { - return fmt.Errorf("unknown tool: %s", name) - } - return nil -} diff --git a/backend/tools_test.go b/backend/tools_test.go deleted file mode 100644 index 8d50f31..0000000 --- a/backend/tools_test.go +++ /dev/null @@ -1,399 +0,0 @@ -package main - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" -) - -// setupTestDB creates a temporary kanban DB for the duration of the test. -func setupTestDB(t *testing.T) *DB { - t.Helper() - dir := t.TempDir() - dbPath := filepath.Join(dir, "test_operations.db") - db, err := openDB(dbPath) - if err != nil { - t.Fatalf("openDB: %v", err) - } - t.Cleanup(func() { db.Close() }) - return db -} - -func mustJSON(t *testing.T, v any) json.RawMessage { - t.Helper() - b, err := json.Marshal(v) - if err != nil { - t.Fatalf("marshal: %v", err) - } - return b -} - -func mustOK(t *testing.T, res ToolResult) { - t.Helper() - if !res.OK { - t.Fatalf("expected ok, got error: %s", res.Error) - } -} - -func mustErr(t *testing.T, res ToolResult, contains string) { - t.Helper() - if res.OK { - t.Fatalf("expected error, got ok with result: %v", res.Result) - } - if contains != "" && !strings.Contains(res.Error, contains) { - t.Fatalf("error %q does not contain %q", res.Error, contains) - } -} - -// --- list_board --- - -func TestExecuteTool_ListBoard_Empty(t *testing.T) { - db := setupTestDB(t) - res := executeTool(db, "list_board", json.RawMessage(`{}`)) - mustOK(t, res) - board, ok := res.Result.(map[string]any) - if !ok { - t.Fatalf("expected map[string]any, got %T", res.Result) - } - cols := board["columns"].([]Column) - cards := board["cards"].([]Card) - if len(cols) != 0 || len(cards) != 0 { - t.Fatalf("expected empty board, got %d cols %d cards", len(cols), len(cards)) - } -} - -// --- create_column / rename_column / delete_column / reorder_columns --- - -func TestExecuteTool_CreateColumn(t *testing.T) { - db := setupTestDB(t) - res := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Backlog"})) - mustOK(t, res) - col := res.Result.(*Column) - if col.Name != "Backlog" || col.Position != 0 || col.ID == "" { - t.Fatalf("unexpected column: %+v", col) - } -} - -func TestExecuteTool_CreateColumn_EmptyName(t *testing.T) { - db := setupTestDB(t) - res := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": " "})) - mustErr(t, res, "name required") -} - -func TestExecuteTool_UpdateColumn_Name(t *testing.T) { - db := setupTestDB(t) - created := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Old"})) - col := created.Result.(*Column) - - res := executeTool(db, "update_column", mustJSON(t, map[string]string{"id": col.ID, "name": "New"})) - mustOK(t, res) - - cols, _ := db.ListColumns() - if cols[0].Name != "New" { - t.Fatalf("rename failed: %s", cols[0].Name) - } -} - -func TestExecuteTool_UpdateColumn_LocationAndWidth(t *testing.T) { - db := setupTestDB(t) - col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column) - - loc := "sidebar" - width := 450 - res := executeTool(db, "update_column", mustJSON(t, map[string]any{"id": col.ID, "location": loc, "width": width})) - mustOK(t, res) - - cols, _ := db.ListColumns() - if cols[0].Location != "sidebar" || cols[0].Width != 450 { - t.Fatalf("update failed: %+v", cols[0]) - } -} - -func TestExecuteTool_UpdateColumn_NoFields(t *testing.T) { - db := setupTestDB(t) - col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column) - res := executeTool(db, "update_column", mustJSON(t, map[string]string{"id": col.ID})) - mustErr(t, res, "at least one") -} - -func TestExecuteTool_RenameColumn_AliasStillWorks(t *testing.T) { - db := setupTestDB(t) - col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Old"})).Result.(*Column) - res := executeTool(db, "rename_column", mustJSON(t, map[string]string{"id": col.ID, "name": "New"})) - mustOK(t, res) - cols, _ := db.ListColumns() - if cols[0].Name != "New" { - t.Fatalf("alias rename failed") - } -} - -func TestExecuteTool_DeleteColumn(t *testing.T) { - db := setupTestDB(t) - created := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Tmp"})) - col := created.Result.(*Column) - - res := executeTool(db, "delete_column", mustJSON(t, map[string]string{"id": col.ID})) - mustOK(t, res) - - cols, _ := db.ListColumns() - if len(cols) != 0 { - t.Fatalf("expected 0 cols after delete, got %d", len(cols)) - } -} - -func TestExecuteTool_ReorderColumns(t *testing.T) { - db := setupTestDB(t) - a := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "A"})).Result.(*Column) - b := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "B"})).Result.(*Column) - c := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "C"})).Result.(*Column) - - res := executeTool(db, "reorder_columns", mustJSON(t, map[string][]string{"ids": {c.ID, a.ID, b.ID}})) - mustOK(t, res) - - cols, _ := db.ListColumns() - got := []string{cols[0].Name, cols[1].Name, cols[2].Name} - want := []string{"C", "A", "B"} - for i := range want { - if got[i] != want[i] { - t.Fatalf("reorder mismatch at %d: want %s got %s", i, want[i], got[i]) - } - } -} - -// --- create_card / update_card / delete_card / move_card --- - -func TestExecuteTool_CreateCard_AndRequester(t *testing.T) { - db := setupTestDB(t) - col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Todo"})).Result.(*Column) - - res := executeTool(db, "create_card", mustJSON(t, map[string]string{ - "column_id": col.ID, - "requester": "Lucas", - "title": "Buy milk", - "description": "Whole milk", - })) - mustOK(t, res) - card := res.Result.(*Card) - if card.Requester != "Lucas" || card.Title != "Buy milk" || card.ColumnID != col.ID { - t.Fatalf("unexpected card: %+v", card) - } -} - -func TestExecuteTool_CreateCard_MissingTitle(t *testing.T) { - db := setupTestDB(t) - col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column) - res := executeTool(db, "create_card", mustJSON(t, map[string]string{ - "column_id": col.ID, - "title": "", - })) - mustErr(t, res, "required") -} - -func TestExecuteTool_UpdateCard(t *testing.T) { - db := setupTestDB(t) - col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column) - card := executeTool(db, "create_card", mustJSON(t, map[string]string{ - "column_id": col.ID, - "requester": "A", - "title": "T1", - })).Result.(*Card) - - newTitle := "T2" - newReq := "B" - color := "violet" - res := executeTool(db, "update_card", mustJSON(t, map[string]any{ - "id": card.ID, - "title": newTitle, - "requester": newReq, - "color": color, - })) - mustOK(t, res) - - cards, _ := db.ListCardsWithTime() - if cards[0].Title != "T2" || cards[0].Requester != "B" || cards[0].Color != "violet" { - t.Fatalf("unexpected card after update: %+v", cards[0]) - } -} - -func TestExecuteTool_DeleteCard(t *testing.T) { - db := setupTestDB(t) - col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column) - card := executeTool(db, "create_card", mustJSON(t, map[string]string{ - "column_id": col.ID, - "title": "T", - })).Result.(*Card) - - res := executeTool(db, "delete_card", mustJSON(t, map[string]string{"id": card.ID})) - mustOK(t, res) - - cards, _ := db.ListCardsWithTime() - if len(cards) != 0 { - t.Fatalf("expected 0 cards, got %d", len(cards)) - } -} - -func TestExecuteTool_MoveCard_BetweenColumns_OpensHistory(t *testing.T) { - db := setupTestDB(t) - src := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Src"})).Result.(*Column) - dst := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Dst"})).Result.(*Column) - card := executeTool(db, "create_card", mustJSON(t, map[string]string{ - "column_id": src.ID, - "title": "Move me", - })).Result.(*Card) - - res := executeTool(db, "move_card", mustJSON(t, map[string]any{ - "id": card.ID, - "column_id": dst.ID, - })) - mustOK(t, res) - - cards, _ := db.ListCardsWithTime() - if cards[0].ColumnID != dst.ID { - t.Fatalf("card not moved, still in %s", cards[0].ColumnID) - } - - histRes := executeTool(db, "card_history", mustJSON(t, map[string]string{"id": card.ID})) - mustOK(t, histRes) - hist := histRes.Result.([]HistoryEntry) - if len(hist) != 2 { - t.Fatalf("expected 2 history entries, got %d", len(hist)) - } - if hist[0].ExitedAt == nil { - t.Fatalf("first entry should be closed") - } - if hist[1].ExitedAt != nil { - t.Fatalf("second entry should be open") - } -} - -func TestExecuteTool_MoveCard_RequiresIDAndColumn(t *testing.T) { - db := setupTestDB(t) - res := executeTool(db, "move_card", mustJSON(t, map[string]string{"id": ""})) - mustErr(t, res, "required") -} - -// --- card_history --- - -func TestExecuteTool_CardHistory_Single(t *testing.T) { - db := setupTestDB(t) - col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column) - card := executeTool(db, "create_card", mustJSON(t, map[string]string{ - "column_id": col.ID, - "title": "T", - })).Result.(*Card) - - res := executeTool(db, "card_history", mustJSON(t, map[string]string{"id": card.ID})) - mustOK(t, res) - hist := res.Result.([]HistoryEntry) - if len(hist) != 1 || hist[0].ExitedAt != nil { - t.Fatalf("expected 1 open history entry, got %+v", hist) - } -} - -// --- find_cards --- - -func TestExecuteTool_FindCards_FilterByQueryRequesterColumn(t *testing.T) { - db := setupTestDB(t) - col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column) - col2 := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Y"})).Result.(*Column) - executeTool(db, "create_card", mustJSON(t, map[string]string{"column_id": col.ID, "requester": "Lucas", "title": "Bug fix"})) - executeTool(db, "create_card", mustJSON(t, map[string]string{"column_id": col.ID, "requester": "Ana", "title": "Feature x"})) - executeTool(db, "create_card", mustJSON(t, map[string]string{"column_id": col2.ID, "requester": "Lucas", "title": "Refactor"})) - - // query - r := executeTool(db, "find_cards", mustJSON(t, map[string]string{"query": "fix"})) - mustOK(t, r) - cards := r.Result.([]Card) - if len(cards) != 1 || cards[0].Title != "Bug fix" { - t.Fatalf("query filter failed: %+v", cards) - } - - // requester - r = executeTool(db, "find_cards", mustJSON(t, map[string]string{"requester": "Lucas"})) - cards = r.Result.([]Card) - if len(cards) != 2 { - t.Fatalf("requester filter expected 2 got %d", len(cards)) - } - - // column - r = executeTool(db, "find_cards", mustJSON(t, map[string]string{"column_id": col2.ID})) - cards = r.Result.([]Card) - if len(cards) != 1 || cards[0].ColumnID != col2.ID { - t.Fatalf("column filter failed: %+v", cards) - } - - // combined - r = executeTool(db, "find_cards", mustJSON(t, map[string]any{"requester": "Lucas", "column_id": col.ID})) - cards = r.Result.([]Card) - if len(cards) != 1 || cards[0].Title != "Bug fix" { - t.Fatalf("combined filter failed: %+v", cards) - } -} - -// --- unknown tool --- - -func TestExecuteTool_Unknown(t *testing.T) { - db := setupTestDB(t) - res := executeTool(db, "no_such_tool", json.RawMessage(`{}`)) - mustErr(t, res, "unknown tool") -} - -// --- chat logger --- - -func TestChatLogger_AppendsJSONLines(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "chat.log") - logger := newChatLogger(path) - - logger.Log("create_column", json.RawMessage(`{"name":"A"}`), ToolResult{OK: true, Result: &Column{ID: "abc", Name: "A"}}) - logger.Log("delete_card", json.RawMessage(`{"id":"x"}`), ToolResult{OK: false, Error: "card not found"}) - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read log: %v", err) - } - lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") - if len(lines) != 2 { - t.Fatalf("expected 2 log lines, got %d", len(lines)) - } - for i, line := range lines { - var entry ChatLogEntry - if err := json.Unmarshal([]byte(line), &entry); err != nil { - t.Fatalf("line %d not valid JSON: %v\n%s", i, err, line) - } - if entry.TS == "" { - t.Fatalf("line %d missing TS", i) - } - } - - var first, second ChatLogEntry - json.Unmarshal([]byte(lines[0]), &first) - json.Unmarshal([]byte(lines[1]), &second) - if first.Tool != "create_column" || !first.OK { - t.Fatalf("unexpected first entry: %+v", first) - } - if second.Tool != "delete_card" || second.OK || second.Error != "card not found" { - t.Fatalf("unexpected second entry: %+v", second) - } -} - -// --- toolMutates --- - -func TestToolMutates(t *testing.T) { - mutating := []string{"create_column", "update_column", "rename_column", "delete_column", "reorder_columns", - "create_card", "update_card", "delete_card", "move_card"} - readonly := []string{"list_board", "card_history", "find_cards"} - - for _, n := range mutating { - if !toolMutates(n) { - t.Errorf("expected %s to mutate", n) - } - } - for _, n := range readonly { - if toolMutates(n) { - t.Errorf("expected %s to be read-only", n) - } - } -} diff --git a/backend/users.go b/backend/users.go deleted file mode 100644 index b6b49ce..0000000 --- a/backend/users.go +++ /dev/null @@ -1,129 +0,0 @@ -package main - -import ( - "database/sql" - "errors" - "fmt" - "strings" - - "fn-registry/functions/infra" -) - -type User struct { - ID string `json:"id"` - Username string `json:"username"` - DisplayName string `json:"display_name"` - Color string `json:"color"` - CreatedAt string `json:"created_at"` -} - -var ( - errUserNotFound = errors.New("user not found") - errUserAlreadyExists = errors.New("username already exists") - errInvalidCredentials = errors.New("invalid credentials") -) - -func (db *DB) CreateUser(username, password, displayName string) (*User, error) { - username = strings.TrimSpace(strings.ToLower(username)) - if username == "" { - return nil, fmt.Errorf("username required") - } - if len(password) < 4 { - return nil, fmt.Errorf("password must be at least 4 characters") - } - hash, err := infra.PasswordHash(password, 0) - if err != nil { - return nil, fmt.Errorf("hash: %w", err) - } - u := User{ID: newID(), Username: username, DisplayName: displayName, CreatedAt: nowRFC3339()} - _, err = db.conn.Exec( - `INSERT INTO users (id, username, password_hash, display_name, created_at) VALUES (?, ?, ?, ?, ?)`, - u.ID, u.Username, hash, u.DisplayName, u.CreatedAt, - ) - if err != nil { - if strings.Contains(err.Error(), "UNIQUE") { - return nil, errUserAlreadyExists - } - return nil, err - } - return &u, nil -} - -func (db *DB) GetUserByID(id string) (*User, error) { - var u User - err := db.conn.QueryRow( - `SELECT id, username, display_name, color, created_at FROM users WHERE id=?`, id, - ).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Color, &u.CreatedAt) - if errors.Is(err, sql.ErrNoRows) { - return nil, errUserNotFound - } - if err != nil { - return nil, err - } - return &u, nil -} - -func (db *DB) GetUserByUsername(username string) (*User, string, error) { - username = strings.TrimSpace(strings.ToLower(username)) - var u User - var hash string - err := db.conn.QueryRow( - `SELECT id, username, display_name, color, created_at, password_hash FROM users WHERE username=?`, username, - ).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Color, &u.CreatedAt, &hash) - if errors.Is(err, sql.ErrNoRows) { - return nil, "", errUserNotFound - } - if err != nil { - return nil, "", err - } - return &u, hash, nil -} - -func (db *DB) ListUsers() ([]User, error) { - rows, err := db.conn.Query(`SELECT id, username, display_name, color, created_at FROM users ORDER BY username`) - if err != nil { - return nil, err - } - defer rows.Close() - out := []User{} - for rows.Next() { - var u User - if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Color, &u.CreatedAt); err != nil { - return nil, err - } - out = append(out, u) - } - return out, rows.Err() -} - -func (db *DB) Authenticate(username, password string) (*User, error) { - u, hash, err := db.GetUserByUsername(username) - if err != nil { - if errors.Is(err, errUserNotFound) { - return nil, errInvalidCredentials - } - return nil, err - } - if err := infra.PasswordVerify(password, hash); err != nil { - return nil, errInvalidCredentials - } - return u, nil -} - -func (db *DB) CountUsers() (int, error) { - var n int - if err := db.conn.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n); err != nil { - return 0, err - } - return n, nil -} - -func (db *DB) UpdateUserColor(id, color string) error { - _, err := db.conn.Exec(`UPDATE users SET color=? WHERE id=?`, color, id) - return err -} - -func (db *DB) DeleteSessionByToken(token string) error { - _, err := db.conn.Exec(`DELETE FROM sessions WHERE token=?`, token) - return err -}