diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..37b0eee --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM golang:1.25-bookworm AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc g++ libsqlite3-dev pkg-config ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build +COPY . . + +WORKDIR /build/apps/kanban/backend +RUN CGO_ENABLED=1 go build -ldflags='-s -w' -o /kanban . + +# --- Runtime --- +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libsqlite3-0 libstdc++6 ca-certificates tzdata \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /kanban /usr/local/bin/kanban + +WORKDIR /data +VOLUME /data + +EXPOSE 8095 + +ENTRYPOINT ["/usr/local/bin/kanban"] +CMD ["--port", "8095", "--db", "/data/operations.db"] diff --git a/app.md b/app.md index caf0238..e366229 100644 --- a/app.md +++ b/app.md @@ -43,6 +43,32 @@ uses_types: framework: "net/http + vite + react + mantine + dnd-kit" entry_point: "backend/main.go" dir_path: "apps/kanban" + +# Validacion end-to-end (fase 4 del bucle reactivo). Ver issue 0068. +e2e_checks: + - id: build_frontend + cmd: "cd frontend && pnpm install --frozen-lockfile && pnpm build" + timeout_s: 180 + expect_exit: 0 + - id: build_backend + cmd: "CGO_ENABLED=1 go build -tags fts5 -o kanban ." + timeout_s: 120 + expect_exit: 0 + - id: migrations_apply + cmd: "rm -f /tmp/kanban_e2e.db && ./kanban --port 0 --db /tmp/kanban_e2e.db --migrate-only" + timeout_s: 15 + expect_exit: 0 + - id: migrations_schema + cmd: "sqlite3 /tmp/kanban_e2e.db 'SELECT version FROM schema_migrations ORDER BY version;'" + expect_stdout_contains: "1" + - id: smoke_api + cmd: "./kanban --port 8195 --db /tmp/kanban_e2e.db &" + health: "http://127.0.0.1:8195/api/board" + timeout_s: 10 + - id: tests_go + cmd: "go test -tags fts5 -count=1 ./..." + timeout_s: 120 + expect_exit: 0 --- ## Arquitectura diff --git a/backend/chat.go b/backend/chat.go index 081004d..cecafb1 100644 --- a/backend/chat.go +++ b/backend/chat.go @@ -15,18 +15,17 @@ import ( "nhooyr.io/websocket" ) -const chatSystemPrompt = `Eres el asistente del tablero kanban. Responde al usuario y, cuando pida cambios, modifica el tablero llamando a tools nativas (MCP). +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 disponibles via MCP server "kanban": -- list_board / find_cards / card_history / list_users — lectura -- create_column / update_column / delete_column / reorder_columns — columnas -- create_card / update_card / delete_card / move_card / assign_card — tarjetas +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 -Llama directamente a las tools cuando necesites mutar el tablero. Usa list_board al principio si necesitas resolver nombres a IDs. NUNCA inventes IDs. +El estado actual del tablero viene en al final del mensaje. Usa esos IDs directamente — NO llames list_board si ya tienes lo que necesitas. NUNCA inventes IDs. -Cuando termines, responde texto natural en markdown (sin llamadas extra) — eso señala el fin de la conversacion.` +Cuando termines, responde texto natural sin mas llamadas — eso cierra la conversacion.` -const claudeModel = "claude-sonnet-4-6" const claudeTimeout = 300 * time.Second func claudeBinary() string { @@ -36,6 +35,13 @@ func claudeBinary() string { 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"` @@ -125,13 +131,18 @@ func streamChat(ctx context.Context, conn *websocket.Conn, db *DB, workdir, toke defer os.Remove(mcpPath) prompt := flattenMessages(msgs) + if board, err := boardSnapshot(db); err == nil && board != "" { + prompt += "\n\n\n" + board + "\n\n" + } stdin := strings.NewReader(prompt) events, err := core.StreamClaude(ctx, core.ClaudeStreamOpts{ Bin: claudeBinary(), Args: []string{ - "--model", claudeModel, + "--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", @@ -215,6 +226,24 @@ func flattenMessages(msgs []chatMessage) string { 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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..544dee8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +name: kanban + +services: + kanban: + build: + context: ../../ + dockerfile: apps/kanban/Dockerfile + container_name: kanban + restart: unless-stopped + ports: + - "8095:8095" + volumes: + - kanban_data:/data + networks: + - coolify + +volumes: + kanban_data: + +networks: + coolify: + external: true diff --git a/traefik-dynamic.yml b/traefik-dynamic.yml new file mode 100644 index 0000000..b45bf4c --- /dev/null +++ b/traefik-dynamic.yml @@ -0,0 +1,37 @@ +http: + routers: + kanban-http: + rule: "Host(`kanban.organic-machine.com`)" + entryPoints: + - "http" + middlewares: + - "kanban-redirect" + service: "kanban-service" + + kanban-https: + rule: "Host(`kanban.organic-machine.com`)" + entryPoints: + - "https" + middlewares: + - "kanban-auth" + - "kanban-gzip" + service: "kanban-service" + tls: + certResolver: letsencrypt + + services: + kanban-service: + loadBalancer: + servers: + - url: "http://kanban:8095" + + middlewares: + kanban-redirect: + redirectScheme: + scheme: "https" + kanban-auth: + basicAuth: + users: + - "lucas:$2y$05$67qHI0i2NiSbVC40gJvqHOb28PKgkNiKYtsdJEEgw3FxT4j4NQcrG" + kanban-gzip: + compress: true