feat: initial scaffold kanban_cpp v0.1.0

C++ ImGui kanban for steering LLM agents. Six panels (Board, Calendar,
Dashboard, Agent runs, Worktrees, DoD inspector) wired to registry
functions http_request, kpi_card, sparkline, agent_runs_timeline,
dod_evidence_panel. Backend Go on :8403 (independent operations.db from
kanban_web).
This commit is contained in:
Egutierrez
2026-05-18 18:46:09 +02:00
commit a76ec74338
42 changed files with 5922 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
build/
*.exe
*.log
backend/operations.db
backend/operations.db-shm
backend/operations.db-wal
backend/kanban_cpp_backend
backend/dist/*
!backend/dist/.gitkeep
local_files/
imgui.ini
app_settings.ini
+24
View File
@@ -0,0 +1,24 @@
add_imgui_app(kanban_cpp
main.cpp
data.cpp
panel_board.cpp
panel_calendar.cpp
panel_dashboard.cpp
panel_agent_runs.cpp
panel_worktrees.cpp
panel_dod.cpp
# Registry functions consumed (see app.md::uses_functions)
${CMAKE_SOURCE_DIR}/functions/core/http_request.cpp
${CMAKE_SOURCE_DIR}/functions/viz/kpi_card.cpp
${CMAKE_SOURCE_DIR}/functions/viz/sparkline.cpp
${CMAKE_SOURCE_DIR}/functions/viz/agent_runs_timeline.cpp
${CMAKE_SOURCE_DIR}/functions/viz/agent_runs_timeline_helpers.cpp
${CMAKE_SOURCE_DIR}/functions/viz/dod_evidence_panel.cpp
${CMAKE_SOURCE_DIR}/functions/viz/dod_evidence_panel_helpers.cpp
)
target_include_directories(kanban_cpp PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
if(WIN32)
target_link_libraries(kanban_cpp PRIVATE ws2_32)
set_target_properties(kanban_cpp PROPERTIES WIN32_EXECUTABLE TRUE)
endif()
+77
View File
@@ -0,0 +1,77 @@
---
name: kanban_cpp
lang: cpp
domain: tools
version: 0.1.0
description: "Clon C++ ImGui de kanban_web — tablero pensado para conducir agentes LLM con DoD evidence"
tags: [kanban, cpp, agents, imgui]
icon:
phosphor: "columns"
accent: "#a855f7"
uses_functions:
- http_request_cpp_core
- dod_evidence_panel_cpp_viz
- agent_runs_timeline_cpp_viz
- kpi_card_cpp_viz
- sparkline_cpp_viz
uses_types: []
framework: "imgui"
entry_point: "main.cpp"
dir_path: "apps/kanban_cpp"
repo_url: "https://gitea.organic-machine.com/dataforge/kanban_cpp"
e2e_checks:
- id: build
cmd: "cmake --build cpp/build/linux --target kanban_cpp -j"
timeout_s: 300
- id: self_test
cmd: "./cpp/build/linux/apps/kanban_cpp/kanban_cpp --self-test"
timeout_s: 30
- id: backend_build
cmd: "cd apps/kanban_cpp/backend && CGO_ENABLED=1 go build -tags fts5 -o kanban_cpp_backend ."
timeout_s: 180
---
# kanban_cpp
Clon C++ ImGui de kanban_web — tablero pensado para conducir agentes LLM con DoD evidence.
Backend Go propio en `backend/` (puerto 8403 por defecto) con `operations.db` independiente del kanban_web original. NO sincroniza datos con `apps/kanban` a proposito.
## Panels
| Panel | Funcion del registry | Notas |
|---|---|---|
| Board | inline | columnas + cards, drag con ImGui::IsItemActive |
| Calendar | inline | vista mensual estatica (MVP) |
| Dashboard | `kpi_card_cpp_viz` + `sparkline_cpp_viz` | KPIs (total, by_status, by_priority) |
| Agent runs | `agent_runs_timeline_cpp_viz` | populated por HTTP poll a agent_runner_api:8486 |
| Worktrees | inline | `git worktree list --porcelain` via popen |
| DoD inspector | `dod_evidence_panel_cpp_viz` | inspecciona DoD items + evidencias |
## Build
```bash
# Backend
cd apps/kanban_cpp/backend && CGO_ENABLED=1 go build -tags fts5 -o kanban_cpp_backend .
./kanban_cpp_backend --port 8403 --db operations.db
# Frontend ImGui
cd cpp && cmake -B build/linux && cmake --build build/linux --target kanban_cpp -j
./build/linux/apps/kanban_cpp/kanban_cpp
```
## Cuando usarla
Cuando quieras un kanban dedicado a conducir agentes LLM (arrastrar card a `Doing (agent)` → arranca workflow) sin abrir browser. Para uso humano puro, `kanban_web` (Mantine) sigue siendo mejor.
## Gotchas
- 2 services + 2 sqlite locks: kanban_web :8095/8401 y kanban_cpp :8403 NUNCA comparten `operations.db`.
- `agent_runner_api` (puerto 8486) puede no estar corriendo — el panel `Agent runs` muestra `connection_status="disconnected"` en ese caso. No bloquea el resto de paneles.
- Calendar es MVP estatico — TODO: integrarlo con cards filtradas por `due_date`.
- Dashboard usa datos sinteticos hasta wire-up del backend stats endpoint (TODO).
- Auth: cada app tiene sus propios usuarios. NO compartir cookies entre kanban_web y kanban_cpp.
## Capability growth log
(v0.1.0 baseline — sin crecimiento aun)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

+156
View File
@@ -0,0 +1,156 @@
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)
}
}
+269
View File
@@ -0,0 +1,269 @@
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 <board_state> 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<board_state>\n" + board + "\n</board_state>\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__<server>__" 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",
})
}
}
+86
View File
@@ -0,0 +1,86 @@
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)
}
+296
View File
@@ -0,0 +1,296 @@
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)
}
}
+1201
View File
File diff suppressed because it is too large Load Diff
View File
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"encoding/json"
"net/http"
"os"
"fn-registry/functions/infra"
)
type FeatureFlag struct {
Enabled bool `json:"enabled"`
Issue string `json:"issue,omitempty"`
Description string `json:"description"`
Added string `json:"added,omitempty"`
EnabledAt string `json:"enabled_at,omitempty"`
}
type FeatureFlags struct {
Flags map[string]FeatureFlag `json:"flags"`
}
func (f FeatureFlags) Enabled(name string) bool {
flag, ok := f.Flags[name]
return ok && flag.Enabled
}
func loadFeatureFlags(path string) (FeatureFlags, error) {
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return FeatureFlags{Flags: map[string]FeatureFlag{}}, nil
}
return FeatureFlags{}, err
}
var f FeatureFlags
if err := json.Unmarshal(b, &f); err != nil {
return FeatureFlags{}, err
}
if f.Flags == nil {
f.Flags = map[string]FeatureFlag{}
}
return f, nil
}
// GET /api/flags → { "<name>": true/false, ... }
func handleListFlags(flags *FeatureFlags) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
out := make(map[string]bool, len(flags.Flags))
for name, fl := range flags.Flags {
out[name] = fl.Enabled
}
infra.HTTPJSONResponse(w, http.StatusOK, out)
}
}
+49
View File
@@ -0,0 +1,49 @@
module kanban
go 1.25.0
require fn-registry v0.0.0-00010101000000-000000000000
require (
github.com/ClickHouse/ch-go v0.71.0 // indirect
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/apache/arrow-go/v18 v18.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/flatbuffers v25.1.24+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/marcboeker/go-duckdb v1.8.5 // indirect
github.com/mattn/go-sqlite3 v1.14.37 // indirect
github.com/paulmach/orb v0.12.0 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
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
)
replace fn-registry => ../../..
+176
View File
@@ -0,0 +1,176 @@
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ=
github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y=
github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0=
github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE=
github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLKOaJN/AC6puCca6Jm7o=
github.com/google/flatbuffers v25.1.24+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0=
github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0=
gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
+474
View File
@@ -0,0 +1,474 @@
package main
import (
"net/http"
"strings"
"fn-registry/functions/infra"
)
const maxBodyBytes = 1 << 20 // 1 MiB
func badRequest(w http.ResponseWriter, msg string) {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusBadRequest, Code: "bad_request", Message: msg})
}
func notFound(w http.ResponseWriter, msg string) {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusNotFound, Code: "not_found", Message: msg})
}
func serverError(w http.ResponseWriter, err error) {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusInternalServerError, Code: "internal", Message: err.Error()})
}
// GET /api/board → { columns: [...], cards: [...] }
func handleGetBoard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cols, err := db.ListColumns()
if err != nil {
serverError(w, err)
return
}
cards, err := db.ListCardsWithTime()
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{
"columns": cols,
"cards": cards,
})
}
}
// 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"` }
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if strings.TrimSpace(body.Name) == "" {
badRequest(w, "name required")
return
}
c, err := db.CreateColumn(body.Name)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusCreated, c)
}
}
// PATCH /api/columns/{id} { name?, position?, location?, width? }
func handleUpdateColumn(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Name *string `json:"name"`
Position *int `json:"position"`
Location *string `json:"location"`
Width *int `json:"width"`
WIPLimit *int `json:"wip_limit"`
IsDone *bool `json:"is_done"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if err := db.UpdateColumn(id, ColumnPatch{Name: body.Name, Position: body.Position, Location: body.Location, Width: body.Width, WIPLimit: body.WIPLimit, IsDone: body.IsDone}); err != nil {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// DELETE /api/columns/{id}
func handleDeleteColumn(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := db.DeleteColumn(id); err != nil {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// 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"` }
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if err := db.ReorderColumns(body.IDs); err != nil {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// POST /api/cards { column_id, requester?, title, description? }
func handleCreateCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body struct {
ColumnID string `json:"column_id"`
Requester string `json:"requester"`
Title string `json:"title"`
Description string `json:"description"`
AssigneeID *string `json:"assignee_id"`
Tags []string `json:"tags"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if body.ColumnID == "" || strings.TrimSpace(body.Title) == "" {
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)
if err == nil && body.AssigneeID != nil && *body.AssigneeID != "" {
err = db.UpdateCardWithActor(c.ID, CardPatch{AssigneeID: body.AssigneeID, HasAssignee: true}, actor)
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)
if err == nil {
c.Tags = tags
}
}
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusCreated, c)
}
}
// PATCH /api/cards/{id} { requester?, title?, description?, color? }
func handleUpdateCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var raw map[string]any
if err := infra.HTTPParseBody(r, &raw, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
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 v, present := raw["deadline"]; present {
patch.HasDeadline = true
if v == nil {
empty := ""
patch.Deadline = &empty
} else if s, ok := v.(string); ok {
patch.Deadline = &s
}
}
if v, present := raw["tags"]; present {
tags := []string{}
if arr, ok := v.([]any); ok {
for _, t := range arr {
if s, ok := t.(string); ok {
tags = append(tags, s)
}
}
}
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 {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// DELETE /api/cards/{id}
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 {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// POST /api/cards/{id}/move { column_id, ordered_ids }
func handleMoveCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
ColumnID string `json:"column_id"`
OrderedIDs []string `json:"ordered_ids"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if body.ColumnID == "" {
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 strings.Contains(err.Error(), "not found") {
notFound(w, "card not found")
return
}
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// 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)
if err != nil {
if strings.Contains(err.Error(), "not found") {
notFound(w, "card not found")
return
}
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusCreated, c)
}
}
// 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) {
cards, err := db.ListDeletedCards()
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, cards)
}
}
// POST /api/cards/{id}/restore
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 {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// DELETE /api/cards/{id}/purge
func handlePurgeCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := db.PurgeCard(id); err != nil {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken string, flags *FeatureFlags) []infra.Route {
return []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)},
{Method: "PATCH", Path: "/api/columns/{id}", Handler: handleUpdateColumn(db)},
{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)},
}
}
func handleListTags(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tags, err := db.ListAllTags()
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, tags)
}
}
func handleListRequesters(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
out, err := db.ListDistinctRequesters()
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, out)
}
}
+60
View File
@@ -0,0 +1,60 @@
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)
}
}
+167
View File
@@ -0,0 +1,167 @@
package main
import (
"context"
"embed"
"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
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)
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:])
featureFlags, err := loadFeatureFlags(*flagsPath)
if err != nil {
log.Fatalf("load feature flags: %v", err)
}
for name, fl := range featureFlags.Flags {
log.Printf("feature flag %q enabled=%v", name, fl.Enabled)
}
db, err := openDB(*dbPath)
if err != nil {
log.Fatalf("open db: %v", err)
}
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", "/health", "/assets/", "/index.html"},
UserCtxKey: userCtxKey,
})
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("database: %s", *dbPath)
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
if err := infra.HTTPServe(addr, handler, ctx); err != nil {
log.Fatalf("server: %v", err)
}
}
// 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)
})
}
}
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")
}
+302
View File
@@ -0,0 +1,302 @@
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
}
+603
View File
@@ -0,0 +1,603 @@
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()
}
+51
View File
@@ -0,0 +1,51 @@
CREATE TABLE IF NOT EXISTS columns (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
location TEXT NOT NULL DEFAULT 'board' CHECK(location IN ('board','sidebar')),
width INTEGER NOT NULL DEFAULT 300,
wip_limit INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS cards (
id TEXT PRIMARY KEY,
requester TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '',
column_id TEXT NOT NULL REFERENCES columns(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
locked INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS card_column_history (
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
column_id TEXT NOT NULL,
entered_at TEXT NOT NULL,
exited_at TEXT
);
CREATE TABLE IF NOT EXISTS card_lock_history (
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
locked_at TEXT NOT NULL,
unlocked_at TEXT
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_cards_column ON cards(column_id);
CREATE INDEX IF NOT EXISTS idx_cards_position ON cards(column_id, position);
CREATE INDEX IF NOT EXISTS idx_history_card ON card_column_history(card_id);
CREATE INDEX IF NOT EXISTS idx_columns_position ON columns(position);
CREATE INDEX IF NOT EXISTS idx_lock_history_card ON card_lock_history(card_id);
+4
View File
@@ -0,0 +1,4 @@
-- Add stickers column to cards. Idempotent ALTER pattern in db.go ensureColumns.
-- Stickers persist as JSON array: [{"emoji":"🔥","x":0.5,"y":0.5}, ...]
-- x, y in [0, 1] relative to card dimensions for resize survival.
ALTER TABLE cards ADD COLUMN stickers TEXT NOT NULL DEFAULT '[]';
@@ -0,0 +1,6 @@
-- Columnas extra de `columns` (location, width, wip_limit, is_done).
-- Antes vivian en ensureColumns Go. Reextraidas a migration por consistencia.
ALTER TABLE columns ADD COLUMN location TEXT NOT NULL DEFAULT 'board';
ALTER TABLE columns ADD COLUMN width INTEGER NOT NULL DEFAULT 300;
ALTER TABLE columns ADD COLUMN wip_limit INTEGER NOT NULL DEFAULT 0;
ALTER TABLE columns ADD COLUMN is_done INTEGER NOT NULL DEFAULT 0;
+9
View File
@@ -0,0 +1,9 @@
-- Columnas extra de `cards` (color, locked, assignee_id, completed_at, deleted_at, tags).
-- Antes vivian en ensureColumns Go. La columna stickers va aparte en 002.
ALTER TABLE cards ADD COLUMN color TEXT NOT NULL DEFAULT '';
ALTER TABLE cards ADD COLUMN locked INTEGER NOT NULL DEFAULT 0;
ALTER TABLE cards ADD COLUMN assignee_id TEXT;
ALTER TABLE cards ADD COLUMN completed_at TEXT;
ALTER TABLE cards ADD COLUMN deleted_at TEXT;
ALTER TABLE cards ADD COLUMN tags TEXT NOT NULL DEFAULT '[]';
CREATE INDEX IF NOT EXISTS idx_cards_assignee ON cards(assignee_id);
+3
View File
@@ -0,0 +1,3 @@
-- actor_id en histories (quien movió la card / quien bloqueó).
ALTER TABLE card_column_history ADD COLUMN actor_id TEXT;
ALTER TABLE card_lock_history ADD COLUMN actor_id TEXT;
+2
View File
@@ -0,0 +1,2 @@
-- Color del avatar del usuario (Mantine color name o '#rrggbb' personalizado).
ALTER TABLE users ADD COLUMN color TEXT NOT NULL DEFAULT '';
+11
View File
@@ -0,0 +1,11 @@
-- Eventos cronologicos por card. Complementa column_history (moves) y lock_history (locks).
-- Captura: created, assigned, unassigned, title_changed, description_changed, color_changed, tags_changed.
CREATE TABLE IF NOT EXISTS card_events (
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
actor_id TEXT,
payload TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_card_events_card ON card_events(card_id, created_at);
+7
View File
@@ -0,0 +1,7 @@
-- ID secuencial humano por card. Distinto del id hex (PK interna).
-- Backfill por orden de creacion.
ALTER TABLE cards ADD COLUMN seq_num INTEGER NOT NULL DEFAULT 0;
UPDATE cards SET seq_num = (
SELECT COUNT(*) FROM cards c2 WHERE c2.created_at <= cards.created_at
) WHERE seq_num = 0;
CREATE UNIQUE INDEX IF NOT EXISTS idx_cards_seq_num ON cards(seq_num) WHERE seq_num > 0;
+4
View File
@@ -0,0 +1,4 @@
-- Deadline opcional por card. Fecha RFC3339 (precision dia o instante).
-- NULL = sin deadline (default). El frontend muestra countdown hasta la fecha.
ALTER TABLE cards ADD COLUMN deadline TEXT;
CREATE INDEX IF NOT EXISTS idx_cards_deadline ON cards(deadline) WHERE deadline IS NOT NULL;
+14
View File
@@ -0,0 +1,14 @@
-- Per-card chat messages (human-to-human comments).
-- Distinct from card_events (which records system events like title_changed)
-- and from /api/chat (which is the board-level LLM chat).
CREATE TABLE IF NOT EXISTS card_messages (
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL,
author_id TEXT,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_card_messages_card ON card_messages(card_id, created_at);
+94
View File
@@ -0,0 +1,94 @@
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)
}
}
+355
View File
@@ -0,0 +1,355 @@
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
}
+399
View File
@@ -0,0 +1,399 @@
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)
}
}
}
+129
View File
@@ -0,0 +1,129 @@
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
}
+174
View File
@@ -0,0 +1,174 @@
// data.cpp — HTTP client implementation for kanban_cpp.
//
// JSON parsing is intentionally manual + permissive: backend is "ours" and
// payload shapes are stable. If we ever need a real parser, swap to nlohmann
// or rapidjson; today the extra dep is not justified (KISS).
#include "data.h"
#include "core/http_request.h"
#include "core/logger.h"
#include <cstring>
#include <cstdio>
namespace kanban_cpp {
namespace {
// Tiny helpers: scan JSON strings out of a raw buffer. NOT a real parser —
// only handles flat-ish payloads our backend emits. Good enough for MVP.
std::string find_str_field(const std::string& s, const std::string& key) {
std::string needle = "\"" + key + "\":";
size_t p = s.find(needle);
if (p == std::string::npos) return "";
p += needle.size();
while (p < s.size() && (s[p] == ' ' || s[p] == '\t')) ++p;
if (p >= s.size() || s[p] != '"') return "";
++p;
std::string out;
while (p < s.size() && s[p] != '"') {
if (s[p] == '\\' && p + 1 < s.size()) {
char c = s[p + 1];
if (c == 'n') out += '\n';
else if (c == 't') out += '\t';
else out += c;
p += 2;
continue;
}
out += s[p++];
}
return out;
}
int64_t find_int_field(const std::string& s, const std::string& key) {
std::string needle = "\"" + key + "\":";
size_t p = s.find(needle);
if (p == std::string::npos) return 0;
p += needle.size();
while (p < s.size() && (s[p] == ' ' || s[p] == '\t')) ++p;
char* end = nullptr;
long long v = std::strtoll(s.c_str() + p, &end, 10);
return static_cast<int64_t>(v);
}
// Split JSON array of objects at depth 1. Returns each object as a substring.
std::vector<std::string> split_objects(const std::string& s) {
std::vector<std::string> out;
int depth = 0;
size_t start = 0;
bool in_obj = false;
for (size_t i = 0; i < s.size(); ++i) {
char c = s[i];
if (c == '{') {
if (depth == 0) { start = i; in_obj = true; }
++depth;
} else if (c == '}') {
--depth;
if (depth == 0 && in_obj) {
out.push_back(s.substr(start, i - start + 1));
in_obj = false;
}
}
}
return out;
}
fn_http::Response do_get(const std::string& url, int timeout_ms) {
fn_http::Request req;
req.method = "GET";
req.url = url;
req.timeout_ms = timeout_ms;
return fn_http::request(req);
}
fn_http::Response do_post_json(const std::string& url, const std::string& body, int timeout_ms) {
fn_http::Request req;
req.method = "POST";
req.url = url;
req.timeout_ms = timeout_ms;
req.body = body;
req.headers.push_back({"Content-Type", "application/json"});
return fn_http::request(req);
}
} // namespace
bool health(const ClientConfig& cfg) {
auto r = do_get(cfg.base_url + "/health", cfg.timeout_ms);
return r.status >= 200 && r.status < 300;
}
std::vector<Card> list_cards(const ClientConfig& cfg, std::string& err) {
auto r = do_get(cfg.base_url + "/api/cards", cfg.timeout_ms);
if (r.status == 0) { err = "transport: " + r.error; return {}; }
if (r.status >= 400) { err = "http " + std::to_string(r.status); return {}; }
std::vector<Card> out;
for (const auto& obj : split_objects(r.body)) {
Card c;
c.id = find_str_field(obj, "id");
c.title = find_str_field(obj, "title");
c.description = find_str_field(obj, "description");
c.column_id = find_str_field(obj, "column_id");
c.priority = find_str_field(obj, "priority");
c.status = find_str_field(obj, "status");
c.position = find_int_field(obj, "position");
c.due_date = find_int_field(obj, "due_date");
c.assignee = find_str_field(obj, "assignee");
if (!c.id.empty()) out.push_back(c);
}
return out;
}
std::vector<Column> list_columns(const ClientConfig& cfg, std::string& err) {
auto r = do_get(cfg.base_url + "/api/columns", cfg.timeout_ms);
if (r.status == 0) { err = "transport: " + r.error; return {}; }
if (r.status >= 400) { err = "http " + std::to_string(r.status); return {}; }
std::vector<Column> out;
for (const auto& obj : split_objects(r.body)) {
Column c;
c.id = find_str_field(obj, "id");
c.name = find_str_field(obj, "name");
c.order = static_cast<int>(find_int_field(obj, "order"));
if (!c.id.empty()) out.push_back(c);
}
return out;
}
bool move_card(const ClientConfig& cfg, const std::string& card_id,
const std::string& new_column_id, std::string& err) {
std::string body = "{\"column_id\":\"" + new_column_id + "\"}";
auto r = do_post_json(cfg.base_url + "/api/cards/" + card_id + "/move", body, cfg.timeout_ms);
if (r.status == 0) { err = "transport: " + r.error; return false; }
if (r.status >= 400) { err = "http " + std::to_string(r.status); return false; }
return true;
}
std::vector<AgentRunSummary> list_runs(const ClientConfig& cfg, std::string& err) {
auto r = do_get(cfg.agent_runner_url + "/api/runs", cfg.timeout_ms);
if (r.status == 0) { err = "transport: " + r.error; return {}; }
if (r.status >= 400) { err = "http " + std::to_string(r.status); return {}; }
std::vector<AgentRunSummary> out;
for (const auto& obj : split_objects(r.body)) {
AgentRunSummary s;
s.id = find_str_field(obj, "id");
s.card_id = find_str_field(obj, "card_id");
s.branch = find_str_field(obj, "branch");
s.status = find_str_field(obj, "status");
s.started_at = find_int_field(obj, "started_at");
s.finished_at = find_int_field(obj, "finished_at");
if (!s.id.empty()) out.push_back(s);
}
return out;
}
bool launch_workflow(const ClientConfig& cfg, const std::string& card_id,
std::string& out_run_id, std::string& err) {
std::string body = "{\"card_id\":\"" + card_id + "\"}";
auto r = do_post_json(cfg.agent_runner_url + "/api/runs", body, cfg.timeout_ms);
if (r.status == 0) { err = "transport: " + r.error; return false; }
if (r.status >= 400) { err = "http " + std::to_string(r.status); return false; }
out_run_id = find_str_field(r.body, "id");
return true;
}
} // namespace kanban_cpp
+61
View File
@@ -0,0 +1,61 @@
// data.h — HTTP client wrapper for kanban_cpp backend at :8403.
//
// Wraps fn_http::request() (cpp/functions/core/http_request.h) with
// kanban-specific shapes (Card, Column, AgentRunSummary).
#pragma once
#include <string>
#include <vector>
#include <cstdint>
namespace kanban_cpp {
struct Card {
std::string id;
std::string title;
std::string description;
std::string column_id;
std::string priority; // low|medium|high|critical
std::string status; // pending|doing|done|...
int64_t position = 0;
int64_t due_date = 0; // unix seconds, 0 = no due
std::string assignee;
std::vector<std::string> labels;
};
struct Column {
std::string id;
std::string name;
int order = 0;
};
struct AgentRunSummary {
std::string id;
std::string card_id;
std::string branch;
std::string status;
int64_t started_at = 0;
int64_t finished_at = 0;
};
struct ClientConfig {
std::string base_url = "http://127.0.0.1:8403";
std::string agent_runner_url = "http://127.0.0.1:8486";
int timeout_ms = 3000;
};
// HTTP GETs ---------------------------------------------------------------
std::vector<Card> list_cards(const ClientConfig& cfg, std::string& err);
std::vector<Column> list_columns(const ClientConfig& cfg, std::string& err);
bool health(const ClientConfig& cfg); // GET /health
// HTTP mutations ----------------------------------------------------------
bool move_card(const ClientConfig& cfg, const std::string& card_id,
const std::string& new_column_id, std::string& err);
// agent_runner_api -------------------------------------------------------
std::vector<AgentRunSummary> list_runs(const ClientConfig& cfg, std::string& err);
bool launch_workflow(const ClientConfig& cfg, const std::string& card_id,
std::string& out_run_id, std::string& err);
} // namespace kanban_cpp
+73
View File
@@ -0,0 +1,73 @@
// main.cpp — kanban_cpp entry point.
//
// Six panels declared via cfg.panels. fn::run_app paints the menubar /
// dockspace / about / layouts automatically.
#include "app_base.h"
#include "core/panel_menu.h"
#include "core/icons_tabler.h"
#include "core/logger.h"
#include "panels.h"
#include <imgui.h>
#include <cstring>
#include <cstdio>
#include <string>
static bool g_show_board = true;
static bool g_show_calendar = true;
static bool g_show_dashboard = true;
static bool g_show_runs = true;
static bool g_show_worktrees = true;
static bool g_show_dod = true;
static kanban_cpp::AppState g_state;
static void render() {
if (g_show_board) kanban_cpp::draw_board (g_state, &g_show_board);
if (g_show_calendar) kanban_cpp::draw_calendar (g_state, &g_show_calendar);
if (g_show_dashboard) kanban_cpp::draw_dashboard (g_state, &g_show_dashboard);
if (g_show_runs) kanban_cpp::draw_agent_runs(g_state, &g_show_runs);
if (g_show_worktrees) kanban_cpp::draw_worktrees (g_state, &g_show_worktrees);
if (g_show_dod) kanban_cpp::draw_dod (g_state, &g_show_dod);
}
// Headless self-test: verifies the binary links, panels include compile,
// and the data layer accepts a config. Used by app.md e2e_checks.
static int run_self_test() {
std::printf("kanban_cpp --self-test\n");
kanban_cpp::AppState s;
s.cfg.base_url = "http://127.0.0.1:65535"; // unreachable on purpose
bool ok = kanban_cpp::health(s.cfg);
std::printf(" health(unreachable) = %s (expected: false)\n", ok ? "true" : "false");
if (ok) return 1;
std::printf("OK\n");
return 0;
}
int main(int argc, char** argv) {
for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], "--self-test") == 0) return run_self_test();
}
static fn_ui::PanelToggle panels[] = {
{ "Board", nullptr, &g_show_board },
{ "Calendar", nullptr, &g_show_calendar },
{ "Dashboard", nullptr, &g_show_dashboard },
{ "Agent runs", nullptr, &g_show_runs },
{ "Worktrees", nullptr, &g_show_worktrees },
{ "DoD inspector", nullptr, &g_show_dod },
};
fn::AppConfig cfg;
cfg.title = "kanban_cpp — agentes LLM con DoD";
cfg.about = { "kanban_cpp", "0.1.0",
"Clon C++ ImGui de kanban_web — agentes LLM con DoD evidence" };
cfg.log = { "kanban_cpp.log", 1 };
cfg.panels = panels;
cfg.panel_count = sizeof(panels) / sizeof(panels[0]);
// First refresh on startup (best-effort; failure surfaces in the Board).
kanban_cpp::refresh_data(g_state);
return fn::run_app(cfg, render);
}
+72
View File
@@ -0,0 +1,72 @@
// panel_agent_runs.cpp — wraps the registry fn_viz::render_agent_runs_timeline.
//
// HTTP polling against agent_runner_api:8486. If the API is offline the
// panel shows `disconnected` and the table stays empty — never blocks the
// UI thread (calls happen lazily via Refresh button).
#include "panels.h"
#include "core/icons_tabler.h"
#include "viz/agent_runs_timeline.h"
#include <imgui.h>
#include <mutex>
namespace kanban_cpp {
namespace {
fn_viz::TimelineState& state_singleton() {
static fn_viz::TimelineState s;
static bool inited = false;
if (!inited) {
s.sse_url = "http://127.0.0.1:8486/api/runs/stream";
s.connection_status = "disconnected";
inited = true;
}
return s;
}
void poll_runs(AppState& app) {
auto& ts = state_singleton();
std::string err;
auto runs = list_runs(app.cfg, err);
if (!err.empty()) {
std::lock_guard<std::mutex> lk(ts.runs_mutex);
ts.connection_status = "disconnected";
return;
}
std::lock_guard<std::mutex> lk(ts.runs_mutex);
ts.runs.clear();
for (const auto& r : runs) {
fn_viz::AgentRun ar;
ar.id = r.id;
ar.app = "kanban_cpp";
ar.card_id = r.card_id;
ar.branch = r.branch;
ar.status = r.status;
ar.started_at = r.started_at;
ar.finished_at = r.finished_at;
ts.runs.push_back(ar);
}
ts.connection_status = "connected";
}
} // namespace
void draw_agent_runs(AppState& app, bool* p_open) {
if (!ImGui::Begin(TI_ROBOT " Agent runs", p_open)) {
ImGui::End();
return;
}
auto& ts = state_singleton();
if (ImGui::Button(TI_REFRESH " Poll agent_runner_api")) poll_runs(app);
ImGui::SameLine();
ImGui::TextDisabled("%s", ts.connection_status.c_str());
ImGui::Separator();
fn_viz::render_agent_runs_timeline(ts);
ImGui::End();
}
} // namespace kanban_cpp
+113
View File
@@ -0,0 +1,113 @@
// panel_board.cpp — columns + cards Kanban panel.
#include "panels.h"
#include "core/icons_tabler.h"
#include <imgui.h>
#include <ctime>
namespace kanban_cpp {
void refresh_data(AppState& s) {
std::string err;
s.cards = list_cards(s.cfg, err);
if (!err.empty()) s.last_refresh_error = "cards: " + err;
s.columns = list_columns(s.cfg, err);
if (!err.empty()) s.last_refresh_error += " columns: " + err;
s.backend_ok = health(s.cfg);
s.last_refresh_ts = std::time(nullptr);
}
void draw_board(AppState& s, bool* p_open) {
if (!ImGui::Begin(TI_LAYOUT_KANBAN " Board", p_open)) {
ImGui::End();
return;
}
// Toolbar
if (ImGui::Button(TI_REFRESH " Refresh")) refresh_data(s);
ImGui::SameLine();
if (s.backend_ok) {
ImGui::TextColored(ImVec4(0.4f, 0.85f, 0.4f, 1.0f), TI_CHECK " backend :8403");
} else {
ImGui::TextColored(ImVec4(0.85f, 0.4f, 0.4f, 1.0f), TI_ALERT_TRIANGLE " backend offline (:8403)");
}
if (!s.last_refresh_error.empty()) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.85f, 0.6f, 0.2f, 1.0f), "%s", s.last_refresh_error.c_str());
}
ImGui::Separator();
// Empty state
if (s.columns.empty()) {
ImGui::TextDisabled("No columns yet. Pulsa Refresh o lanza el backend en :8403.");
ImGui::End();
return;
}
// Render columns left-to-right
const float col_w = 280.0f;
if (ImGui::BeginChild("##board_scroll", ImVec2(0, 0), false,
ImGuiWindowFlags_HorizontalScrollbar)) {
for (size_t ci = 0; ci < s.columns.size(); ++ci) {
const auto& col = s.columns[ci];
ImGui::SameLine();
ImGui::BeginChild((std::string("##col_") + col.id).c_str(),
ImVec2(col_w, 0), true);
ImGui::TextUnformatted(col.name.c_str());
ImGui::SameLine();
int count = 0;
for (const auto& c : s.cards) if (c.column_id == col.id) ++count;
ImGui::TextDisabled("(%d)", count);
ImGui::Separator();
for (const auto& card : s.cards) {
if (card.column_id != col.id) continue;
ImGui::PushID(card.id.c_str());
ImGui::BeginChild("##card", ImVec2(0, 70), true,
ImGuiWindowFlags_NoScrollbar);
ImGui::TextUnformatted(card.title.c_str());
if (!card.priority.empty()) {
ImVec4 col_p(0.6f, 0.6f, 0.6f, 1);
if (card.priority == "high") col_p = {0.95f, 0.55f, 0.2f, 1};
else if (card.priority == "critical") col_p = {0.95f, 0.25f, 0.25f, 1};
else if (card.priority == "low") col_p = {0.45f, 0.7f, 0.95f, 1};
ImGui::TextColored(col_p, TI_FLAG " %s", card.priority.c_str());
}
if (!card.assignee.empty()) {
ImGui::SameLine();
ImGui::TextDisabled(TI_USER " %s", card.assignee.c_str());
}
ImGui::EndChild();
if (ImGui::IsItemHovered() && ImGui::IsItemClicked(ImGuiMouseButton_Right)) {
ImGui::OpenPopup("##card_ctx");
}
if (ImGui::BeginPopup("##card_ctx")) {
ImGui::TextDisabled("Move to:");
for (const auto& tgt : s.columns) {
if (tgt.id == card.column_id) continue;
if (ImGui::MenuItem(tgt.name.c_str())) {
std::string err;
if (!move_card(s.cfg, card.id, tgt.id, err))
s.last_refresh_error = "move: " + err;
else refresh_data(s);
}
}
ImGui::Separator();
if (ImGui::MenuItem(TI_PLAYER_PLAY " Launch agent workflow")) {
std::string run_id, err;
if (!launch_workflow(s.cfg, card.id, run_id, err))
s.last_refresh_error = "launch: " + err;
}
ImGui::EndPopup();
}
ImGui::PopID();
}
ImGui::EndChild();
}
}
ImGui::EndChild();
ImGui::End();
}
} // namespace kanban_cpp
+97
View File
@@ -0,0 +1,97 @@
// panel_calendar.cpp — MVP monthly calendar view.
//
// Renders a simple 7-column grid for the current month with cards bucketed
// by `due_date`. No navigation, no editing — that's tracked as TODO in
// app.md ## Gotchas.
#include "panels.h"
#include "core/icons_tabler.h"
#include <imgui.h>
#include <ctime>
namespace kanban_cpp {
void draw_calendar(AppState& s, bool* p_open) {
if (!ImGui::Begin(TI_CALENDAR " Calendar", p_open)) {
ImGui::End();
return;
}
std::time_t now = std::time(nullptr);
std::tm tm_now;
#ifdef _WIN32
localtime_s(&tm_now, &now);
#else
localtime_r(&now, &tm_now);
#endif
// Heading
static const char* months[] = {"Enero","Febrero","Marzo","Abril","Mayo","Junio",
"Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"};
ImGui::Text("%s %d", months[tm_now.tm_mon], tm_now.tm_year + 1900);
ImGui::TextDisabled("(MVP estatico — TODO: navegacion + filtros)");
ImGui::Separator();
// First day of current month + days in month
std::tm tm_first = tm_now;
tm_first.tm_mday = 1;
std::mktime(&tm_first);
int first_wday = tm_first.tm_wday; // 0 = Sunday
int first_wday_mon = (first_wday + 6) % 7; // 0 = Monday (ES convention)
int days_in_month = 31;
{
std::tm tm_test = tm_now;
tm_test.tm_mday = 32;
std::mktime(&tm_test);
days_in_month = 32 - tm_test.tm_mday;
}
// Grid 7 cols
if (ImGui::BeginTable("##cal", 7, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchSame)) {
const char* wdays[] = {"L","M","X","J","V","S","D"};
for (int i = 0; i < 7; ++i) ImGui::TableSetupColumn(wdays[i]);
ImGui::TableHeadersRow();
int day = 1;
int total_cells = first_wday_mon + days_in_month;
int rows = (total_cells + 6) / 7;
int cell_index = 0;
for (int r = 0; r < rows; ++r) {
ImGui::TableNextRow();
for (int c = 0; c < 7; ++c) {
ImGui::TableSetColumnIndex(c);
if (cell_index < first_wday_mon || day > days_in_month) {
ImGui::TextDisabled(" ");
} else {
ImGui::Text("%d", day);
// Count cards whose due_date falls in this day.
int hits = 0;
for (const auto& card : s.cards) {
if (card.due_date == 0) continue;
std::time_t cd = (std::time_t)card.due_date;
std::tm tmc;
#ifdef _WIN32
localtime_s(&tmc, &cd);
#else
localtime_r(&cd, &tmc);
#endif
if (tmc.tm_year == tm_now.tm_year && tmc.tm_mon == tm_now.tm_mon
&& tmc.tm_mday == day) ++hits;
}
if (hits > 0)
ImGui::TextColored(ImVec4(0.6f, 0.55f, 0.95f, 1.0f),
TI_FLAG " %d", hits);
++day;
}
++cell_index;
}
}
ImGui::EndTable();
}
ImGui::End();
}
} // namespace kanban_cpp
+72
View File
@@ -0,0 +1,72 @@
// panel_dashboard.cpp — KPI grid using kpi_card + sparkline from the registry.
#include "panels.h"
#include "core/icons_tabler.h"
#include "viz/kpi_card.h"
#include "viz/sparkline.h"
#include <imgui.h>
#include <map>
#include <string>
namespace kanban_cpp {
namespace {
float pct_by_status(const std::vector<Card>& cards, const std::string& status) {
if (cards.empty()) return 0.0f;
int hits = 0;
for (const auto& c : cards) if (c.status == status) ++hits;
return 100.0f * static_cast<float>(hits) / static_cast<float>(cards.size());
}
} // namespace
void draw_dashboard(AppState& s, bool* p_open) {
if (!ImGui::Begin(TI_DASHBOARD " Dashboard", p_open)) {
ImGui::End();
return;
}
ImGui::TextDisabled("KPIs sinteticos (TODO: backend /api/stats endpoint)");
ImGui::Separator();
// Snapshot counts
int total = static_cast<int>(s.cards.size());
std::map<std::string, int> by_priority;
std::map<std::string, int> by_status;
for (const auto& c : s.cards) {
if (!c.priority.empty()) by_priority[c.priority]++;
if (!c.status.empty()) by_status[c.status]++;
}
// Fake history for sparkline — until backend wires real time-series.
static float hist_total[12] = {3,4,5,5,7,8,9,8,10,11,12,13};
static float hist_doing[12] = {1,1,2,2,3,3,4,4,5,5,6,6};
// KPI grid (use Columns for a quick 3-up layout)
ImGui::Columns(3, "##kpi_cols", false);
kpi_card("Total cards", static_cast<float>(total), 0.0f,
hist_total, 12, "%.0f", TI_LAYOUT_KANBAN);
ImGui::NextColumn();
kpi_card("Doing now", static_cast<float>(by_status["doing"]), 0.0f,
hist_doing, 12, "%.0f", TI_PLAYER_PLAY);
ImGui::NextColumn();
kpi_card("Critical", static_cast<float>(by_priority["critical"]), 0.0f,
nullptr, 0, "%.0f", TI_FLAG);
ImGui::Columns(1);
ImGui::Separator();
// Status breakdown
ImGui::Text("Status breakdown");
for (const auto& kv : by_status) {
ImGui::Text(" %-12s %d", kv.first.c_str(), kv.second);
}
ImGui::End();
}
} // namespace kanban_cpp
+43
View File
@@ -0,0 +1,43 @@
// panel_dod.cpp — DoD evidence inspector wrapping the registry panel.
//
// MVP: uses a synthetic in-memory DodPanelState so the panel renders without
// the agent_runner_api wired up. When that API exposes /api/dod_items +
// /api/dod_evidences endpoints, this will fetch them like panel_agent_runs.
#include "panels.h"
#include "core/icons_tabler.h"
#include "viz/dod_evidence_panel.h"
#include <imgui.h>
namespace kanban_cpp {
namespace {
fn_viz::DodPanelState& state_singleton() {
static fn_viz::DodPanelState st;
static bool inited = false;
if (!inited) {
st.run_id = "(none)";
// Empty until wired to backend. Helpers count zeros gracefully.
inited = true;
}
return st;
}
} // namespace
void draw_dod(AppState& /*s*/, bool* p_open) {
if (!ImGui::Begin(TI_LIST_CHECK " DoD inspector", p_open)) {
ImGui::End();
return;
}
auto& st = state_singleton();
ImGui::TextDisabled("run_id: %s (TODO: wire to agent_runner_api)", st.run_id.c_str());
ImGui::Separator();
fn_viz::render_dod_evidence_panel(st);
ImGui::End();
}
} // namespace kanban_cpp
+91
View File
@@ -0,0 +1,91 @@
// panel_worktrees.cpp — lists git worktrees via `git worktree list --porcelain`.
//
// Read-only MVP: shows path, head, branch. Future work: create/remove from
// inside the panel (TODO in app.md ## Gotchas).
#include "panels.h"
#include "core/icons_tabler.h"
#include <imgui.h>
#include <cstdio>
#include <string>
#include <vector>
#include <array>
namespace kanban_cpp {
namespace {
struct WT {
std::string path;
std::string head;
std::string branch;
};
std::vector<WT> scan_worktrees() {
std::vector<WT> out;
#ifdef _WIN32
FILE* fp = _popen("git worktree list --porcelain 2>nul", "r");
#else
FILE* fp = popen("git worktree list --porcelain 2>/dev/null", "r");
#endif
if (!fp) return out;
std::array<char, 1024> buf;
WT cur;
while (std::fgets(buf.data(), static_cast<int>(buf.size()), fp)) {
std::string line(buf.data());
while (!line.empty() && (line.back() == '\n' || line.back() == '\r')) line.pop_back();
if (line.empty()) {
if (!cur.path.empty()) out.push_back(cur);
cur = WT();
continue;
}
if (line.rfind("worktree ", 0) == 0) cur.path = line.substr(9);
else if (line.rfind("HEAD ", 0) == 0) cur.head = line.substr(5);
else if (line.rfind("branch ", 0) == 0) cur.branch = line.substr(7);
}
if (!cur.path.empty()) out.push_back(cur);
#ifdef _WIN32
_pclose(fp);
#else
pclose(fp);
#endif
return out;
}
} // namespace
void draw_worktrees(AppState& /*s*/, bool* p_open) {
if (!ImGui::Begin(TI_GIT_BRANCH " Worktrees", p_open)) {
ImGui::End();
return;
}
static std::vector<WT> wts;
static bool first = true;
if (first) { wts = scan_worktrees(); first = false; }
if (ImGui::Button(TI_REFRESH " Rescan")) wts = scan_worktrees();
ImGui::SameLine();
ImGui::TextDisabled("%zu worktrees", wts.size());
ImGui::Separator();
if (ImGui::BeginTable("##wts", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) {
ImGui::TableSetupColumn("Branch");
ImGui::TableSetupColumn("HEAD");
ImGui::TableSetupColumn("Path");
ImGui::TableHeadersRow();
for (const auto& w : wts) {
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImGui::TextUnformatted(w.branch.empty() ? "(detached)" : w.branch.c_str());
ImGui::TableSetColumnIndex(1);
ImGui::TextUnformatted(w.head.substr(0, 10).c_str());
ImGui::TableSetColumnIndex(2);
ImGui::TextUnformatted(w.path.c_str());
}
ImGui::EndTable();
}
ImGui::End();
}
} // namespace kanban_cpp
+31
View File
@@ -0,0 +1,31 @@
// panels.h — Panel draw functions for kanban_cpp.
//
// Each draw_* expects to be called inside an active ImGui frame; it issues
// its own ImGui::Begin/End block guarded by the supplied bool*.
#pragma once
#include "data.h"
namespace kanban_cpp {
// Shared app state passed to every panel. Owned by main.cpp.
struct AppState {
ClientConfig cfg;
std::vector<Card> cards;
std::vector<Column> columns;
std::string last_refresh_error;
int64_t last_refresh_ts = 0;
bool backend_ok = false;
};
void draw_board (AppState& s, bool* p_open);
void draw_calendar (AppState& s, bool* p_open);
void draw_dashboard (AppState& s, bool* p_open);
void draw_agent_runs(AppState& s, bool* p_open);
void draw_worktrees (AppState& s, bool* p_open);
void draw_dod (AppState& s, bool* p_open);
// Polls the backend for cards/columns; updates s.last_refresh_*.
void refresh_data(AppState& s);
} // namespace kanban_cpp