Compare commits

2 Commits

Author SHA1 Message Date
egutierrez 98bf278472 chore: auto-commit (8 archivos)
- backend/handlers.go
- data.cpp
- data.h
- main.cpp
- panel_board.cpp
- panel_filters.cpp
- appicon.ico
- backend/kanban_cpp_backend.exe

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:38:15 +02:00
agent 255e8dcf71 feat: initial scaffold of kanban_cpp v2 (issue 0130)
Frontend C++ ImGui (main.cpp + 4 paneles) + backend Go (HTTP + SQLite + fsnotify + SSE).
Reusa parse/scan/watch funcs del registry (issue 0130a).
2026-05-22 22:19:47 +02:00
50 changed files with 1982 additions and 4250 deletions
+4 -11
View File
@@ -1,12 +1,5 @@
build/
*.exe
*.log
backend/operations.db
backend/operations.db-shm
backend/operations.db-wal
backend/kanban_cpp_backend backend/kanban_cpp_backend
backend/dist/* backend/operations.db*
!backend/dist/.gitkeep backend/registry.db
local_files/ build/
imgui.ini *.log
app_settings.ini
+7 -14
View File
@@ -2,24 +2,17 @@ add_imgui_app(kanban_cpp
main.cpp main.cpp
data.cpp data.cpp
panel_board.cpp panel_board.cpp
panel_calendar.cpp panel_flows.cpp
panel_dashboard.cpp panel_filters.cpp
panel_agent_runs.cpp panel_detail.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/core/http_request.cpp
${CMAKE_SOURCE_DIR}/functions/core/sse_client.cpp ${CMAKE_SOURCE_DIR}/functions/core/sse_client.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}) target_include_directories(kanban_cpp PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_SOURCE_DIR}/vendor
)
if(WIN32) if(WIN32)
target_link_libraries(kanban_cpp PRIVATE ws2_32)
set_target_properties(kanban_cpp PROPERTIES WIN32_EXECUTABLE TRUE) set_target_properties(kanban_cpp PROPERTIES WIN32_EXECUTABLE TRUE)
endif() endif()
+38 -40
View File
@@ -2,77 +2,75 @@
name: kanban_cpp name: kanban_cpp
lang: cpp lang: cpp
domain: tools domain: tools
version: 0.1.0 version: 0.2.0
description: "Clon C++ ImGui de kanban_web — tablero pensado para conducir agentes LLM con DoD evidence" description: "Kanban C++ v2 — gestor de dev/issues y dev/flows del registry. Board drag-drop, edicion bidireccional de frontmatter MD"
tags: [kanban, cpp, agents, imgui] tags: [imgui, kanban, dev_ux, issues, flows]
icon: icon:
phosphor: "columns" phosphor: "kanban"
accent: "#a855f7" accent: "#a855f7"
uses_functions: uses_functions:
- http_request_cpp_core - http_request_cpp_core
- sse_client_cpp_core - sse_client_cpp_core
- dod_evidence_panel_cpp_viz
- agent_runs_timeline_cpp_viz
- kpi_card_cpp_viz
- sparkline_cpp_viz
uses_types: [] uses_types: []
framework: "imgui" framework: "imgui"
entry_point: "main.cpp" entry_point: "main.cpp"
dir_path: "apps/kanban_cpp" dir_path: "apps/kanban_cpp"
repo_url: "https://gitea.organic-machine.com/dataforge/kanban_cpp" repo_url: "https://gitea.organic-machine.com/dataforge/kanban_cpp"
e2e_checks: e2e_checks:
- id: backend_build
cmd: "cd apps/kanban_cpp/backend && CGO_ENABLED=1 go build -o kanban_cpp_backend ."
timeout_s: 180
- id: backend_tests
cmd: "cd apps/kanban_cpp/backend && CGO_ENABLED=1 go test -count=1 ./..."
timeout_s: 60
- id: build - id: build
cmd: "cmake --build cpp/build/linux --target kanban_cpp -j" cmd: "cmake --build cpp/build/linux --target kanban_cpp -j"
timeout_s: 300 timeout_s: 300
- id: self_test - id: self_test
cmd: "./cpp/build/linux/apps/kanban_cpp/kanban_cpp --self-test" cmd: "./cpp/build/linux/apps/kanban_cpp/kanban_cpp --self-test"
timeout_s: 30 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 # kanban_cpp
Clon C++ ImGui de kanban_web — tablero pensado para conducir agentes LLM con DoD evidence. Kanban C++ v2 para gestionar `dev/issues/` y `dev/flows/` del registry. Frontend ImGui sobre `fn::run_app`, backend Go local en `backend/` que parsea los `.md` a SQLite y expone REST + SSE.
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 ## Build
```bash ```bash
# Backend # Backend
cd apps/kanban_cpp/backend && CGO_ENABLED=1 go build -tags fts5 -o kanban_cpp_backend . cd apps/kanban_cpp/backend && CGO_ENABLED=1 go build -o kanban_cpp_backend .
./kanban_cpp_backend --port 8403 --db operations.db
# Frontend ImGui # Frontend
cd cpp && cmake -B build/linux && cmake --build build/linux --target kanban_cpp -j cmake --build cpp/build/linux --target kanban_cpp -j
./build/linux/apps/kanban_cpp/kanban_cpp
``` ```
## Cuando usarla ## Run
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. ```bash
# Terminal 1: backend
apps/kanban_cpp/backend/kanban_cpp_backend --port 8487 --registry $PWD
## Gotchas # Terminal 2: frontend
./cpp/build/linux/apps/kanban_cpp/kanban_cpp
```
- 2 services + 2 sqlite locks: kanban_web :8095/8401 y kanban_cpp :8403 NUNCA comparten `operations.db`. Por defecto el frontend apunta a `http://127.0.0.1:8487`. Cambiar con `--backend http://host:port`.
- `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 ## Self-test
(v0.1.0 baseline — sin crecimiento aun) `./kanban_cpp --self-test` — smoke headless (state singleton, filtros, HTTP wrapper sin backend).
## Paneles
1. **Board** — 4 columnas (pendiente / in-progress / bloqueado / completado). Drag-drop entre columnas reescribe el `.md` correspondiente via PATCH al backend.
2. **Flows** — tabla de flows. Click para detalle.
3. **Filters** — sidebar multi-select de priority / scope / domain / tag + include_completed.
4. **Detail** — combos para status/priority/scope + CSV editors para tags/domain/depends/blocks. Body MD read-only.
## Anti-scope (issue 0130)
- Sin grafo de dependencias.
- Sin edicion del body MD (solo frontmatter).
- Sin crear issues nuevos.
- Sin DoD panel / agent runs / worktrees.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

+72
View File
@@ -0,0 +1,72 @@
---
name: kanban_cpp_backend
lang: go
domain: tools
version: 0.1.0
description: "Backend HTTP del kanban_cpp v2: sirve dev/issues y dev/flows con parser MD bidireccional + SQLite cache + fsnotify watcher + SSE"
tags: [service, kanban, go, sqlite, sse]
uses_functions:
- parse_issue_md_go_infra
- write_issue_md_go_infra
- scan_issues_dir_go_infra
- scan_flows_dir_go_infra
- watch_dir_fsnotify_go_infra
uses_types:
- issue_go_infra
- flow_go_infra
- fs_event_go_infra
framework: ""
entry_point: "main.go"
dir_path: "apps/kanban_cpp/backend"
repo_url: "https://gitea.organic-machine.com/dataforge/kanban_cpp"
service:
port: 8487
health_endpoint: /api/health
health_timeout_s: 3
systemd_unit: null
systemd_scope: null
restart_policy: none
runtime: manual
pc_targets:
- home-wsl
is_local_only: true
e2e_checks:
- id: build
cmd: "cd apps/kanban_cpp/backend && CGO_ENABLED=1 go build -o kanban_cpp_backend ."
timeout_s: 180
- id: tests
cmd: "cd apps/kanban_cpp/backend && CGO_ENABLED=1 go test -count=1 ./..."
timeout_s: 60
- id: smoke_health
cmd: "cd apps/kanban_cpp/backend && ./kanban_cpp_backend --port 18487 --db /tmp/kanban_cpp_e2e.db --registry /home/lucas/fn_registry &\nsleep 2\ncurl -fsS http://127.0.0.1:18487/api/health\npkill -f kanban_cpp_backend || true"
timeout_s: 30
---
# kanban_cpp_backend
Servicio HTTP local que sirve `dev/issues/` y `dev/flows/` al frontend C++ ImGui (`apps/kanban_cpp/`).
## Endpoints
- `GET /api/health` — counts + version.
- `GET /api/issues?status=&priority=&scope=&domain=&tag=&completed=` — lista.
- `GET /api/issues/{id}` — detalle + body.
- `PATCH /api/issues/{id}` — reescribe frontmatter en disco. Body intacto.
- `GET /api/flows` — lista.
- `GET /api/flows/{id}` — detalle.
- `GET /api/meta` — enums (statuses, priorities, scopes, types).
- `GET /api/sse` — stream cambios (incl. mods externas en disco).
## Run
```bash
cd apps/kanban_cpp/backend
CGO_ENABLED=1 go build -o kanban_cpp_backend .
./kanban_cpp_backend --port 8487 --registry /home/lucas/fn_registry
```
El binario auto-detecta el root del registry buscando `registry.db` hacia arriba si no se pasa `--registry`.
## Esquema
Ver `migrations/001_init.sql`. SQLite WAL. Las filas se upsertean en cada ingest/watch event.
+26 -1176
View File
File diff suppressed because it is too large Load Diff
View File
-55
View File
@@ -1,55 +0,0 @@
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)
}
}
-35
View File
@@ -1,35 +0,0 @@
package main
import (
"path/filepath"
"strings"
"time"
)
// flowsCache mirrors issuesCache but for dev/flows/*.md.
var flowsCache = &cardsCache{ttl: 30 * time.Second}
// mapFlowStatusToColumn maps flow frontmatter status -> kanban column id.
// Flows use a different vocabulary than issues.
func mapFlowStatusToColumn(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "pending", "":
return "Pending"
case "running":
return "Running"
case "done":
return "Done"
case "deferred":
return "Deferred"
default:
return "Pending"
}
}
func loadFlowCards(dir string) ([]IssueCard, error) {
return loadCardsFromDir(dir, mapFlowStatusToColumn, "flow")
}
func flowsDir() string {
return filepath.Join(registryRoot(), "dev", "flows")
}
-60
View File
@@ -1,60 +0,0 @@
package main
import (
"testing"
)
func TestLoadFlowCards_MapsStatuses(t *testing.T) {
dir := t.TempDir()
writeFixture(t, dir, "INDEX.md", "skip")
writeFixture(t, dir, "0001-foo.md", "---\nid: 0001\nname: foo\nstatus: pending\n---\nbody\n")
writeFixture(t, dir, "0002-bar.md", "---\nid: 0002\nname: bar\nstatus: running\n---\nbody\n")
writeFixture(t, dir, "0003-baz.md", "---\nid: 0003\nname: baz\nstatus: done\n---\nbody\n")
writeFixture(t, dir, "0004-bop.md", "---\nid: 0004\nname: bop\nstatus: deferred\n---\nbody\n")
cards, err := loadFlowCards(dir)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(cards) != 4 {
t.Fatalf("expected 4 cards, got %d", len(cards))
}
want := map[string]string{
"0001": "Pending",
"0002": "Running",
"0003": "Done",
"0004": "Deferred",
}
for _, c := range cards {
if want[c.ID] != c.ColumnID {
t.Fatalf("%s: expected column %s, got %s", c.ID, want[c.ID], c.ColumnID)
}
}
}
func TestLoadFlowCards_MissingStatusDefaultsPending(t *testing.T) {
dir := t.TempDir()
writeFixture(t, dir, "0010-nostatus.md", "---\nid: 0010\nname: empty\n---\nbody\n")
cards, err := loadFlowCards(dir)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(cards) != 1 {
t.Fatalf("expected 1 card")
}
if cards[0].ColumnID != "Pending" {
t.Fatalf("expected Pending column, got %q", cards[0].ColumnID)
}
}
func TestLoadFlowCards_MalformedDoesNotCrash(t *testing.T) {
dir := t.TempDir()
writeFixture(t, dir, "0011-bad.md", "---\nid: 0011\nstatus: pending\n : malformed yaml\n---\nbody\n")
cards, err := loadFlowCards(dir)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(cards) != 1 || cards[0].ParseError == "" {
t.Fatalf("expected 1 card with ParseError, got %#v", cards)
}
}
-163
View File
@@ -1,163 +0,0 @@
package main
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
)
// PatchFrontmatterField rewrites the value of a single YAML frontmatter key
// in the file at filePath, preserving everything else byte-for-byte.
//
// The file MUST begin with a "---" frontmatter delimiter (issue 0100 canonical).
// The function:
// - locates the frontmatter block delimited by leading "---" and closing "---"
// - finds a line matching "<key>:" at the root level (no indent)
// - replaces the value portion (keeping the key) with newValue
// - writes back atomically via temp file + rename
//
// If the key does not exist, it is inserted just before the closing "---" line.
// The function does NOT validate that newValue is YAML-safe; callers should
// pass plain scalars (no embedded newlines).
func PatchFrontmatterField(filePath, key, newValue string) error {
if key == "" {
return fmt.Errorf("PatchFrontmatterField: empty key")
}
if strings.ContainsAny(newValue, "\n\r") {
return fmt.Errorf("PatchFrontmatterField: newValue must not contain newlines")
}
raw, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("read %s: %w", filePath, err)
}
out, err := patchFrontmatterBytes(raw, key, newValue)
if err != nil {
return err
}
dir := filepath.Dir(filePath)
tmp, err := os.CreateTemp(dir, ".fm.*.tmp")
if err != nil {
return fmt.Errorf("tmp: %w", err)
}
tmpName := tmp.Name()
if _, err := tmp.Write(out); err != nil {
tmp.Close()
os.Remove(tmpName)
return fmt.Errorf("write tmp: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return fmt.Errorf("close tmp: %w", err)
}
// Preserve original file mode if possible.
if info, err := os.Stat(filePath); err == nil {
_ = os.Chmod(tmpName, info.Mode())
}
if err := os.Rename(tmpName, filePath); err != nil {
os.Remove(tmpName)
return fmt.Errorf("rename: %w", err)
}
return nil
}
func patchFrontmatterBytes(raw []byte, key, newValue string) ([]byte, error) {
// Detect line ending convention (default to LF).
eol := []byte("\n")
if bytes.Contains(raw, []byte("\r\n")) {
eol = []byte("\r\n")
}
// Find frontmatter boundaries. First line must be "---".
lines := splitKeepEOL(raw)
if len(lines) == 0 {
return nil, fmt.Errorf("empty file")
}
if !isDashDashDash(lines[0]) {
return nil, fmt.Errorf("no frontmatter (file does not start with '---')")
}
closeIdx := -1
for i := 1; i < len(lines); i++ {
if isDashDashDash(lines[i]) {
closeIdx = i
break
}
}
if closeIdx < 0 {
return nil, fmt.Errorf("no frontmatter close delimiter")
}
keyPrefix := key + ":"
found := -1
for i := 1; i < closeIdx; i++ {
trimmed := strings.TrimRight(strings.TrimRight(string(lines[i]), "\n"), "\r")
// Only match top-level keys (no leading whitespace).
if !strings.HasPrefix(trimmed, keyPrefix) {
continue
}
// Ensure next char after key is ':' followed by space or EOL (avoid prefix collisions).
afterKey := trimmed[len(keyPrefix):]
if afterKey != "" && afterKey[0] != ' ' && afterKey[0] != '\t' {
continue
}
found = i
break
}
var buf bytes.Buffer
if found >= 0 {
// Replace just the value on that line, preserving EOL.
original := lines[found]
// Compute EOL preserved at end.
lineEOL := []byte{}
if bytes.HasSuffix(original, []byte("\r\n")) {
lineEOL = []byte("\r\n")
} else if bytes.HasSuffix(original, []byte("\n")) {
lineEOL = []byte("\n")
}
newLine := []byte(key + ": " + newValue)
newLine = append(newLine, lineEOL...)
for i, l := range lines {
if i == found {
buf.Write(newLine)
} else {
buf.Write(l)
}
}
} else {
// Insert just before closeIdx.
insertion := []byte(key + ": " + newValue)
insertion = append(insertion, eol...)
for i, l := range lines {
if i == closeIdx {
buf.Write(insertion)
}
buf.Write(l)
}
}
return buf.Bytes(), nil
}
// splitKeepEOL splits raw into lines, preserving the trailing EOL on each line.
func splitKeepEOL(raw []byte) [][]byte {
var lines [][]byte
start := 0
for i := 0; i < len(raw); i++ {
if raw[i] == '\n' {
lines = append(lines, raw[start:i+1])
start = i + 1
}
}
if start < len(raw) {
lines = append(lines, raw[start:])
}
return lines
}
func isDashDashDash(line []byte) bool {
s := strings.TrimRight(strings.TrimRight(string(line), "\n"), "\r")
return s == "---"
}
-104
View File
@@ -1,104 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestPatchFrontmatterField_UpdateExistingKey(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "0119-x.md")
original := "---\n" +
"id: \"0119\"\n" +
"title: \"Test\"\n" +
"status: pendiente\n" +
"priority: alta\n" +
"tags: [a, b]\n" +
"---\n" +
"\n" +
"# Body heading\n" +
"\n" +
"Some body.\n"
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := PatchFrontmatterField(path, "status", "en-curso"); err != nil {
t.Fatalf("patch: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
gotStr := string(got)
if !strings.Contains(gotStr, "status: en-curso") {
t.Fatalf("expected status: en-curso, got:\n%s", gotStr)
}
// Preserve everything else.
for _, line := range []string{
`id: "0119"`,
`title: "Test"`,
`priority: alta`,
`tags: [a, b]`,
`# Body heading`,
`Some body.`,
} {
if !strings.Contains(gotStr, line) {
t.Fatalf("line %q lost after patch, got:\n%s", line, gotStr)
}
}
// Ensure original status line is gone (no duplicate).
if strings.Count(gotStr, "status:") != 1 {
t.Fatalf("expected exactly one status: line, got %d:\n%s", strings.Count(gotStr, "status:"), gotStr)
}
}
func TestPatchFrontmatterField_InsertMissingKey(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "0119-x.md")
original := "---\n" +
"id: \"0119\"\n" +
"title: \"Test\"\n" +
"---\n" +
"body\n"
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := PatchFrontmatterField(path, "status", "done"); err != nil {
t.Fatalf("patch: %v", err)
}
got, _ := os.ReadFile(path)
gotStr := string(got)
if !strings.Contains(gotStr, "status: done") {
t.Fatalf("missing inserted status line:\n%s", gotStr)
}
if !strings.Contains(gotStr, "body") {
t.Fatalf("body lost:\n%s", gotStr)
}
}
func TestPatchFrontmatterField_NoFrontmatter(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "plain.md")
_ = os.WriteFile(path, []byte("just a body\n"), 0o644)
err := PatchFrontmatterField(path, "status", "done")
if err == nil {
t.Fatalf("expected error for missing frontmatter")
}
}
func TestPatchFrontmatterField_AtomicNoLeftovers(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "0119.md")
_ = os.WriteFile(path, []byte("---\nid: \"0119\"\nstatus: pendiente\n---\n"), 0o644)
if err := PatchFrontmatterField(path, "status", "en-curso"); err != nil {
t.Fatalf("patch: %v", err)
}
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".fm.") || strings.HasSuffix(e.Name(), ".tmp") {
t.Fatalf("leftover tmp file: %s", e.Name())
}
}
}
+4 -5
View File
@@ -1,11 +1,10 @@
module kanban module kanban_cpp_backend
go 1.25.0 go 1.25.0
require ( require (
fn-registry v0.0.0-00010101000000-000000000000 fn-registry v0.0.0-00010101000000-000000000000
github.com/fsnotify/fsnotify v1.10.1 github.com/mattn/go-sqlite3 v1.14.37
gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
@@ -14,6 +13,7 @@ require (
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/apache/arrow-go/v18 v18.1.0 // indirect github.com/apache/arrow-go/v18 v18.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect github.com/go-faster/errors v0.7.1 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
@@ -27,7 +27,6 @@ require (
github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/marcboeker/go-duckdb v1.8.5 // 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/paulmach/orb v0.12.0 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/asm v1.2.1 // indirect
@@ -39,13 +38,13 @@ require (
golang.org/x/crypto v0.50.0 // indirect golang.org/x/crypto v0.50.0 // indirect
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect
golang.org/x/mod v0.34.0 // 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/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.43.0 // indirect
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.43.0 // indirect golang.org/x/tools v0.43.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // 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 nhooyr.io/websocket v1.8.17 // indirect
) )
+2 -4
View File
@@ -13,8 +13,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= 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/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 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
@@ -126,8 +126,6 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/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-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.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-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-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-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+366 -309
View File
@@ -1,360 +1,417 @@
package main package main
import ( import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http" "net/http"
"os"
"strings" "strings"
"time"
"fn-registry/functions/infra" "fn-registry/functions/infra"
) )
const maxBodyBytes = 1 << 20 // 1 MiB const agentRunnerBase = "http://127.0.0.1:8486"
func badRequest(w http.ResponseWriter, msg string) { func (s *Server) registerRoutes(mux *http.ServeMux) {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusBadRequest, Code: "bad_request", Message: msg}) mux.HandleFunc("/api/health", s.handleHealth)
mux.HandleFunc("/api/issues", s.handleIssues)
mux.HandleFunc("/api/issues/", s.handleIssueByID)
mux.HandleFunc("/api/flows", s.handleFlows)
mux.HandleFunc("/api/flows/", s.handleFlowByID)
mux.HandleFunc("/api/meta", s.handleMeta)
mux.HandleFunc("/api/sse", s.handleSSE)
mux.HandleFunc("/api/agent_status", s.handleAgentStatus)
mux.HandleFunc("/api/agent_launch", s.handleAgentLaunch)
} }
func notFound(w http.ResponseWriter, msg string) { func writeJSON(w http.ResponseWriter, status int, v any) {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusNotFound, Code: "not_found", Message: msg}) w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
} }
func serverError(w http.ResponseWriter, err error) { func writeErr(w http.ResponseWriter, status int, msg string) {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusInternalServerError, Code: "internal", Message: err.Error()}) writeJSON(w, status, map[string]string{"error": msg})
} }
// GET /api/board → { columns: [...], cards: [...] } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
func handleGetBoard(db *DB) http.HandlerFunc { var ni, nf int
return func(w http.ResponseWriter, r *http.Request) { s.db.QueryRow(`SELECT COUNT(*) FROM issues`).Scan(&ni)
cols, err := db.ListColumns() s.db.QueryRow(`SELECT COUNT(*) FROM flows`).Scan(&nf)
if err != nil { writeJSON(w, 200, map[string]any{
serverError(w, err) "ok": true,
"version": "0.1.0",
"count_issues": ni,
"count_flows": nf,
"issues_dir": s.issuesDir,
"flows_dir": s.flowsDir,
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
func (s *Server) handleIssues(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeErr(w, 405, "method not allowed")
return
}
q := r.URL.Query()
where := []string{"1=1"}
args := []any{}
if v := q.Get("status"); v != "" {
where = append(where, "status=?")
args = append(args, v)
}
if v := q.Get("priority"); v != "" {
where = append(where, "priority=?")
args = append(args, v)
}
if v := q.Get("scope"); v != "" {
where = append(where, "scope=?")
args = append(args, v)
}
if v := q.Get("domain"); v != "" {
where = append(where, "domain_json LIKE ?")
args = append(args, "%\""+v+"\"%")
}
if v := q.Get("tag"); v != "" {
where = append(where, "tags_json LIKE ?")
args = append(args, "%\""+v+"\"%")
}
if v := q.Get("completed"); v != "" {
if v == "true" || v == "1" {
where = append(where, "completed=1")
} else {
where = append(where, "completed=0")
}
}
sql := "SELECT id,title,status,type,scope,priority,domain_json,tags_json,depends_json,blocks_json,related_json,flow_id,file_path,completed,created_at,updated_at FROM issues WHERE " + strings.Join(where, " AND ") + " ORDER BY id ASC"
rows, err := s.db.Query(sql, args...)
if err != nil {
writeErr(w, 500, err.Error())
return
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var (
id, title, status, typ, scope, priority string
domJ, tagJ, depJ, blkJ, relJ, flow, path string
completedInt int
createdAt, updatedAt string
)
if err := rows.Scan(&id, &title, &status, &typ, &scope, &priority, &domJ, &tagJ, &depJ, &blkJ, &relJ, &flow, &path, &completedInt, &createdAt, &updatedAt); err != nil {
writeErr(w, 500, err.Error())
return return
} }
cards, err := db.ListCardsWithTime() out = append(out, map[string]any{
if err != nil { "id": id,
serverError(w, err) "title": title,
return "status": status,
} "type": typ,
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{ "scope": scope,
"columns": cols, "priority": priority,
"cards": cards, "domain": parseJSONArr(domJ),
"tags": parseJSONArr(tagJ),
"depends": parseJSONArr(depJ),
"blocks": parseJSONArr(blkJ),
"related": parseJSONArr(relJ),
"flow": flow,
"file_path": path,
"completed": completedInt == 1,
"created": createdAt,
"updated": updatedAt,
}) })
} }
writeJSON(w, 200, out)
} }
// POST /api/columns { name } func (s *Server) handleIssueByID(w http.ResponseWriter, r *http.Request) {
func handleCreateColumn(db *DB) http.HandlerFunc { id := strings.TrimPrefix(r.URL.Path, "/api/issues/")
return func(w http.ResponseWriter, r *http.Request) { if id == "" {
var body struct { writeErr(w, 400, "missing id")
Name string `json:"name"` return
} }
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { switch r.Method {
badRequest(w, err.Error()) case "GET":
return s.getIssue(w, id)
} case "PATCH":
if strings.TrimSpace(body.Name) == "" { s.patchIssue(w, r, id)
badRequest(w, "name required") default:
return writeErr(w, 405, "method not allowed")
}
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 (s *Server) getIssue(w http.ResponseWriter, id string) {
func handleUpdateColumn(db *DB) http.HandlerFunc { row := s.db.QueryRow(`SELECT id,title,status,type,scope,priority,domain_json,tags_json,depends_json,blocks_json,related_json,flow_id,body,file_path,completed,created_at,updated_at FROM issues WHERE id=?`, id)
return func(w http.ResponseWriter, r *http.Request) { var (
id := r.PathValue("id") iid, title, status, typ, scope, priority string
var body struct { domJ, tagJ, depJ, blkJ, relJ, flow, body, path string
Name *string `json:"name"` completedInt int
Position *int `json:"position"` createdAt, updatedAt string
Location *string `json:"location"` )
Width *int `json:"width"` if err := row.Scan(&iid, &title, &status, &typ, &scope, &priority, &domJ, &tagJ, &depJ, &blkJ, &relJ, &flow, &body, &path, &completedInt, &createdAt, &updatedAt); err != nil {
WIPLimit *int `json:"wip_limit"` writeErr(w, 404, "not found")
IsDone *bool `json:"is_done"` return
}
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)
} }
writeJSON(w, 200, map[string]any{
"id": iid, "title": title, "status": status, "type": typ, "scope": scope, "priority": priority,
"domain": parseJSONArr(domJ), "tags": parseJSONArr(tagJ),
"depends": parseJSONArr(depJ), "blocks": parseJSONArr(blkJ), "related": parseJSONArr(relJ),
"flow": flow, "body": body, "file_path": path, "completed": completedInt == 1,
"created": createdAt, "updated": updatedAt,
})
} }
// DELETE /api/columns/{id} func (s *Server) patchIssue(w http.ResponseWriter, r *http.Request, id string) {
func handleDeleteColumn(db *DB) http.HandlerFunc { var patch map[string]any
return func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
id := r.PathValue("id") writeErr(w, 400, "bad json")
if err := db.DeleteColumn(id); err != nil { return
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
} }
var filePath string
if err := s.db.QueryRow(`SELECT file_path FROM issues WHERE id=?`, id).Scan(&filePath); err != nil {
writeErr(w, 404, "not found")
return
}
iss, body, err := infra.ParseIssueMd(filePath)
if err != nil {
writeErr(w, 500, fmt.Sprintf("parse: %v", err))
return
}
applyPatch(&iss, patch)
iss.Updated = time.Now().UTC().Format("2006-01-02")
if err := infra.WriteIssueMd(filePath, iss, body); err != nil {
writeErr(w, 500, fmt.Sprintf("write: %v", err))
return
}
info, _ := os.Stat(filePath)
if info != nil {
iss.MtimeNs = info.ModTime().UnixNano()
}
iss.FilePath = filePath
iss.Completed = strings.Contains(filePath, "/completed/")
if err := s.upsertIssueRow(iss); err != nil {
writeErr(w, 500, err.Error())
return
}
s.hub.broadcast(SSEEvent{Type: "updated", ID: id, Path: filePath})
s.getIssue(w, id)
} }
// POST /api/columns/reorder { ids: [...] } func applyPatch(iss *infra.Issue, patch map[string]any) {
func handleReorderColumns(db *DB) http.HandlerFunc { if v, ok := patch["status"].(string); ok {
return func(w http.ResponseWriter, r *http.Request) { iss.Status = v
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)
} }
} if v, ok := patch["priority"].(string); ok {
iss.Priority = v
// 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
}
c, err := db.CreateCard(body.ColumnID, body.Requester, body.Title, body.Description, "")
if err == nil && body.AssigneeID != nil && *body.AssigneeID != "" {
err = db.UpdateCardWithActor(c.ID, CardPatch{AssigneeID: body.AssigneeID, HasAssignee: true}, "")
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}, "")
if err == nil {
c.Tags = tags
}
}
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusCreated, c)
} }
} if v, ok := patch["scope"].(string); ok {
iss.Scope = v
// PATCH /api/cards/{id} { requester?, title?, description?, color? } }
func handleUpdateCard(db *DB) http.HandlerFunc { if v, ok := patch["title"].(string); ok {
return func(w http.ResponseWriter, r *http.Request) { iss.Title = v
id := r.PathValue("id") }
var raw map[string]any if v, ok := patch["type"].(string); ok {
if err := infra.HTTPParseBody(r, &raw, maxBodyBytes); err != nil { iss.Type = v
badRequest(w, err.Error()) }
return if v, ok := patch["flow"].(string); ok {
} iss.Flow = v
patch := CardPatch{} }
if v, ok := raw["requester"].(string); ok { for _, k := range []string{"domain", "tags", "depends", "blocks", "related"} {
patch.Requester = &v if raw, ok := patch[k]; ok {
} arr := []string{}
if v, ok := raw["title"].(string); ok { if xs, ok := raw.([]any); ok {
patch.Title = &v for _, x := range xs {
} if s, ok := x.(string); ok {
if v, ok := raw["description"].(string); ok { arr = append(arr, s)
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 switch k {
} case "domain":
if err := db.UpdateCardWithActor(id, patch, ""); err != nil { iss.Domain = arr
serverError(w, err) case "tags":
return iss.Tags = arr
} case "depends":
w.WriteHeader(http.StatusNoContent) iss.Depends = arr
} case "blocks":
} iss.Blocks = arr
case "related":
// DELETE /api/cards/{id} iss.Related = arr
func handleDeleteCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := db.DeleteCardWithActor(id, ""); 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
}
if err := db.MoveCard(id, body.ColumnID, body.OrderedIDs, ""); err != nil {
if strings.Contains(err.Error(), "not found") {
notFound(w, "card not found")
return
} }
serverError(w, err)
return
} }
w.WriteHeader(http.StatusNoContent)
} }
} }
// POST /api/cards/{id}/duplicate func (s *Server) handleFlows(w http.ResponseWriter, r *http.Request) {
func handleDuplicateCard(db *DB) http.HandlerFunc { rows, err := s.db.Query(`SELECT id,title,status,kind,tags_json,file_path FROM flows ORDER BY id ASC`)
return func(w http.ResponseWriter, r *http.Request) { if err != nil {
id := r.PathValue("id") writeErr(w, 500, err.Error())
c, err := db.DuplicateCard(id, "") return
if err != nil { }
if strings.Contains(err.Error(), "not found") { defer rows.Close()
notFound(w, "card not found") out := []map[string]any{}
return for rows.Next() {
} var id, title, status, kind, tagJ, path string
serverError(w, err) rows.Scan(&id, &title, &status, &kind, &tagJ, &path)
return out = append(out, map[string]any{
"id": id, "title": title, "status": status, "kind": kind,
"tags": parseJSONArr(tagJ), "file_path": path,
})
}
writeJSON(w, 200, out)
}
func (s *Server) handleFlowByID(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/flows/")
if id == "" {
writeErr(w, 400, "missing id")
return
}
row := s.db.QueryRow(`SELECT id,title,status,kind,tags_json,body,file_path FROM flows WHERE id=?`, id)
var iid, title, status, kind, tagJ, body, path string
if err := row.Scan(&iid, &title, &status, &kind, &tagJ, &body, &path); err != nil {
writeErr(w, 404, "not found")
return
}
writeJSON(w, 200, map[string]any{
"id": iid, "title": title, "status": status, "kind": kind,
"tags": parseJSONArr(tagJ), "body": body, "file_path": path,
})
}
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{
"statuses": []string{"ideas", "pendiente", "in-progress", "bloqueado", "completado", "deferred", "descartado"},
"board_columns": []string{"ideas", "pendiente", "in-progress", "completado"},
"priorities": []string{"critica", "alta", "media", "baja"},
"scopes": []string{"registry-only", "app-scoped", "multi-app", "cross-stack"},
"types": []string{"feature", "bugfix", "refactor", "docs", "chore", "research", "infra", "app", "spike", "epic", "planning"},
})
}
// GET /api/agent_status — proxies agent_runner_api running runs, returns map issue_id -> run_id
func (s *Server) handleAgentStatus(w http.ResponseWriter, r *http.Request) {
resp, err := http.Get(agentRunnerBase + "/api/runs?status=running")
if err != nil {
writeJSON(w, 200, map[string]any{"available": false, "active": map[string]string{}})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var runs []map[string]any
if err := json.Unmarshal(body, &runs); err != nil {
writeJSON(w, 200, map[string]any{"available": false, "active": map[string]string{}})
return
}
active := map[string]string{}
for _, run := range runs {
issueID, _ := run["issue_id"].(string)
runID, _ := run["id"].(string)
if issueID != "" && runID != "" {
active[issueID] = runID
}
}
writeJSON(w, 200, map[string]any{"available": true, "active": active})
}
// POST /api/agent_launch {"issue_id":"NNNN"} — forwards to agent_runner_api
func (s *Server) handleAgentLaunch(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeErr(w, 405, "method not allowed")
return
}
var req struct {
IssueID string `json:"issue_id"`
Mode string `json:"mode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, "bad json")
return
}
if req.IssueID == "" {
writeErr(w, 400, "issue_id required")
return
}
if req.Mode == "" {
req.Mode = "fix-issue"
}
payload, _ := json.Marshal(map[string]string{
"issue_id": req.IssueID,
"mode": req.Mode,
"kanban_app": "kanban_cpp",
})
resp, err := http.Post(agentRunnerBase+"/api/runs", "application/json", bytes.NewReader(payload))
if err != nil {
writeErr(w, 502, fmt.Sprintf("agent_runner_api unreachable: %v", err))
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
writeErr(w, resp.StatusCode, string(body))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
w.Write(body)
}
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
writeErr(w, 500, "streaming unsupported")
return
}
ch := s.hub.subscribe()
defer s.hub.unsubscribe(ch)
pingTick := time.NewTicker(15 * time.Second)
defer pingTick.Stop()
for {
select {
case <-r.Context().Done():
return
case ev := <-ch:
b, _ := json.Marshal(ev)
fmt.Fprintf(w, "data: %s\n\n", b)
flusher.Flush()
case <-pingTick.C:
fmt.Fprintf(w, ": ping\n\n")
flusher.Flush()
} }
infra.HTTPJSONResponse(w, http.StatusCreated, c)
} }
} }
// GET /api/trash func parseJSONArr(s string) []string {
func handleListTrash(db *DB) http.HandlerFunc { if s == "" {
return func(w http.ResponseWriter, r *http.Request) { return []string{}
cards, err := db.ListDeletedCards()
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, cards)
} }
var arr []string
if err := json.Unmarshal([]byte(s), &arr); err != nil {
return []string{}
}
return arr
} }
// POST /api/cards/{id}/restore func withMiddleware(h http.Handler) http.Handler {
func handleRestoreCard(db *DB) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*")
id := r.PathValue("id") w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,OPTIONS")
if err := db.RestoreCardWithActor(id, ""); err != nil { w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization")
serverError(w, err) if r.Method == "OPTIONS" {
w.WriteHeader(204)
return return
} }
w.WriteHeader(http.StatusNoContent) start := time.Now()
} h.ServeHTTP(w, r)
} fmt.Printf("[%s] %s %s %s\n", time.Now().Format("15:04:05"), r.Method, r.URL.Path, time.Since(start))
})
// 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, flags *FeatureFlags) []infra.Route {
routes := []infra.Route{
{Method: "GET", Path: "/api/flags", Handler: handleListFlags(flags)},
{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: "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/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: "GET", Path: "/api/tags", Handler: handleListTags(db)},
{Method: "GET", Path: "/api/requesters", Handler: handleListRequesters(db)},
}
routes = append(routes, boardRoutes()...)
return routes
}
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)
}
} }
-346
View File
@@ -1,346 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"fn-registry/functions/infra"
)
// agentRunnerEndpoint returns the agent_runner_api base URL.
// Override with KANBAN_AGENT_RUNNER_API env var.
func agentRunnerEndpoint() string {
if v := strings.TrimSpace(os.Getenv("KANBAN_AGENT_RUNNER_API")); v != "" {
return v
}
return "http://127.0.0.1:8486"
}
// allowedStatusForBoard returns the canonical statuses a PATCH can set on a
// given board. Anything else returns 400 (taxonomy issue 0103).
func allowedStatusForBoard(board string) []string {
switch board {
case "issues":
return []string{"pendiente", "en-curso", "en-revisión", "en-revision", "done", "deferred"}
case "flows":
return []string{"pending", "running", "done", "deferred"}
default:
return nil
}
}
func isAllowedStatus(board, status string) bool {
allowed := allowedStatusForBoard(board)
for _, a := range allowed {
if a == status {
return true
}
}
return false
}
// dirAndCacheForBoard returns the filesystem directory + cache for a board
// name. Unknown boards yield ("", nil).
func dirAndCacheForBoard(board string) (string, *cardsCache, func(string) string) {
switch board {
case "issues":
return issuesDir(), issuesCache, mapIssueStatusToColumn
case "flows":
return flowsDir(), flowsCache, mapFlowStatusToColumn
default:
return "", nil, nil
}
}
// findCardFile locates the .md file in dir whose leading numeric id matches
// the given card id. Returns "" if not found.
func findCardFile(dir, id string) (string, error) {
id = strings.TrimSpace(id)
if id == "" {
return "", nil
}
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasSuffix(strings.ToLower(name), ".md") {
continue
}
if isSkippedMarkdown(name) {
continue
}
if deriveIDFromFilename(name) == id {
return filepath.Join(dir, name), nil
}
}
return "", nil
}
// GET /api/boards/{board}/cards
func handleListBoardCards() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
board := r.PathValue("board")
dir, cache, _ := dirAndCacheForBoard(board)
if dir == "" {
notFound(w, "unknown board: "+board)
return
}
if cached, ok := cache.get(); ok {
infra.HTTPJSONResponse(w, http.StatusOK, cached)
return
}
var (
cards []IssueCard
err error
)
switch board {
case "issues":
cards, err = loadIssueCards(dir)
case "flows":
cards, err = loadFlowCards(dir)
}
if err != nil {
serverError(w, err)
return
}
cache.set(cards)
infra.HTTPJSONResponse(w, http.StatusOK, cards)
}
}
// PATCH /api/boards/{board}/cards/{id} body: { status: "..." }
func handlePatchBoardCard() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
board := r.PathValue("board")
id := r.PathValue("id")
dir, cache, _ := dirAndCacheForBoard(board)
if dir == "" {
notFound(w, "unknown board: "+board)
return
}
var body struct {
Status string `json:"status"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
status := strings.TrimSpace(body.Status)
if status == "" {
badRequest(w, "status required")
return
}
if !isAllowedStatus(board, status) {
badRequest(w, fmt.Sprintf("invalid status for board %q: %q (allowed: %s)",
board, status, strings.Join(allowedStatusForBoard(board), ", ")))
return
}
file, err := findCardFile(dir, id)
if err != nil {
serverError(w, err)
return
}
if file == "" {
notFound(w, fmt.Sprintf("card %q not found on board %q", id, board))
return
}
// Patch status; also bump updated to today (YYYY-MM-DD).
if err := PatchFrontmatterField(file, "status", status); err != nil {
serverError(w, err)
return
}
_ = PatchFrontmatterField(file, "updated", time.Now().UTC().Format("2006-01-02"))
cache.invalidate()
if globalHub != nil {
globalHub.Broadcast(ServerEvent{
Board: board,
CardID: id,
Action: "updated",
EventType: "card_changed",
})
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{
"ok": true,
"id": id,
"board": board,
"status": status,
"file": file,
})
}
}
// POST /api/boards/{board}/cards/{id}/launch
// Proxies to agent_runner_api at /api/runs with payload including the issue id
// and the DoD items pulled from the .md frontmatter. If the runner is
// unreachable, returns 502 with a suggestion.
func handleLaunchBoardCard() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
board := r.PathValue("board")
id := r.PathValue("id")
dir, _, statusMapper := dirAndCacheForBoard(board)
if dir == "" {
notFound(w, "unknown board: "+board)
return
}
file, err := findCardFile(dir, id)
if err != nil {
serverError(w, err)
return
}
if file == "" {
notFound(w, fmt.Sprintf("card %q not found on board %q", id, board))
return
}
card, err := parseCardFile(file, statusMapper)
if err != nil {
serverError(w, err)
return
}
// Drain incoming body (optional overrides from client). We do not
// forward it as-is to avoid trust issues; we build a clean payload.
_, _ = io.Copy(io.Discard, r.Body)
payload := map[string]any{
"board": board,
"issue_id": card.ExternalID,
"title": card.Title,
"priority": card.Priority,
"type": card.Type,
"flow_id": card.FlowID,
"dod_items": card.DoDItems,
"file_path": card.FilePath,
"launched_at": time.Now().UTC().Format(time.RFC3339),
}
buf, _ := json.Marshal(payload)
url := strings.TrimRight(agentRunnerEndpoint(), "/") + "/api/runs"
client := &http.Client{Timeout: 5 * time.Second}
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, url, bytes.NewReader(buf))
if err != nil {
serverError(w, err)
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{
Status: http.StatusBadGateway,
Code: "agent_runner_unreachable",
Message: fmt.Sprintf("could not reach agent_runner_api at %s: %v (suggestion: start agent_runner_api service)", url, err),
})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// Forward status + body verbatim so the UI can show backend errors.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(body)
}
}
// GET /api/boards/{board}/stream (text/event-stream)
//
// Long-lived SSE connection that emits one event per card change on the
// given board. Events:
// - card_added {"board","card_id","action":"created"}
// - card_changed {"board","card_id","action":"updated"}
// - card_removed {"board","card_id","action":"deleted"}
// - keepalive ts=<unix>
//
// Events for OTHER boards are filtered out (one subscription per board).
// A keepalive is emitted every 15s to prevent proxy timeouts.
func handleBoardStream() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
board := r.PathValue("board")
dir, _, _ := dirAndCacheForBoard(board)
if dir == "" {
notFound(w, "unknown board: "+board)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
if globalHub == nil {
http.Error(w, "hub not initialised", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher.Flush()
ch := globalHub.Subscribe()
defer globalHub.Unsubscribe(ch)
// Honor Last-Event-ID is not supported yet (TODO: replay buffer).
_ = r.Header.Get("Last-Event-ID")
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if _, err := fmt.Fprintf(w, "event: keepalive\ndata: ts=%d\n\n", time.Now().Unix()); err != nil {
return
}
flusher.Flush()
case ev, ok := <-ch:
if !ok {
return
}
if ev.Board != board {
continue
}
evType := ev.EventType
if evType == "" {
evType = "card_changed"
}
payload, err := json.Marshal(map[string]string{
"board": ev.Board,
"card_id": ev.CardID,
"action": ev.Action,
})
if err != nil {
continue
}
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", evType, payload); err != nil {
return
}
flusher.Flush()
}
}
}
}
// boardRoutes returns the additional routes for issues/flows boards. Called
// from apiRoutes() in handlers.go.
func boardRoutes() []infra.Route {
return []infra.Route{
{Method: "GET", Path: "/api/boards/{board}/cards", Handler: handleListBoardCards()},
{Method: "GET", Path: "/api/boards/{board}/stream", Handler: handleBoardStream()},
{Method: "PATCH", Path: "/api/boards/{board}/cards/{id}", Handler: handlePatchBoardCard()},
{Method: "POST", Path: "/api/boards/{board}/cards/{id}/launch", Handler: handleLaunchBoardCard()},
}
}
+137
View File
@@ -0,0 +1,137 @@
package main
import (
"database/sql"
"encoding/json"
"log"
"path/filepath"
"strings"
"fn-registry/functions/infra"
)
func (s *Server) ingestAll() error {
issues, err := infra.ScanIssuesDir(s.issuesDir)
if err != nil {
return err
}
for _, iss := range issues {
if err := s.upsertIssueRow(iss); err != nil {
log.Printf("upsert issue %s: %v", iss.ID, err)
}
}
flows, err := infra.ScanFlowsDir(s.flowsDir)
if err != nil {
return err
}
for _, fl := range flows {
if err := s.upsertFlowRow(fl); err != nil {
log.Printf("upsert flow %s: %v", fl.ID, err)
}
}
log.Printf("ingested %d issues, %d flows", len(issues), len(flows))
return nil
}
func (s *Server) upsertIssueRow(iss infra.Issue) error {
body, err := readBody(iss.FilePath)
if err != nil {
return err
}
_, err = s.db.Exec(`
INSERT INTO issues (id,title,status,type,scope,priority,domain_json,tags_json,depends_json,blocks_json,related_json,flow_id,body,file_path,mtime_ns,created_at,updated_at,completed)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
title=excluded.title, status=excluded.status, type=excluded.type, scope=excluded.scope,
priority=excluded.priority, domain_json=excluded.domain_json, tags_json=excluded.tags_json,
depends_json=excluded.depends_json, blocks_json=excluded.blocks_json, related_json=excluded.related_json,
flow_id=excluded.flow_id, body=excluded.body, file_path=excluded.file_path, mtime_ns=excluded.mtime_ns,
created_at=excluded.created_at, updated_at=excluded.updated_at, completed=excluded.completed
`,
iss.ID, iss.Title, iss.Status, iss.Type, iss.Scope, iss.Priority,
jsonOrEmpty(iss.Domain), jsonOrEmpty(iss.Tags),
jsonOrEmpty(iss.Depends), jsonOrEmpty(iss.Blocks), jsonOrEmpty(iss.Related),
iss.Flow, string(body), iss.FilePath, iss.MtimeNs,
iss.Created, iss.Updated, boolInt(iss.Completed),
)
return err
}
func (s *Server) upsertFlowRow(fl infra.Flow) error {
body, err := readBody(fl.FilePath)
if err != nil {
return err
}
_, err = s.db.Exec(`
INSERT INTO flows (id,title,status,kind,tags_json,body,file_path,mtime_ns)
VALUES (?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
title=excluded.title, status=excluded.status, kind=excluded.kind,
tags_json=excluded.tags_json, body=excluded.body, file_path=excluded.file_path, mtime_ns=excluded.mtime_ns
`,
fl.ID, fl.Title, fl.Status, fl.Kind, jsonOrEmpty(fl.Tags),
string(body), fl.FilePath, fl.MtimeNs,
)
return err
}
func readBody(path string) ([]byte, error) {
_, body, err := infra.ParseIssueMd(path)
if err != nil {
return nil, err
}
return body, nil
}
func jsonOrEmpty(xs []string) string {
if len(xs) == 0 {
return "[]"
}
b, _ := json.Marshal(xs)
return string(b)
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
func (s *Server) deleteIssue(id string) error {
_, err := s.db.Exec(`DELETE FROM issues WHERE id=?`, id)
return err
}
func (s *Server) deleteFlow(id string) error {
_, err := s.db.Exec(`DELETE FROM flows WHERE id=?`, id)
return err
}
func issueIDFromPath(path, issuesDir string) string {
rel, err := filepath.Rel(issuesDir, path)
if err != nil {
return ""
}
base := filepath.Base(rel)
if !strings.HasSuffix(base, ".md") {
return ""
}
name := strings.TrimSuffix(base, ".md")
for i, r := range name {
if r == '-' {
return name[:i]
}
if r < '0' || r > '9' {
if i == 0 {
return ""
}
return name[:i]
}
}
return name
}
var _ = sql.ErrNoRows
-387
View File
@@ -1,387 +0,0 @@
package main
import (
"bytes"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"gopkg.in/yaml.v3"
)
// IssueCard is the on-the-wire card representation for issues/flows boards.
// It mirrors enough of the regular Card shape so the kanban UI can render it,
// but it is built from a .md frontmatter file (NOT from operations.db).
type IssueCard struct {
ID string `json:"id"` // canonical "external_id" (e.g. "0119")
ExternalID string `json:"external_id"` // same as ID, for clarity
Title string `json:"title"`
Description string `json:"description"` // first ~5 lines of body
Status string `json:"status"` // raw frontmatter status (pendiente/en-curso/done/deferred)
ColumnID string `json:"column_id"` // mapped column ("Backlog"/"Doing"/"Review"/"Done"/"Deferred")
Priority string `json:"priority"` // alta/media/baja
Type string `json:"type"` // feature/bug/chore/...
Tag string `json:"tag"` // same as type, for card.tag convenience
Tags []string `json:"tags"`
FlowID string `json:"flow_id"` // frontmatter flow
DoDItems []string `json:"dod_items"` // from dod_evidence_schema if present
UpdatedAt string `json:"updated_at"`
CreatedAt string `json:"created_at"`
FilePath string `json:"file_path"` // relative path under registry root
ParseError string `json:"parse_error,omitempty"`
}
type issueFrontmatter struct {
ID string `yaml:"id"`
Title string `yaml:"title"`
Status string `yaml:"status"`
Type string `yaml:"type"`
Priority string `yaml:"priority"`
Tags []string `yaml:"tags"`
Flow string `yaml:"flow"`
Created string `yaml:"created"`
Updated string `yaml:"updated"`
DoDEvidenceSchema []any `yaml:"dod_evidence_schema"`
Extra map[string]interface{} `yaml:",inline"`
}
// --- in-memory cache ---------------------------------------------------------
type cardsCache struct {
mu sync.Mutex
at time.Time
cards []IssueCard
dir string
ttl time.Duration
}
func (c *cardsCache) get() ([]IssueCard, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if time.Since(c.at) < c.ttl && c.cards != nil {
out := make([]IssueCard, len(c.cards))
copy(out, c.cards)
return out, true
}
return nil, false
}
func (c *cardsCache) set(cards []IssueCard) {
c.mu.Lock()
defer c.mu.Unlock()
c.cards = make([]IssueCard, len(cards))
copy(c.cards, cards)
c.at = time.Now()
}
func (c *cardsCache) invalidate() {
c.mu.Lock()
defer c.mu.Unlock()
c.at = time.Time{}
c.cards = nil
}
var (
issuesCache = &cardsCache{ttl: 30 * time.Second}
)
// mapIssueStatusToColumn maps canonical issue frontmatter statuses to kanban
// column ids. Falls back to "Backlog" for unknown / empty values.
func mapIssueStatusToColumn(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "pendiente", "":
return "Backlog"
case "en-curso":
return "Doing"
case "en-revisión", "en-revision":
return "Review"
case "done":
return "Done"
case "deferred":
return "Deferred"
default:
return "Backlog"
}
}
// loadIssueCards scans issuesDir for *.md issue files, parses each frontmatter
// and returns the resulting cards. README/INDEX/AGENT_GUIDE and files inside
// completed/ are skipped. Parse errors do NOT abort the scan — they yield a
// card with ParseError set so the UI can surface them.
//
// Results are sorted by updated_at desc, then id asc.
func loadIssueCards(issuesDir string) ([]IssueCard, error) {
return loadCardsFromDir(issuesDir, mapIssueStatusToColumn, "issue")
}
// loadCardsFromDir is the shared implementation for issues and flows.
// statusMapper translates frontmatter status -> column id for the given board.
func loadCardsFromDir(dir string, statusMapper func(string) string, kind string) ([]IssueCard, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("read %s: %w", dir, err)
}
out := make([]IssueCard, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasSuffix(strings.ToLower(name), ".md") {
continue
}
if isSkippedMarkdown(name) {
continue
}
full := filepath.Join(dir, name)
c, err := parseCardFile(full, statusMapper)
if err != nil {
// Surface as a parse-error card so the UI still shows it.
out = append(out, IssueCard{
ID: deriveIDFromFilename(name),
ExternalID: deriveIDFromFilename(name),
Title: name,
Status: "pendiente",
ColumnID: statusMapper(""),
FilePath: full,
ParseError: err.Error(),
})
continue
}
out = append(out, c)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].UpdatedAt != out[j].UpdatedAt {
return out[i].UpdatedAt > out[j].UpdatedAt
}
return out[i].ID < out[j].ID
})
_ = kind // reserved for telemetry/log labels
return out, nil
}
func isSkippedMarkdown(name string) bool {
upper := strings.ToUpper(name)
switch upper {
case "README.MD", "INDEX.MD", "AGENT_GUIDE.MD":
return true
}
return false
}
// deriveIDFromFilename pulls the leading numeric id segment from a filename
// like "0119-foo-bar.md" -> "0119". If no leading digits, returns the
// stem without extension.
func deriveIDFromFilename(name string) string {
stem := strings.TrimSuffix(name, filepath.Ext(name))
for i := 0; i < len(stem); i++ {
if stem[i] < '0' || stem[i] > '9' {
if i == 0 {
return stem
}
return stem[:i]
}
}
return stem
}
// parseCardFile reads filePath, splits frontmatter from body and returns a
// populated IssueCard. The body's first ~5 non-empty lines (after the first
// markdown heading) become the description.
func parseCardFile(filePath string, statusMapper func(string) string) (IssueCard, error) {
raw, err := os.ReadFile(filePath)
if err != nil {
return IssueCard{}, fmt.Errorf("read: %w", err)
}
fmText, body, err := splitFrontmatter(raw)
if err != nil {
return IssueCard{}, err
}
var fm issueFrontmatter
if err := yaml.Unmarshal(fmText, &fm); err != nil {
return IssueCard{}, fmt.Errorf("yaml: %w", err)
}
id := strings.TrimSpace(fm.ID)
if id == "" {
id = deriveIDFromFilename(filepath.Base(filePath))
}
status := strings.TrimSpace(fm.Status)
if status == "" {
status = "pendiente"
}
card := IssueCard{
ID: id,
ExternalID: id,
Title: strings.TrimSpace(fm.Title),
Status: status,
ColumnID: statusMapper(status),
Priority: strings.TrimSpace(fm.Priority),
Type: strings.TrimSpace(fm.Type),
Tag: strings.TrimSpace(fm.Type),
Tags: fm.Tags,
FlowID: strings.TrimSpace(fm.Flow),
UpdatedAt: strings.TrimSpace(fm.Updated),
CreatedAt: strings.TrimSpace(fm.Created),
FilePath: filePath,
DoDItems: dodItemsFromSchema(fm.DoDEvidenceSchema),
}
if card.Title == "" {
card.Title = filepath.Base(filePath)
}
card.Description = firstBodyLines(body, 5)
return card, nil
}
// splitFrontmatter expects raw to start with "---\n". Returns frontmatter
// bytes (without the delimiters) and body bytes.
func splitFrontmatter(raw []byte) ([]byte, []byte, error) {
// Tolerate optional BOM.
if bytes.HasPrefix(raw, []byte{0xEF, 0xBB, 0xBF}) {
raw = raw[3:]
}
if !bytes.HasPrefix(raw, []byte("---")) {
return nil, nil, fmt.Errorf("no frontmatter")
}
// Find end of first delimiter line.
firstNL := bytes.IndexByte(raw, '\n')
if firstNL < 0 {
return nil, nil, fmt.Errorf("malformed frontmatter (no newline after opening ---)")
}
rest := raw[firstNL+1:]
// Find closing delimiter at start of a line.
closeIdx := -1
searchFrom := 0
for {
idx := bytes.Index(rest[searchFrom:], []byte("\n---"))
if idx < 0 {
// also accept frontmatter that starts immediately with "---" then directly "---" on next line
if bytes.HasPrefix(rest, []byte("---")) {
closeIdx = 0
}
break
}
absolute := searchFrom + idx + 1 // skip the \n
// confirm it's on its own line (followed by \n or EOF or \r\n)
after := absolute + 3
if after == len(rest) || rest[after] == '\n' || rest[after] == '\r' {
closeIdx = absolute
break
}
searchFrom = absolute + 3
}
if closeIdx < 0 {
return nil, nil, fmt.Errorf("malformed frontmatter (no closing ---)")
}
fm := rest[:closeIdx]
// Trim trailing newline from fm if present.
fm = bytes.TrimRight(fm, "\r\n")
body := []byte{}
bodyStart := closeIdx + 3
if bodyStart < len(rest) {
// skip leading EOL after closing ---
if rest[bodyStart] == '\r' && bodyStart+1 < len(rest) && rest[bodyStart+1] == '\n' {
bodyStart += 2
} else if rest[bodyStart] == '\n' {
bodyStart++
}
body = rest[bodyStart:]
}
return fm, body, nil
}
// firstBodyLines returns up to n meaningful lines from body (skipping the
// initial H1/H2 heading and blank lines) joined with spaces.
func firstBodyLines(body []byte, n int) string {
if n <= 0 {
return ""
}
lines := strings.Split(string(body), "\n")
out := make([]string, 0, n)
skippedHeading := false
for _, l := range lines {
t := strings.TrimRight(strings.TrimRight(l, "\n"), "\r")
ts := strings.TrimSpace(t)
if ts == "" {
continue
}
if !skippedHeading && strings.HasPrefix(ts, "#") {
skippedHeading = true
continue
}
// Skip pure markdown decorations like horizontal rules.
if ts == "---" {
continue
}
out = append(out, ts)
if len(out) >= n {
break
}
}
return strings.Join(out, " ")
}
// dodItemsFromSchema extracts a flat list of titles/keys from the
// `dod_evidence_schema` frontmatter field, which can be either a list of
// strings or a list of objects with a "title" or "key" field.
func dodItemsFromSchema(items []any) []string {
if len(items) == 0 {
return nil
}
out := make([]string, 0, len(items))
for _, it := range items {
switch v := it.(type) {
case string:
s := strings.TrimSpace(v)
if s != "" {
out = append(out, s)
}
case map[string]interface{}:
for _, k := range []string{"title", "name", "key", "id"} {
if val, ok := v[k]; ok {
if s, ok2 := val.(string); ok2 && strings.TrimSpace(s) != "" {
out = append(out, strings.TrimSpace(s))
break
}
}
}
}
}
return out
}
// --- registry root resolution ------------------------------------------------
// registryRoot returns FN_REGISTRY_ROOT if set, otherwise walks upward from
// cwd looking for a "dev/issues" directory. Falls back to cwd.
func registryRoot() string {
if v := strings.TrimSpace(os.Getenv("FN_REGISTRY_ROOT")); v != "" {
return v
}
cwd, err := os.Getwd()
if err != nil {
return "."
}
dir := cwd
for i := 0; i < 8; i++ {
if st, err := os.Stat(filepath.Join(dir, "dev", "issues")); err == nil && st.IsDir() {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return cwd
}
func issuesDir() string {
return filepath.Join(registryRoot(), "dev", "issues")
}
-119
View File
@@ -1,119 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func writeFixture(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
func TestLoadIssueCards_MapsStatusesAndSkipsNonIssues(t *testing.T) {
dir := t.TempDir()
writeFixture(t, dir, "README.md", "skip me")
writeFixture(t, dir, "AGENT_GUIDE.md", "skip me too")
writeFixture(t, dir, "0001-foo.md", "---\n"+
"id: \"0001\"\n"+
"title: \"Foo\"\n"+
"status: pendiente\n"+
"priority: alta\n"+
"type: feature\n"+
"tags: [x, y]\n"+
"flow: \"0008\"\n"+
"created: 2026-05-18\n"+
"updated: 2026-05-18\n"+
"---\n# Foo\n\nDescription body line 1.\nLine 2.\n")
writeFixture(t, dir, "0002-bar.md", "---\n"+
"id: \"0002\"\n"+
"title: \"Bar\"\n"+
"status: en-curso\n"+
"---\nBody\n")
writeFixture(t, dir, "0003-baz.md", "---\n"+
"id: \"0003\"\n"+
"title: \"Baz\"\n"+
"status: done\n"+
"---\nBody\n")
cards, err := loadIssueCards(dir)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(cards) != 3 {
t.Fatalf("expected 3 cards, got %d: %#v", len(cards), cards)
}
byID := map[string]IssueCard{}
for _, c := range cards {
byID[c.ID] = c
}
if c := byID["0001"]; c.ColumnID != "Backlog" || c.Priority != "alta" || c.FlowID != "0008" || c.Type != "feature" {
t.Fatalf("0001 mismapped: %#v", c)
}
if c := byID["0002"]; c.ColumnID != "Doing" {
t.Fatalf("0002 expected Doing, got %s", c.ColumnID)
}
if c := byID["0003"]; c.ColumnID != "Done" {
t.Fatalf("0003 expected Done, got %s", c.ColumnID)
}
// Description must contain body content but NOT the title heading.
if c := byID["0001"]; c.Description == "" {
t.Fatalf("0001 missing description")
}
}
func TestLoadIssueCards_MalformedYAMLDoesNotCrash(t *testing.T) {
dir := t.TempDir()
writeFixture(t, dir, "0010-bad.md", "---\nid: \"0010\"\ntitle: \"Bad\"\nstatus: pendiente\n : malformed\n---\nbody\n")
cards, err := loadIssueCards(dir)
if err != nil {
t.Fatalf("expected no top-level error, got: %v", err)
}
if len(cards) != 1 {
t.Fatalf("expected 1 card (with parse error), got %d", len(cards))
}
if cards[0].ParseError == "" {
t.Fatalf("expected ParseError to be set on malformed card")
}
if cards[0].ID != "0010" {
t.Fatalf("expected id 0010 derived from filename, got %q", cards[0].ID)
}
}
func TestLoadIssueCards_MissingStatusDefaultsPendiente(t *testing.T) {
dir := t.TempDir()
writeFixture(t, dir, "0011-nostatus.md", "---\nid: \"0011\"\ntitle: \"NoStatus\"\n---\nbody\n")
cards, err := loadIssueCards(dir)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(cards) != 1 {
t.Fatalf("expected 1 card, got %d", len(cards))
}
if cards[0].Status != "pendiente" {
t.Fatalf("expected default status pendiente, got %q", cards[0].Status)
}
if cards[0].ColumnID != "Backlog" {
t.Fatalf("expected column Backlog, got %q", cards[0].ColumnID)
}
}
func TestIssuesCacheTTL(t *testing.T) {
// Sanity: a fresh cache misses.
cache := &cardsCache{ttl: 30 * 1_000_000_000} // 30s in ns
if _, ok := cache.get(); ok {
t.Fatalf("expected miss on empty cache")
}
cache.set([]IssueCard{{ID: "0001"}})
if c, ok := cache.get(); !ok || len(c) != 1 {
t.Fatalf("expected cache hit")
}
cache.invalidate()
if _, ok := cache.get(); ok {
t.Fatalf("expected miss after invalidate")
}
}
BIN
View File
Binary file not shown.
+74 -42
View File
@@ -2,74 +2,106 @@ package main
import ( import (
"context" "context"
"encoding/json" "database/sql"
"embed"
"flag" "flag"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"syscall" "syscall"
"time"
"fn-registry/functions/infra"
) )
const syncLayerVersion = "v0.1.0" //go:embed migrations/*.sql
var migrationsFS embed.FS
type Server struct {
db *sql.DB
issuesDir string
flowsDir string
hub *SSEHub
}
func main() { func main() {
flags := flag.NewFlagSet("kanban_cpp_backend", flag.ExitOnError) port := flag.Int("port", 8487, "HTTP port")
port := flags.Int("port", 8403, "HTTP port") dbPath := flag.String("db", "operations.db", "SQLite path")
dbPath := flags.String("db", "operations.db", "SQLite database path") registryRoot := flag.String("registry", "", "fn_registry root (default: auto-detect from cwd)")
flagsPath := flags.String("flags", "dev/feature_flags.json", "Feature flags JSON path (missing file → all disabled)") flag.Parse()
flags.Parse(os.Args[1:])
featureFlags, err := loadFeatureFlags(*flagsPath) root := *registryRoot
if err != nil { if root == "" {
log.Fatalf("load feature flags: %v", err) root = detectRegistryRoot()
} }
for name, fl := range featureFlags.Flags { issuesDir := filepath.Join(root, "dev", "issues")
log.Printf("feature flag %q enabled=%v", name, fl.Enabled) flowsDir := filepath.Join(root, "dev", "flows")
for _, d := range []string{issuesDir, flowsDir} {
if _, err := os.Stat(d); err != nil {
log.Fatalf("missing dir %s: %v", d, err)
}
} }
db, err := openDB(*dbPath) db, err := openDB(*dbPath)
if err != nil { if err != nil {
log.Fatalf("open db: %v", err) log.Fatalf("openDB: %v", err)
} }
defer db.Close() defer db.Close()
if err := applyMigrations(db); err != nil {
log.Fatalf("applyMigrations: %v", err)
}
// SSE: hub + fsnotify watcher for dev/issues + dev/flows. srv := &Server{
globalHub = NewHub() db: db,
startBoardsWatcher(globalHub) issuesDir: issuesDir,
flowsDir: flowsDir,
hub: newSSEHub(),
}
mux := infra.HTTPRouter(apiRoutes(db, &featureFlags)) if err := srv.ingestAll(); err != nil {
mux.HandleFunc("/health", handleHealth(*port)) log.Fatalf("initial ingest: %v", err)
}
chain := infra.HTTPMiddlewareChain( ctx, cancel := context.WithCancel(context.Background())
infra.HTTPLoggerMiddleware(os.Stdout),
infra.HTTPCORSMiddleware([]string{"*"}, []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"}),
)
handler := chain(mux)
addr := fmt.Sprintf(":%d", *port)
log.Printf("kanban_cpp_backend starting on http://0.0.0.0%s (sync layer %s)", addr, syncLayerVersion)
log.Printf("database: %s", *dbPath)
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel() defer cancel()
if err := infra.HTTPServe(addr, handler, ctx); err != nil { go srv.watchLoop(ctx, srv.issuesDir, "issue")
log.Fatalf("server: %v", err) go srv.watchLoop(ctx, srv.flowsDir, "flow")
mux := http.NewServeMux()
srv.registerRoutes(mux)
httpSrv := &http.Server{
Addr: fmt.Sprintf(":%d", *port),
Handler: withMiddleware(mux),
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
} }
go func() {
log.Printf("kanban_cpp_backend listening on :%d (registry=%s)", *port, root)
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
log.Println("shutdown")
shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelShutdown()
httpSrv.Shutdown(shutdownCtx)
} }
// handleHealth returns 200 with a small JSON describing the service. No auth. func detectRegistryRoot() string {
func handleHealth(port int) http.HandlerFunc { wd, _ := os.Getwd()
return func(w http.ResponseWriter, r *http.Request) { for d := wd; d != "/" && d != "."; d = filepath.Dir(d) {
w.Header().Set("Content-Type", "application/json") if _, err := os.Stat(filepath.Join(d, "registry.db")); err == nil {
_ = json.NewEncoder(w).Encode(map[string]any{ return d
"status": "ok", }
"port": port,
"sync_layer": syncLayerVersion,
})
} }
log.Fatalf("could not auto-detect fn_registry root from %s", wd)
return ""
} }
+31 -48
View File
@@ -1,51 +1,34 @@
CREATE TABLE IF NOT EXISTS columns ( CREATE TABLE IF NOT EXISTS issues (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, title TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL,
location TEXT NOT NULL DEFAULT 'board' CHECK(location IN ('board','sidebar')), type TEXT,
width INTEGER NOT NULL DEFAULT 300, scope TEXT,
wip_limit INTEGER NOT NULL DEFAULT 0, priority TEXT,
created_at TEXT NOT NULL domain_json TEXT NOT NULL DEFAULT '[]',
tags_json TEXT NOT NULL DEFAULT '[]',
depends_json TEXT NOT NULL DEFAULT '[]',
blocks_json TEXT NOT NULL DEFAULT '[]',
related_json TEXT NOT NULL DEFAULT '[]',
flow_id TEXT,
body TEXT NOT NULL DEFAULT '',
file_path TEXT NOT NULL,
mtime_ns INTEGER NOT NULL,
created_at TEXT,
updated_at TEXT,
completed INTEGER NOT NULL DEFAULT 0
); );
CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status);
CREATE INDEX IF NOT EXISTS idx_issues_priority ON issues(priority);
CREATE INDEX IF NOT EXISTS idx_issues_scope ON issues(scope);
CREATE TABLE IF NOT EXISTS cards ( CREATE TABLE IF NOT EXISTS flows (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
requester TEXT NOT NULL DEFAULT '', title TEXT NOT NULL,
title TEXT NOT NULL, status TEXT,
description TEXT NOT NULL DEFAULT '', kind TEXT,
color TEXT NOT NULL DEFAULT '', tags_json TEXT NOT NULL DEFAULT '[]',
column_id TEXT NOT NULL REFERENCES columns(id) ON DELETE CASCADE, body TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL DEFAULT 0, file_path TEXT NOT NULL,
locked INTEGER NOT NULL DEFAULT 0, mtime_ns INTEGER NOT NULL
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
@@ -1,4 +0,0 @@
-- 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 '[]';
@@ -1,6 +0,0 @@
-- 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
@@ -1,9 +0,0 @@
-- 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
@@ -1,3 +0,0 @@
-- 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
@@ -1,2 +0,0 @@
-- Color del avatar del usuario (Mantine color name o '#rrggbb' personalizado).
ALTER TABLE users ADD COLUMN color TEXT NOT NULL DEFAULT '';
-11
View File
@@ -1,11 +0,0 @@
-- 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
@@ -1,7 +0,0 @@
-- 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
@@ -1,4 +0,0 @@
-- 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
@@ -1,14 +0,0 @@
-- 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);
+13 -45
View File
@@ -4,75 +4,43 @@ import (
"sync" "sync"
) )
// ServerEvent is a board-scoped event broadcast to all SSE subscribers type SSEEvent struct {
// of a given board. It is emitted both by the fsnotify watcher (file Type string `json:"type"`
// changes on disk under dev/issues or dev/flows) and by handlers that ID string `json:"id"`
// mutate cards (PATCH /api/boards/{board}/cards/{id}) so the C++ client Path string `json:"path,omitempty"`
// updates in real time without polling.
type ServerEvent struct {
Board string `json:"board"` // "issues" | "flows"
CardID string `json:"card_id"` // canonical id, e.g. "0119"
Action string `json:"action"` // "created" | "updated" | "deleted"
EventType string `json:"-"` // SSE "event:" field. Empty → "card_changed"
} }
// Hub fans out ServerEvent messages to N concurrent subscribers. Each type SSEHub struct {
// subscriber gets a buffered channel; if the channel is full, the event
// is dropped for THAT subscriber (slow consumer must reconnect to get a
// fresh snapshot). Hub itself is safe for concurrent use.
type Hub struct {
mu sync.RWMutex mu sync.RWMutex
clients map[chan ServerEvent]struct{} clients map[chan SSEEvent]struct{}
} }
// NewHub returns an empty Hub ready to use. func newSSEHub() *SSEHub {
func NewHub() *Hub { return &SSEHub{clients: map[chan SSEEvent]struct{}{}}
return &Hub{
clients: make(map[chan ServerEvent]struct{}),
}
} }
// Subscribe registers a new subscriber. The returned channel is buffered func (h *SSEHub) subscribe() chan SSEEvent {
// (16) so a brief stall on the consumer side doesn't block the producer. ch := make(chan SSEEvent, 16)
func (h *Hub) Subscribe() chan ServerEvent {
ch := make(chan ServerEvent, 16)
h.mu.Lock() h.mu.Lock()
h.clients[ch] = struct{}{} h.clients[ch] = struct{}{}
h.mu.Unlock() h.mu.Unlock()
return ch return ch
} }
// Unsubscribe removes a subscriber and closes its channel. Idempotent. func (h *SSEHub) unsubscribe(ch chan SSEEvent) {
func (h *Hub) Unsubscribe(ch chan ServerEvent) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock()
if _, ok := h.clients[ch]; !ok {
return
}
delete(h.clients, ch) delete(h.clients, ch)
h.mu.Unlock()
close(ch) close(ch)
} }
// Broadcast sends ev to every current subscriber. Non-blocking: if a func (h *SSEHub) broadcast(ev SSEEvent) {
// subscriber's channel is full the event is dropped for that subscriber.
func (h *Hub) Broadcast(ev ServerEvent) {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
for ch := range h.clients { for ch := range h.clients {
select { select {
case ch <- ev: case ch <- ev:
default: default:
// slow consumer: drop
} }
} }
} }
// Count returns the current number of subscribers (test/diagnostic).
func (h *Hub) Count() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.clients)
}
// globalHub is initialised in main() and consumed by handlers + watcher.
var globalHub *Hub
-73
View File
@@ -1,73 +0,0 @@
package main
import (
"testing"
"time"
)
func TestHub_BroadcastReachesSubscriber(t *testing.T) {
h := NewHub()
ch := h.Subscribe()
defer h.Unsubscribe(ch)
want := ServerEvent{Board: "issues", CardID: "0119", Action: "updated"}
h.Broadcast(want)
select {
case got := <-ch:
if got != want {
t.Fatalf("got %+v, want %+v", got, want)
}
case <-time.After(time.Second):
t.Fatal("timeout waiting for broadcast")
}
}
func TestHub_UnsubscribeStopsDelivery(t *testing.T) {
h := NewHub()
ch := h.Subscribe()
if got := h.Count(); got != 1 {
t.Fatalf("Count() = %d, want 1", got)
}
h.Unsubscribe(ch)
if got := h.Count(); got != 0 {
t.Fatalf("Count() after Unsubscribe = %d, want 0", got)
}
// channel should be closed
if _, ok := <-ch; ok {
t.Fatalf("expected closed channel after Unsubscribe")
}
// double-unsubscribe is a no-op
h.Unsubscribe(ch)
// broadcast should not panic and should reach nobody
h.Broadcast(ServerEvent{Board: "issues", CardID: "x", Action: "updated"})
}
func TestHub_MultipleSubscribersAllReceive(t *testing.T) {
h := NewHub()
const n = 5
chans := make([]chan ServerEvent, n)
for i := range chans {
chans[i] = h.Subscribe()
}
defer func() {
for _, ch := range chans {
h.Unsubscribe(ch)
}
}()
want := ServerEvent{Board: "flows", CardID: "abc", Action: "created", EventType: "card_added"}
h.Broadcast(want)
for i, ch := range chans {
select {
case got := <-ch:
if got != want {
t.Fatalf("sub %d: got %+v, want %+v", i, got, want)
}
case <-time.After(time.Second):
t.Fatalf("sub %d: timeout", i)
}
}
}
-191
View File
@@ -1,191 +0,0 @@
package main
import (
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/fsnotify/fsnotify"
)
// startBoardsWatcher launches a goroutine that watches dev/issues and
// dev/flows (recursively, one level deep — completed/ subdir) for .md
// changes and broadcasts ServerEvent messages via the hub. It also
// invalidates the relevant cardsCache so the next /cards GET reflects
// disk state.
//
// On fsnotify errors the watcher logs + retries every 30s.
func startBoardsWatcher(hub *Hub) {
go func() {
for {
if err := runBoardsWatcher(hub); err != nil {
log.Printf("sse_watcher: %v — retrying in 30s", err)
time.Sleep(30 * time.Second)
}
}
}()
}
func runBoardsWatcher(hub *Hub) error {
w, err := fsnotify.NewWatcher()
if err != nil {
return err
}
defer w.Close()
roots := map[string]string{
"issues": issuesDir(),
"flows": flowsDir(),
}
for board, dir := range roots {
if err := addRecursive(w, dir); err != nil {
log.Printf("sse_watcher: watch %s (%s): %v", board, dir, err)
} else {
log.Printf("sse_watcher: watching %s -> %s", board, dir)
}
}
for {
select {
case ev, ok := <-w.Events:
if !ok {
return nil
}
handleFsEvent(hub, ev)
case err, ok := <-w.Errors:
if !ok {
return nil
}
log.Printf("sse_watcher: fsnotify error: %v", err)
}
}
}
// addRecursive adds dir and its immediate subdirectories (e.g.
// dev/issues/completed/) to the watcher. We do NOT follow symlinks.
func addRecursive(w *fsnotify.Watcher, root string) error {
if err := w.Add(root); err != nil {
return err
}
entries, err := readDirNoSymlink(root)
if err != nil {
return nil // root added, subdirs best-effort
}
for _, name := range entries {
full := filepath.Join(root, name)
// best-effort, ignore errors on subdirs
_ = w.Add(full)
}
return nil
}
// handleFsEvent translates an fsnotify event into a ServerEvent (if
// applicable) and broadcasts it. Non-md files and skipped names
// (README/INDEX/...) are ignored.
func handleFsEvent(hub *Hub, ev fsnotify.Event) {
board, cardID, action := classifyEvent(ev)
if board == "" {
return
}
// Invalidate the right cache so the next GET re-scans disk.
switch board {
case "issues":
if issuesCache != nil {
issuesCache.invalidate()
}
case "flows":
if flowsCache != nil {
flowsCache.invalidate()
}
}
if hub == nil {
return
}
hub.Broadcast(ServerEvent{
Board: board,
CardID: cardID,
Action: action,
EventType: sseEventForAction(action),
})
}
// classifyEvent inspects a raw fsnotify event and returns
// (board, cardID, action) — board is "" if the event is irrelevant.
func classifyEvent(ev fsnotify.Event) (board, cardID, action string) {
name := filepath.Base(ev.Name)
if !strings.HasSuffix(strings.ToLower(name), ".md") {
return "", "", ""
}
if isSkippedMarkdown(name) {
return "", "", ""
}
// Determine board from the path.
lower := strings.ToLower(filepath.ToSlash(ev.Name))
switch {
case strings.Contains(lower, "/dev/issues/"):
board = "issues"
case strings.Contains(lower, "/dev/flows/"):
board = "flows"
default:
return "", "", ""
}
cardID = deriveIDFromFilename(name)
switch {
case ev.Op&fsnotify.Create != 0:
action = "created"
case ev.Op&fsnotify.Remove != 0:
action = "deleted"
case ev.Op&fsnotify.Rename != 0:
// Treat rename as updated — the file likely moved between
// canonical dir and completed/.
action = "updated"
case ev.Op&fsnotify.Write != 0:
action = "updated"
default:
return "", "", ""
}
return board, cardID, action
}
func sseEventForAction(action string) string {
switch action {
case "created":
return "card_added"
case "deleted":
return "card_removed"
default:
return "card_changed"
}
}
// readDirNoSymlink lists subdirectory names under root, skipping symlinks
// and hidden entries.
func readDirNoSymlink(root string) ([]string, error) {
entries, err := os.ReadDir(root)
if err != nil {
return nil, err
}
out := []string{}
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
if strings.HasPrefix(name, ".") {
continue
}
// Detect symlinks: lstat the full path.
full := filepath.Join(root, name)
info, err := os.Lstat(full)
if err != nil {
continue
}
if info.Mode()&os.ModeSymlink != 0 {
continue
}
out = append(out, name)
}
return out, nil
}
-126
View File
@@ -1,126 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/fsnotify/fsnotify"
)
func TestWatcher_PathToEvent_IssuesCreate(t *testing.T) {
ev := fsnotify.Event{
Name: "/home/x/fn_registry/dev/issues/0119-frontmatter-migration.md",
Op: fsnotify.Create,
}
board, id, action := classifyEvent(ev)
if board != "issues" || id != "0119" || action != "created" {
t.Fatalf("got (%q,%q,%q), want (issues,0119,created)", board, id, action)
}
if sseEventForAction(action) != "card_added" {
t.Fatalf("sseEventForAction(created) != card_added")
}
}
func TestWatcher_PathToEvent_FlowsRename(t *testing.T) {
ev := fsnotify.Event{
Name: "/x/dev/flows/0042-deploy-vps.md",
Op: fsnotify.Rename,
}
board, id, action := classifyEvent(ev)
if board != "flows" || id != "0042" || action != "updated" {
t.Fatalf("got (%q,%q,%q), want (flows,0042,updated)", board, id, action)
}
}
func TestWatcher_PathToEvent_Remove(t *testing.T) {
ev := fsnotify.Event{
Name: "/x/dev/issues/0050-foo.md",
Op: fsnotify.Remove,
}
board, id, action := classifyEvent(ev)
if board != "issues" || id != "0050" || action != "deleted" {
t.Fatalf("got (%q,%q,%q), want (issues,0050,deleted)", board, id, action)
}
if sseEventForAction(action) != "card_removed" {
t.Fatalf("sseEventForAction(deleted) != card_removed")
}
}
func TestWatcher_PathToEvent_SkippedNames(t *testing.T) {
cases := []string{
"/x/dev/issues/README.md",
"/x/dev/issues/INDEX.md",
"/x/dev/issues/AGENT_GUIDE.md",
"/x/dev/issues/notes.txt", // not .md
"/x/somewhere-else/0001-foo.md", // not under dev/issues|flows
}
for _, p := range cases {
ev := fsnotify.Event{Name: p, Op: fsnotify.Create}
if board, _, _ := classifyEvent(ev); board != "" {
t.Fatalf("expected ignored, got board=%q for %s", board, p)
}
}
}
func TestWatcher_DetectsWrite(t *testing.T) {
// Build a temp tree that *looks like* dev/issues/ so classifyEvent
// will accept it (it matches by path substring "/dev/issues/").
root := t.TempDir()
issuesDirPath := filepath.Join(root, "dev", "issues")
if err := os.MkdirAll(issuesDirPath, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
w, err := fsnotify.NewWatcher()
if err != nil {
t.Fatalf("new watcher: %v", err)
}
defer w.Close()
if err := w.Add(issuesDirPath); err != nil {
t.Fatalf("watch add: %v", err)
}
hub := NewHub()
ch := hub.Subscribe()
defer hub.Unsubscribe(ch)
// Drive events in a goroutine via handleFsEvent so we exercise the
// full pipeline (classify -> broadcast).
done := make(chan struct{})
go func() {
defer close(done)
for {
select {
case ev, ok := <-w.Events:
if !ok {
return
}
handleFsEvent(hub, ev)
case <-w.Errors:
case <-time.After(2 * time.Second):
return
}
}
}()
cardPath := filepath.Join(issuesDirPath, "0999-test.md")
if err := os.WriteFile(cardPath, []byte("---\nstatus: pendiente\n---\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
select {
case ev := <-ch:
if ev.Board != "issues" || ev.CardID != "0999" {
t.Fatalf("unexpected event: %+v", ev)
}
if ev.Action != "created" && ev.Action != "updated" {
t.Fatalf("expected created/updated, got action=%q", ev.Action)
}
case <-time.After(3 * time.Second):
t.Fatal("timeout waiting for write event")
}
<-done
}
+80
View File
@@ -0,0 +1,80 @@
package main
import (
"context"
"log"
"strings"
"fn-registry/functions/infra"
)
func (s *Server) watchLoop(ctx context.Context, root, kind string) {
ch, err := infra.WatchDirFsnotify(ctx, root)
if err != nil {
log.Printf("watcher %s: %v", root, err)
return
}
for ev := range ch {
if !strings.HasSuffix(ev.Path, ".md") {
continue
}
s.handleFsEvent(kind, ev)
}
}
func (s *Server) handleFsEvent(kind string, ev infra.FsEvent) {
switch kind {
case "issue":
s.handleIssueEvent(ev)
case "flow":
s.handleFlowEvent(ev)
}
}
func (s *Server) handleIssueEvent(ev infra.FsEvent) {
id := issueIDFromPath(ev.Path, s.issuesDir)
if id == "" {
return
}
if ev.Op == "remove" || ev.Op == "rename" {
s.deleteIssue(id)
s.hub.broadcast(SSEEvent{Type: "removed", ID: id, Path: ev.Path})
return
}
iss, _, err := infra.ParseIssueMd(ev.Path)
if err != nil {
log.Printf("parse %s: %v", ev.Path, err)
return
}
if err := s.upsertIssueRow(iss); err != nil {
log.Printf("upsert %s: %v", iss.ID, err)
return
}
s.hub.broadcast(SSEEvent{Type: "updated", ID: iss.ID, Path: ev.Path})
}
func (s *Server) handleFlowEvent(ev infra.FsEvent) {
id := issueIDFromPath(ev.Path, s.flowsDir)
if id == "" {
return
}
if ev.Op == "remove" || ev.Op == "rename" {
s.deleteFlow(id)
s.hub.broadcast(SSEEvent{Type: "flow_removed", ID: id, Path: ev.Path})
return
}
flows, err := infra.ScanFlowsDir(s.flowsDir)
if err != nil {
log.Printf("scan flows: %v", err)
return
}
for _, fl := range flows {
if fl.ID == id {
if err := s.upsertFlowRow(fl); err != nil {
log.Printf("upsert flow %s: %v", id, err)
}
s.hub.broadcast(SSEEvent{Type: "flow_updated", ID: id, Path: ev.Path})
return
}
}
}
+292 -159
View File
@@ -1,183 +1,316 @@
// 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 "data.h"
#include <atomic>
#include <chrono>
#include <memory>
#include <thread>
#include "core/http_request.h" #include "core/http_request.h"
#include "core/sse_client.h"
#include "core/logger.h" #include "core/logger.h"
#include <nlohmann/json.hpp>
#include <cstring> namespace kanban {
#include <cstdio>
namespace kanban_cpp { using json = nlohmann::json;
namespace { State& state() {
static State s;
return s;
}
// Tiny helpers: scan JSON strings out of a raw buffer. NOT a real parser — static std::unique_ptr<fn_sse::Client> g_sse;
// 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) { static std::vector<std::string> j_arr(const json& j, const char* key) {
std::string needle = "\"" + key + "\":"; std::vector<std::string> out;
size_t p = s.find(needle); if (!j.contains(key) || !j[key].is_array()) return out;
if (p == std::string::npos) return ""; for (const auto& v : j[key]) {
p += needle.size(); if (v.is_string()) out.push_back(v.get<std::string>());
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; return out;
} }
int64_t find_int_field(const std::string& s, const std::string& key) { static std::string j_str(const json& j, const char* key) {
std::string needle = "\"" + key + "\":"; if (!j.contains(key) || !j[key].is_string()) return "";
size_t p = s.find(needle); return j[key].get<std::string>();
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. static Issue parse_issue(const json& j) {
std::vector<std::string> split_objects(const std::string& s) { Issue i;
std::vector<std::string> out; i.id = j_str(j, "id");
int depth = 0; i.title = j_str(j, "title");
size_t start = 0; i.status = j_str(j, "status");
bool in_obj = false; i.type = j_str(j, "type");
for (size_t i = 0; i < s.size(); ++i) { i.scope = j_str(j, "scope");
char c = s[i]; i.priority = j_str(j, "priority");
if (c == '{') { i.domain = j_arr(j, "domain");
if (depth == 0) { start = i; in_obj = true; } i.tags = j_arr(j, "tags");
++depth; i.depends = j_arr(j, "depends");
} else if (c == '}') { i.blocks = j_arr(j, "blocks");
--depth; i.related = j_arr(j, "related");
if (depth == 0 && in_obj) { i.flow = j_str(j, "flow");
out.push_back(s.substr(start, i - start + 1)); i.file_path = j_str(j, "file_path");
in_obj = false; if (j.contains("completed") && j["completed"].is_boolean())
i.completed = j["completed"].get<bool>();
i.body = j_str(j, "body");
return i;
}
static Flow parse_flow(const json& j) {
Flow f;
f.id = j_str(j, "id");
f.title = j_str(j, "title");
f.status = j_str(j, "status");
f.kind = j_str(j, "kind");
f.tags = j_arr(j, "tags");
f.file_path = j_str(j, "file_path");
f.body = j_str(j, "body");
return f;
}
static fn_http::Response http_get(const std::string& path) {
fn_http::Request req;
req.method = "GET";
req.url = state().backend_url + path;
req.timeout_ms = 5000;
return fn_http::request(req);
}
static fn_http::Response http_patch_json(const std::string& path, const std::string& body) {
fn_http::Request req;
req.method = "PATCH";
req.url = state().backend_url + path;
req.timeout_ms = 5000;
req.headers.push_back({"Content-Type", "application/json"});
req.body = body;
return fn_http::request(req);
}
static fn_http::Response http_post_json(const std::string& path, const std::string& body) {
fn_http::Request req;
req.method = "POST";
req.url = state().backend_url + path;
req.timeout_ms = 10000;
req.headers.push_back({"Content-Type", "application/json"});
req.body = body;
return fn_http::request(req);
}
bool refresh_issues() {
auto resp = http_get("/api/issues");
if (resp.status != 200) {
std::lock_guard<std::mutex> g(state().mu);
state().last_error = "issues fetch: " + std::to_string(resp.status) + " " + resp.error;
return false;
}
auto j = json::parse(resp.body, nullptr, false);
if (j.is_discarded() || !j.is_array()) {
std::lock_guard<std::mutex> g(state().mu);
state().last_error = "issues: invalid JSON";
return false;
}
std::vector<Issue> out;
out.reserve(j.size());
for (const auto& it : j) out.push_back(parse_issue(it));
std::lock_guard<std::mutex> g(state().mu);
state().issues = std::move(out);
state().last_refresh_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
state().last_error.clear();
return true;
}
bool refresh_flows() {
auto resp = http_get("/api/flows");
if (resp.status != 200) {
std::lock_guard<std::mutex> g(state().mu);
state().last_error = "flows fetch: " + std::to_string(resp.status);
return false;
}
auto j = json::parse(resp.body, nullptr, false);
if (j.is_discarded() || !j.is_array()) return false;
std::vector<Flow> out;
out.reserve(j.size());
for (const auto& it : j) out.push_back(parse_flow(it));
std::lock_guard<std::mutex> g(state().mu);
state().flows = std::move(out);
return true;
}
bool refresh_meta() {
auto resp = http_get("/api/meta");
if (resp.status != 200) return false;
auto j = json::parse(resp.body, nullptr, false);
if (j.is_discarded()) return false;
std::lock_guard<std::mutex> g(state().mu);
state().meta.statuses = j_arr(j, "statuses");
state().meta.priorities = j_arr(j, "priorities");
state().meta.scopes = j_arr(j, "scopes");
state().meta.types = j_arr(j, "types");
return true;
}
bool refresh_issue_detail(const std::string& id) {
auto resp = http_get("/api/issues/" + id);
if (resp.status != 200) return false;
auto j = json::parse(resp.body, nullptr, false);
if (j.is_discarded()) return false;
std::lock_guard<std::mutex> g(state().mu);
state().selected_issue_id = id;
state().selected_issue_detail = parse_issue(j);
return true;
}
static std::string json_escape(const std::string& s) {
json j = s;
return j.dump();
}
bool patch_issue_status(const std::string& id, const std::string& new_status) {
std::string body = "{\"status\":" + json_escape(new_status) + "}";
auto resp = http_patch_json("/api/issues/" + id, body);
if (resp.status != 200) {
std::lock_guard<std::mutex> g(state().mu);
state().last_error = "PATCH status " + id + ": " + std::to_string(resp.status);
return false;
}
// Update local cache without full refresh.
auto j = json::parse(resp.body, nullptr, false);
if (!j.is_discarded()) {
Issue updated = parse_issue(j);
std::lock_guard<std::mutex> g(state().mu);
for (auto& i : state().issues) {
if (i.id == id) {
i = updated;
break;
} }
} }
} }
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) {
// /health legacy tiene auth middleware → 500. Usar endpoint sync layer
// (issue 0119) sin auth como ping.
auto r = do_get(cfg.base_url + "/api/boards/issues/cards", cfg.timeout_ms);
return r.status >= 200 && r.status < 300;
}
static std::string status_to_column(const std::string& s) {
if (s == "pendiente" || s == "pending") return "backlog";
if (s == "en-curso" || s == "in-progress") return "doing";
if (s == "en-revision" || s == "review") return "review";
if (s == "done" || s == "completado") return "done";
if (s == "deferred") return "deferred";
return "backlog";
}
std::vector<Card> list_cards(const ClientConfig& cfg, std::string& err) {
// Issue 0119 sync layer: cards = issues + flows. Aqui solo issues; flows
// viven en su propio tab/panel cuando se anada.
auto r = do_get(cfg.base_url + "/api/boards/issues/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.status = find_str_field(obj, "status");
c.column_id = status_to_column(c.status);
c.priority = find_str_field(obj, "priority");
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*/) {
// Columnas fijas derivadas de taxonomia (issue 0103).
return {
{"backlog", "Backlog", 0},
{"doing", "Doing", 1},
{"review", "Review", 2},
{"done", "Done", 3},
{"deferred", "Deferred", 4},
};
}
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; return true;
} }
std::vector<AgentRunSummary> list_runs(const ClientConfig& cfg, std::string& err) { bool refresh_agent_status() {
auto r = do_get(cfg.agent_runner_url + "/api/runs", cfg.timeout_ms); auto resp = http_get("/api/agent_status");
if (r.status == 0) { err = "transport: " + r.error; return {}; } if (resp.status != 200) {
if (r.status >= 400) { err = "http " + std::to_string(r.status); return {}; } std::lock_guard<std::mutex> g(state().mu);
std::vector<AgentRunSummary> out; state().agent_runner_up = false;
for (const auto& obj : split_objects(r.body)) { state().agent_active.clear();
AgentRunSummary s; return false;
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; auto j = json::parse(resp.body, nullptr, false);
} if (j.is_discarded()) return false;
std::map<std::string, std::string> active;
bool launch_workflow(const ClientConfig& cfg, const std::string& card_id, if (j.contains("active") && j["active"].is_object()) {
std::string& out_run_id, std::string& err) { for (auto it = j["active"].begin(); it != j["active"].end(); ++it) {
std::string body = "{\"card_id\":\"" + card_id + "\"}"; if (it.value().is_string()) active[it.key()] = it.value().get<std::string>();
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; } bool up = j.value("available", false);
out_run_id = find_str_field(r.body, "id"); std::lock_guard<std::mutex> g(state().mu);
state().agent_runner_up = up;
state().agent_active = std::move(active);
return true; return true;
} }
} // namespace kanban_cpp bool launch_agent(const std::string& issue_id) {
std::string body = "{\"issue_id\":" + json_escape(issue_id) + ",\"mode\":\"fix-issue\"}";
auto resp = http_post_json("/api/agent_launch", body);
if (resp.status < 200 || resp.status >= 300) {
std::lock_guard<std::mutex> g(state().mu);
state().last_launch_msg = "launch failed (" + std::to_string(resp.status) + "): " + resp.body;
return false;
}
{
std::lock_guard<std::mutex> g(state().mu);
state().last_launch_msg = "launched agent on " + issue_id;
// Optimistically mark as active so the dot shows up immediately.
state().agent_active[issue_id] = "pending";
}
return true;
}
bool is_agent_active(const std::string& issue_id) {
std::lock_guard<std::mutex> g(state().mu);
return state().agent_active.find(issue_id) != state().agent_active.end();
}
bool patch_issue_fields(const std::string& id, const std::string& json_partial) {
auto resp = http_patch_json("/api/issues/" + id, json_partial);
if (resp.status != 200) {
std::lock_guard<std::mutex> g(state().mu);
state().last_error = "PATCH " + id + ": " + std::to_string(resp.status);
return false;
}
return true;
}
void start_sse() {
if (g_sse) return;
fn_sse::Config cfg;
cfg.url = state().backend_url + "/api/sse";
g_sse = std::make_unique<fn_sse::Client>();
g_sse->start(
cfg,
[](const fn_sse::Event& /*ev*/) {
std::lock_guard<std::mutex> g(state().mu);
state().last_refresh_ns = 0;
},
nullptr);
}
void stop_sse() {
if (g_sse) {
g_sse->stop();
g_sse.reset();
}
}
static bool contains(const std::set<std::string>& s, const std::string& v) {
return s.find(v) != s.end();
}
bool passes_filters(const Issue& iss) {
const auto& f = state().filters;
if (!f.include_completed && iss.completed) return false;
if (!f.priorities.empty() && !contains(f.priorities, iss.priority)) return false;
if (!f.scopes.empty() && !contains(f.scopes, iss.scope)) return false;
if (!f.domains.empty()) {
bool match = false;
for (const auto& d : iss.domain) {
if (contains(f.domains, d)) {
match = true;
break;
}
}
if (!match) return false;
}
if (!f.tags.empty()) {
bool match = false;
for (const auto& t : iss.tags) {
if (contains(f.tags, t)) {
match = true;
break;
}
}
if (!match) return false;
}
return true;
}
std::vector<std::string> collect_domains() {
std::set<std::string> uniq;
for (const auto& i : state().issues) {
for (const auto& d : i.domain) uniq.insert(d);
}
return {uniq.begin(), uniq.end()};
}
std::vector<std::string> collect_tags() {
std::set<std::string> uniq;
for (const auto& i : state().issues) {
for (const auto& t : i.tags) uniq.insert(t);
}
return {uniq.begin(), uniq.end()};
}
} // namespace kanban
+79 -45
View File
@@ -1,61 +1,95 @@
// 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 #pragma once
#include <map>
#include <mutex>
#include <set>
#include <string> #include <string>
#include <vector> #include <vector>
#include <cstdint>
namespace kanban_cpp { namespace kanban {
struct Card { struct Issue {
std::string id; std::string id;
std::string title; 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; std::string status;
int64_t started_at = 0; std::string type;
int64_t finished_at = 0; std::string scope;
std::string priority;
std::vector<std::string> domain;
std::vector<std::string> tags;
std::vector<std::string> depends;
std::vector<std::string> blocks;
std::vector<std::string> related;
std::string flow;
std::string file_path;
bool completed = false;
std::string body; // only filled in detail GET
}; };
struct ClientConfig { struct Flow {
std::string base_url = "http://127.0.0.1:8403"; std::string id;
std::string agent_runner_url = "http://127.0.0.1:8486"; std::string title;
int timeout_ms = 3000; std::string status;
std::string kind;
std::vector<std::string> tags;
std::string file_path;
std::string body;
}; };
// HTTP GETs --------------------------------------------------------------- struct Meta {
std::vector<Card> list_cards(const ClientConfig& cfg, std::string& err); std::vector<std::string> statuses;
std::vector<Column> list_columns(const ClientConfig& cfg, std::string& err); std::vector<std::string> priorities;
bool health(const ClientConfig& cfg); // GET /health std::vector<std::string> scopes;
std::vector<std::string> types;
};
// HTTP mutations ---------------------------------------------------------- struct Filters {
bool move_card(const ClientConfig& cfg, const std::string& card_id, std::set<std::string> domains;
const std::string& new_column_id, std::string& err); std::set<std::string> scopes;
std::set<std::string> priorities;
std::set<std::string> tags;
bool include_completed = false;
bool show_issues = true;
bool show_flows = true;
};
// agent_runner_api ------------------------------------------------------- struct State {
std::vector<AgentRunSummary> list_runs(const ClientConfig& cfg, std::string& err); std::mutex mu;
bool launch_workflow(const ClientConfig& cfg, const std::string& card_id, std::string backend_url = "http://127.0.0.1:8487";
std::string& out_run_id, std::string& err); std::vector<Issue> issues;
std::vector<Flow> flows;
Meta meta;
Filters filters;
std::string selected_issue_id;
Issue selected_issue_detail;
bool loading = false;
std::string last_error;
long long last_refresh_ns = 0;
std::map<std::string, std::string> agent_active; // issue_id -> run_id
bool agent_runner_up = false;
std::string last_launch_msg;
};
} // namespace kanban_cpp State& state();
// HTTP operations (block briefly, called from background or coalesced).
bool refresh_issues();
bool refresh_flows();
bool refresh_meta();
bool refresh_issue_detail(const std::string& id);
bool patch_issue_status(const std::string& id, const std::string& new_status);
bool patch_issue_fields(const std::string& id, const std::string& json_partial);
bool refresh_agent_status();
bool launch_agent(const std::string& issue_id);
bool is_agent_active(const std::string& issue_id);
// Background SSE subscription (no-op if already started).
void start_sse();
void stop_sse();
// Filters helpers.
bool passes_filters(const Issue& iss);
std::vector<std::string> collect_domains();
std::vector<std::string> collect_tags();
} // namespace kanban
+85 -80
View File
@@ -1,109 +1,114 @@
// main.cpp — kanban_cpp entry point. #include <imgui.h>
// #include <atomic>
// Six panels declared via cfg.panels. fn::run_app paints the menubar / #include <chrono>
// dockspace / about / layouts automatically. #include <cstdio>
#include <cstring>
#include <string>
#include <thread>
#include "app_base.h" #include "app_base.h"
#include "core/panel_menu.h" #include "core/panel_menu.h"
#include "core/icons_tabler.h" #include "core/icons_tabler.h"
#include "core/logger.h" #include "core/logger.h"
#include "core/sse_client.h"
#include "panels.h" #include "panels.h"
#include "data.h"
#include <imgui.h> static bool g_show_board = true;
#include <chrono> static bool g_show_flows = true;
#include <cstring> static bool g_show_filters = true;
#include <cstdio> static bool g_show_detail = true;
#include <mutex>
#include <string>
#include <thread>
static bool g_show_board = true; static std::atomic<bool> g_refresh_thread_alive{false};
static bool g_show_calendar = true; static std::thread g_refresh_thread;
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 start_refresh_thread() {
g_refresh_thread_alive = true;
// SSE client: receives push notifications from the backend stream so the g_refresh_thread = std::thread([]() {
// board updates without polling. Lifetime tied to main() — stop() before kanban::refresh_meta();
// returning so the worker thread joins cleanly. kanban::refresh_issues();
static fn_sse::Client g_sse_client; kanban::refresh_flows();
kanban::refresh_agent_status();
static void render() { kanban::start_sse();
if (g_show_board) kanban_cpp::draw_board (g_state, &g_show_board); int tick = 0;
if (g_show_calendar) kanban_cpp::draw_calendar (g_state, &g_show_calendar); while (g_refresh_thread_alive) {
if (g_show_dashboard) kanban_cpp::draw_dashboard (g_state, &g_show_dashboard); // Fast loop (every 3s) for agent status; full refresh every 30s.
if (g_show_runs) kanban_cpp::draw_agent_runs(g_state, &g_show_runs); std::this_thread::sleep_for(std::chrono::seconds(3));
if (g_show_worktrees) kanban_cpp::draw_worktrees (g_state, &g_show_worktrees); if (!g_refresh_thread_alive) break;
if (g_show_dod) kanban_cpp::draw_dod (g_state, &g_show_dod); kanban::refresh_agent_status();
if (++tick >= 10) {
tick = 0;
kanban::refresh_issues();
kanban::refresh_flows();
}
}
kanban::stop_sse();
});
}
static void stop_refresh_thread() {
g_refresh_thread_alive = false;
if (g_refresh_thread.joinable()) g_refresh_thread.join();
}
static void render() {
if (g_show_board) kanban::draw_board();
if (g_show_flows) kanban::draw_flows();
if (g_show_filters) kanban::draw_filters();
if (g_show_detail) kanban::draw_detail();
} }
// 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() { static int run_self_test() {
std::printf("kanban_cpp --self-test\n"); kanban::State& s = kanban::state();
kanban_cpp::AppState s; s.backend_url = "http://127.0.0.1:1";
s.cfg.base_url = "http://127.0.0.1:65535"; // unreachable on purpose bool ok_issues = kanban::refresh_issues();
bool ok = kanban_cpp::health(s.cfg); bool ok_flows = kanban::refresh_flows();
std::printf(" health(unreachable) = %s (expected: false)\n", ok ? "true" : "false"); if (ok_issues || ok_flows) {
if (ok) return 1; std::fprintf(stderr, "[self-test] expected refresh to fail on unreachable backend\n");
std::printf("OK\n"); return 1;
}
kanban::Issue dummy;
dummy.status = "pendiente";
dummy.priority = "media";
if (!kanban::passes_filters(dummy)) {
std::fprintf(stderr, "[self-test] empty filters should let issue pass\n");
return 2;
}
s.filters.priorities.insert("alta");
if (kanban::passes_filters(dummy)) {
std::fprintf(stderr, "[self-test] priority filter should reject media when alta required\n");
return 3;
}
std::fprintf(stdout, "[self-test] ok\n");
return 0; return 0;
} }
int main(int argc, char** argv) { int main(int argc, char** argv) {
for (int i = 1; i < argc; ++i) { for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], "--self-test") == 0) return run_self_test(); if (std::strcmp(argv[i], "--self-test") == 0) {
return run_self_test();
}
if (std::strcmp(argv[i], "--backend") == 0 && i + 1 < argc) {
kanban::state().backend_url = argv[++i];
}
} }
static fn_ui::PanelToggle panels[] = { static fn_ui::PanelToggle panels[] = {
{ "Board", nullptr, &g_show_board }, { "Board", nullptr, &g_show_board },
{ "Calendar", nullptr, &g_show_calendar }, { "Flows", nullptr, &g_show_flows },
{ "Dashboard", nullptr, &g_show_dashboard }, { "Filters", nullptr, &g_show_filters },
{ "Agent runs", nullptr, &g_show_runs }, { "Detail", nullptr, &g_show_detail },
{ "Worktrees", nullptr, &g_show_worktrees },
{ "DoD inspector", nullptr, &g_show_dod },
}; };
fn::AppConfig cfg; fn::AppConfig cfg;
cfg.title = "kanban_cpp — agentes LLM con DoD"; cfg.title = "kanban_cpp v2 — dev/issues + dev/flows";
cfg.about = { "kanban_cpp", "0.1.0", cfg.about = { "kanban_cpp", "0.2.0", "Kanban C++ v2 — gestor de dev/issues y dev/flows del registry" };
"Clon C++ ImGui de kanban_web — agentes LLM con DoD evidence" };
cfg.log = { "kanban_cpp.log", 1 }; cfg.log = { "kanban_cpp.log", 1 };
cfg.panels = panels; cfg.panels = panels;
cfg.panel_count = sizeof(panels) / sizeof(panels[0]); cfg.panel_count = sizeof(panels) / sizeof(panels[0]);
// First refresh on startup en thread separado: no bloquea primer frame start_refresh_thread();
// si el backend :8403 esta caido (timeout HTTP ~9s).
std::thread([](){ kanban_cpp::refresh_data(g_state); }).detach();
// SSE live updates: arranca tras 500ms para no competir con el primer
// refresh inicial. Auto-reconecta con backoff si el endpoint no existe
// aun o si el backend cae — NUNCA crashea el frame.
std::thread([](){
std::this_thread::sleep_for(std::chrono::milliseconds(500));
fn_sse::Config sse_cfg;
sse_cfg.url = g_state.cfg.base_url + "/api/boards/issues/stream";
sse_cfg.auto_reconnect = true;
g_sse_client.start(sse_cfg,
// on_event: cualquier evento dispara un refresh asincrono.
[](const fn_sse::Event& /*ev*/) {
std::thread([](){ kanban_cpp::refresh_data(g_state); }).detach();
},
// on_status: actualiza el badge UI bajo mutex.
[](const std::string& status) {
std::lock_guard<std::mutex> lock(g_state.mu);
g_state.sse_status = status;
});
}).detach();
int rc = fn::run_app(cfg, render); int rc = fn::run_app(cfg, render);
stop_refresh_thread();
// Kill SSE worker antes de salir — orden importa para evitar dangling
// thread storage cuando se destruyen los globales.
g_sse_client.stop();
return rc; return rc;
} }
BIN
View File
Binary file not shown.
-72
View File
@@ -1,72 +0,0 @@
// 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
+290 -129
View File
@@ -1,159 +1,320 @@
// panel_board.cpp — columns + cards Kanban panel.
#include "panels.h" #include "panels.h"
#include "core/icons_tabler.h" #include "data.h"
#include <imgui.h> #include <imgui.h>
#include <ctime> #include <imgui_internal.h>
#include <thread> #include <map>
#include <mutex> #include <mutex>
#include <string>
#include <utility>
#include <vector>
namespace kanban_cpp { #include "core/icons_tabler.h"
void refresh_data(AppState& s) { namespace kanban {
std::string err;
auto cards = list_cards(s.cfg, err); static const char* k_columns[] = {"ideas", "pendiente", "in-progress", "completado"};
std::string err_cards = err; err.clear(); static const char* k_column_icons[] = {TI_BULB, TI_CLIPBOARD, TI_TOOLS, TI_CIRCLE_CHECK};
auto columns = list_columns(s.cfg, err); static const int k_n_columns = sizeof(k_columns) / sizeof(k_columns[0]);
std::string err_cols = err;
bool ok = health(s.cfg); // Map flow status (free-form) → one of the 4 board columns.
int64_t ts = std::time(nullptr); static int flow_column_index(const std::string& s) {
std::lock_guard<std::mutex> lock(s.mu); if (s == "draft" || s == "ideas") return 0;
s.cards = std::move(cards); if (s == "active" || s == "in-progress") return 2;
s.columns = std::move(columns); if (s == "done" || s == "completed" || s == "completado") return 3;
s.last_refresh_error.clear(); // pending, paused, "" → pendiente
if (!err_cards.empty()) s.last_refresh_error = "cards: " + err_cards; return 1;
if (!err_cols.empty()) s.last_refresh_error += " columns: " + err_cols;
s.backend_ok = ok;
s.last_refresh_ts = ts;
} }
void draw_board(AppState& s, bool* p_open) { static ImU32 priority_color(const std::string& p) {
if (!ImGui::Begin(TI_LAYOUT_KANBAN " Board", p_open)) { if (p == "critica") return IM_COL32(255, 80, 80, 255);
if (p == "alta") return IM_COL32(255, 165, 0, 255);
if (p == "media") return IM_COL32(120, 170, 255, 255);
if (p == "baja") return IM_COL32(140, 140, 140, 255);
return IM_COL32(180, 180, 180, 255);
}
static ImU32 status_tint(const std::string& s) {
if (s == "bloqueado") return IM_COL32(120, 60, 60, 200);
if (s == "completado") return IM_COL32( 60, 110, 60, 100);
if (s == "in-progress") return IM_COL32( 70, 90, 140, 100);
if (s == "ideas") return IM_COL32(110, 90, 140, 100);
return IM_COL32(60, 60, 70, 100);
}
static void draw_card(const Issue& iss) {
ImGui::PushID(iss.id.c_str());
const float card_h = 110.0f;
ImVec2 size(0.0f, card_h);
ImU32 bg = status_tint(iss.status);
ImGui::PushStyleColor(ImGuiCol_ChildBg, bg);
ImGui::BeginChild("card", size, true, ImGuiWindowFlags_NoScrollbar);
// Top row: id · priority dot · agent icon (right-aligned) · block badge if blocked
ImGui::TextColored(ImColor(200, 200, 200, 255), "%s", iss.id.c_str());
ImGui::SameLine();
{
ImVec2 cur = ImGui::GetCursorScreenPos();
ImU32 c = priority_color(iss.priority);
ImDrawList* dl = ImGui::GetWindowDrawList();
float r = 5.0f;
dl->AddCircleFilled(ImVec2(cur.x + r, cur.y + r + 2), r, c);
ImGui::Dummy(ImVec2(2 * r + 4, 2 * r));
ImGui::SameLine();
ImGui::TextColored(ImColor(180, 180, 180, 255), "%s", iss.priority.c_str());
}
// Right-aligned indicators (agent active + blocked).
{
std::string indicators;
bool active = is_agent_active(iss.id);
if (active) indicators += TI_ROBOT;
if (iss.status == "bloqueado") { if (!indicators.empty()) indicators += " "; indicators += TI_LOCK; }
if (!indicators.empty()) {
ImVec2 ts = ImGui::CalcTextSize(indicators.c_str());
float avail = ImGui::GetContentRegionAvail().x;
ImGui::SameLine(0, avail - ts.x - 4);
if (active) {
ImGui::TextColored(ImColor(120, 220, 120, 255), "%s", indicators.c_str());
} else {
ImGui::TextColored(ImColor(220, 120, 120, 255), "%s", indicators.c_str());
}
}
}
// Title (2-3 lines)
std::string t = iss.title;
if (t.size() > 140) t = t.substr(0, 137) + "...";
ImGui::TextWrapped("%s", t.c_str());
// Bottom: first domain chip
if (!iss.domain.empty()) {
ImGui::TextColored(ImColor(140, 200, 255, 255), "%s %s", TI_TAG, iss.domain[0].c_str());
}
ImGui::EndChild();
ImGui::PopStyleColor();
// Click → load detail
if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) {
refresh_issue_detail(iss.id);
}
// Drag source
if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_None)) {
ImGui::SetDragDropPayload("KANBAN_ISSUE", iss.id.c_str(), iss.id.size() + 1);
ImGui::Text("%s %s", iss.id.c_str(), t.c_str());
ImGui::EndDragDropSource();
}
// Right-click context menu
if (ImGui::BeginPopupContextItem("card_ctx")) {
if (iss.status == "bloqueado") {
if (ImGui::MenuItem(TI_LOCK_OPEN " Unblock (→ pendiente)")) {
patch_issue_status(iss.id, "pendiente");
}
} else {
if (ImGui::MenuItem(TI_LOCK " Block this issue")) {
patch_issue_status(iss.id, "bloqueado");
}
}
ImGui::Separator();
if (ImGui::MenuItem(TI_PLAYER_PLAY " Launch agent (fix-issue)", nullptr, false, !is_agent_active(iss.id))) {
launch_agent(iss.id);
}
if (is_agent_active(iss.id)) {
ImGui::TextDisabled(TI_ROBOT " agent already running");
}
ImGui::Separator();
if (ImGui::MenuItem("Move → ideas")) patch_issue_status(iss.id, "ideas");
if (ImGui::MenuItem("Move → pendiente")) patch_issue_status(iss.id, "pendiente");
if (ImGui::MenuItem("Move → in-progress")) patch_issue_status(iss.id, "in-progress");
if (ImGui::MenuItem("Move → completado")) patch_issue_status(iss.id, "completado");
ImGui::EndPopup();
}
ImGui::PopID();
}
static void draw_flow_card(const Flow& fl, int child_issue_count) {
ImGui::PushID(("flow_" + fl.id).c_str());
const float card_h = 88.0f;
ImVec2 size(0.0f, card_h);
ImU32 bg = IM_COL32(80, 60, 110, 100);
ImGui::PushStyleColor(ImGuiCol_ChildBg, bg);
ImGui::BeginChild("flow_card", size, true, ImGuiWindowFlags_NoScrollbar);
ImGui::TextColored(ImColor(190, 170, 230, 255), "%s flow %s", TI_GIT_BRANCH, fl.id.c_str());
{
ImVec2 ts = ImGui::CalcTextSize("999 issues");
float avail = ImGui::GetContentRegionAvail().x;
ImGui::SameLine(0, avail - ts.x - 4);
ImGui::TextDisabled("%d issues", child_issue_count);
}
std::string t = fl.title;
if (t.size() > 140) t = t.substr(0, 137) + "...";
ImGui::TextWrapped("%s", t.c_str());
if (!fl.tags.empty()) {
ImGui::TextColored(ImColor(140, 200, 255, 255), "%s %s", TI_TAG, fl.tags[0].c_str());
}
ImGui::EndChild();
ImGui::PopStyleColor();
// Click → set selected_issue_id to first child issue (best UX guess).
if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) {
std::lock_guard<std::mutex> g(state().mu);
for (const auto& iss : state().issues) {
if (iss.flow == fl.id) {
state().selected_issue_id = iss.id;
state().selected_issue_detail = iss;
break;
}
}
}
if (ImGui::BeginPopupContextItem("flow_ctx")) {
ImGui::TextDisabled("flow %s — %d issues", fl.id.c_str(), child_issue_count);
ImGui::Separator();
ImGui::TextDisabled("(no actions; edit %s manually)", fl.file_path.c_str());
ImGui::EndPopup();
}
ImGui::PopID();
}
static void draw_column(const char* status_key, const char* icon,
std::vector<const Issue*>& issues,
std::vector<std::pair<const Flow*, int>>& flows) {
ImGui::TableNextColumn();
ImGui::PushFont(nullptr);
size_t total = issues.size() + flows.size();
ImGui::TextColored(ImColor(220, 220, 220, 255), "%s %s (%zu)", icon, status_key, total);
ImGui::PopFont();
ImGui::Separator();
ImVec2 region = ImGui::GetContentRegionAvail();
ImGui::BeginChild((std::string("col_") + status_key).c_str(), region, false);
// Issues first (heavier visual), then flows (lighter).
for (const auto* iss : issues) {
draw_card(*iss);
}
for (const auto& [fl, cnt] : flows) {
draw_flow_card(*fl, cnt);
}
ImGui::InvisibleButton("col_drop", ImVec2(-1, 24));
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("KANBAN_ISSUE")) {
std::string id((const char*)p->Data, p->DataSize - 1);
patch_issue_status(id, status_key);
}
ImGui::EndDragDropTarget();
}
ImGui::EndChild();
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("KANBAN_ISSUE")) {
std::string id((const char*)p->Data, p->DataSize - 1);
patch_issue_status(id, status_key);
}
ImGui::EndDragDropTarget();
}
}
void draw_board() {
if (!ImGui::Begin(TI_LAYOUT_KANBAN " Board")) {
ImGui::End(); ImGui::End();
return; return;
} }
// Snapshot bajo lock — refresh corre en thread separado. std::vector<Issue> issue_snap;
std::vector<Card> cards_snap; std::vector<Flow> flow_snap;
std::vector<Column> cols_snap; bool agent_up = false;
bool backend_ok_snap; int agent_running_count = 0;
std::string err_snap; std::string last_launch;
std::string sse_snap; bool show_issues_flag = true;
bool show_flows_flag = true;
{ {
std::lock_guard<std::mutex> lock(s.mu); std::lock_guard<std::mutex> g(state().mu);
cards_snap = s.cards; issue_snap = state().issues;
cols_snap = s.columns; flow_snap = state().flows;
backend_ok_snap = s.backend_ok; agent_up = state().agent_runner_up;
err_snap = s.last_refresh_error; agent_running_count = (int)state().agent_active.size();
sse_snap = s.sse_status; last_launch = state().last_launch_msg;
show_issues_flag = state().filters.show_issues;
show_flows_flag = state().filters.show_flows;
} }
// Toolbar — refresh corre en thread separado (no bloquea frame). // Bucket issues per column.
if (ImGui::Button(TI_REFRESH " Refresh")) { std::vector<std::vector<const Issue*>> issue_buckets(k_n_columns);
std::thread([&s](){ refresh_data(s); }).detach(); int filtered_out = 0;
if (show_issues_flag) {
for (const auto& iss : issue_snap) {
if (!passes_filters(iss)) {
filtered_out++;
continue;
}
std::string s = iss.status;
if (s == "bloqueado") s = "pendiente";
for (int c = 0; c < k_n_columns; ++c) {
if (s == k_columns[c]) {
issue_buckets[c].push_back(&iss);
break;
}
}
}
} }
// Count children per flow (issues that reference flow_id).
std::map<std::string, int> flow_child_count;
for (const auto& iss : issue_snap) {
if (!iss.flow.empty()) flow_child_count[iss.flow]++;
}
// Bucket flows per column using flow_column_index.
std::vector<std::vector<std::pair<const Flow*, int>>> flow_buckets(k_n_columns);
if (show_flows_flag) {
for (const auto& fl : flow_snap) {
int col = flow_column_index(fl.status);
int cnt = flow_child_count.count(fl.id) ? flow_child_count[fl.id] : 0;
flow_buckets[col].push_back({&fl, cnt});
}
}
// Header line
size_t total_visible = 0;
for (int c = 0; c < k_n_columns; ++c) total_visible += issue_buckets[c].size() + flow_buckets[c].size();
ImGui::Text("%zu issues + %zu flows — %d filtered out — visible %zu",
issue_snap.size(), flow_snap.size(), filtered_out, total_visible);
ImGui::SameLine(); ImGui::SameLine();
if (backend_ok_snap) { ImGui::TextDisabled(" | ");
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)");
}
// SSE live badge — refleja el estado del stream push del backend.
ImGui::SameLine(); ImGui::SameLine();
if (sse_snap == "connected") { if (agent_up) {
ImGui::TextColored(ImVec4(0.4f, 0.85f, 0.4f, 1.0f), TI_BROADCAST " live"); ImGui::TextColored(ImColor(120, 220, 120, 255), TI_ROBOT " agent_runner_api OK — %d active", agent_running_count);
} else if (sse_snap == "connecting") {
ImGui::TextColored(ImVec4(0.85f, 0.85f, 0.4f, 1.0f), TI_LOADER " connecting");
} else if (sse_snap == "disconnected") {
ImGui::TextColored(ImVec4(0.85f, 0.4f, 0.4f, 1.0f), TI_PLUG_CONNECTED_X " disconnected");
} else { } else {
// "error: <msg>" o cualquier otro string ImGui::TextColored(ImColor(220, 140, 140, 255), TI_ROBOT " agent_runner_api offline");
ImGui::TextColored(ImVec4(0.85f, 0.4f, 0.4f, 1.0f), TI_PLUG_CONNECTED_X " %s", sse_snap.c_str());
} }
if (!last_launch.empty()) {
if (!err_snap.empty()) {
ImGui::SameLine(); ImGui::SameLine();
ImGui::TextColored(ImVec4(0.85f, 0.6f, 0.2f, 1.0f), "%s", err_snap.c_str()); ImGui::TextDisabled(" | %s", last_launch.c_str());
} }
ImGui::Separator(); ImGui::Separator();
// Empty state if (ImGui::BeginTable("board_table", k_n_columns,
if (cols_snap.empty()) { ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_Resizable | ImGuiTableFlags_SizingStretchSame)) {
ImGui::TextDisabled("No columns yet. Pulsa Refresh o lanza el backend en :8403."); ImGui::TableNextRow();
ImGui::End(); for (int c = 0; c < k_n_columns; ++c) {
return; draw_column(k_columns[c], k_column_icons[c], issue_buckets[c], flow_buckets[c]);
}
// 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 < cols_snap.size(); ++ci) {
const auto& col = cols_snap[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 : cards_snap) if (c.column_id == col.id) ++count;
ImGui::TextDisabled("(%d)", count);
ImGui::Separator();
for (const auto& card : cards_snap) {
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 : cols_snap) {
if (tgt.id == card.column_id) continue;
if (ImGui::MenuItem(tgt.name.c_str())) {
std::thread([&s, card_id=card.id, tgt_id=tgt.id](){
std::string err;
if (!move_card(s.cfg, card_id, tgt_id, err)) {
std::lock_guard<std::mutex> lock(s.mu);
s.last_refresh_error = "move: " + err;
return;
}
refresh_data(s);
}).detach();
}
}
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::EndTable();
} }
ImGui::EndChild();
ImGui::End(); ImGui::End();
} }
} // namespace kanban_cpp } // namespace kanban
-104
View File
@@ -1,104 +0,0 @@
// 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>
#include <mutex>
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();
std::vector<Card> cards_snap;
{
std::lock_guard<std::mutex> lock(s.mu);
cards_snap = s.cards;
}
// 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 : cards_snap) {
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
-79
View File
@@ -1,79 +0,0 @@
// 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 <mutex>
#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();
std::vector<Card> cards_snap;
{
std::lock_guard<std::mutex> lock(s.mu);
cards_snap = s.cards;
}
// Snapshot counts
int total = static_cast<int>(cards_snap.size());
std::map<std::string, int> by_priority;
std::map<std::string, int> by_status;
for (const auto& c : cards_snap) {
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
+229
View File
@@ -0,0 +1,229 @@
#include "panels.h"
#include "data.h"
#include <imgui.h>
#include <mutex>
#include <string>
#include <vector>
#include "core/icons_tabler.h"
namespace kanban {
static std::string join(const std::vector<std::string>& xs, const char* sep) {
std::string out;
for (size_t i = 0; i < xs.size(); ++i) {
if (i) out += sep;
out += xs[i];
}
return out;
}
static int find_idx(const std::vector<std::string>& xs, const std::string& v) {
for (size_t i = 0; i < xs.size(); ++i) if (xs[i] == v) return (int)i;
return -1;
}
static std::vector<std::string> split_csv(const std::string& s) {
std::vector<std::string> out;
std::string cur;
for (char c : s) {
if (c == ',') {
// trim
while (!cur.empty() && (cur.front() == ' ' || cur.front() == '\t')) cur.erase(cur.begin());
while (!cur.empty() && (cur.back() == ' ' || cur.back() == '\t')) cur.pop_back();
if (!cur.empty()) out.push_back(cur);
cur.clear();
} else {
cur.push_back(c);
}
}
while (!cur.empty() && (cur.front() == ' ' || cur.front() == '\t')) cur.erase(cur.begin());
while (!cur.empty() && (cur.back() == ' ' || cur.back() == '\t')) cur.pop_back();
if (!cur.empty()) out.push_back(cur);
return out;
}
static std::string js_arr(const std::vector<std::string>& xs) {
std::string out = "[";
for (size_t i = 0; i < xs.size(); ++i) {
if (i) out += ",";
out += "\"";
for (char c : xs[i]) {
if (c == '"' || c == '\\') out += '\\';
out += c;
}
out += "\"";
}
out += "]";
return out;
}
static std::string js_str(const std::string& s) {
std::string out = "\"";
for (char c : s) {
if (c == '"' || c == '\\') out += '\\';
out += c;
}
out += "\"";
return out;
}
void draw_detail() {
if (!ImGui::Begin(TI_INFO_CIRCLE " Detail")) {
ImGui::End();
return;
}
Issue iss;
Meta meta;
std::string sel_id;
{
std::lock_guard<std::mutex> g(state().mu);
sel_id = state().selected_issue_id;
iss = state().selected_issue_detail;
meta = state().meta;
}
if (sel_id.empty()) {
ImGui::TextDisabled("Click a card on the Board to load detail.");
ImGui::End();
return;
}
ImGui::Text("%s — %s", iss.id.c_str(), iss.title.c_str());
ImGui::SameLine();
if (ImGui::SmallButton(TI_REFRESH "##reload")) {
refresh_issue_detail(sel_id);
}
ImGui::Separator();
// Status combo
int s_idx = find_idx(meta.statuses, iss.status);
{
std::vector<const char*> items;
for (const auto& s : meta.statuses) items.push_back(s.c_str());
if (!items.empty() && ImGui::Combo("Status", &s_idx, items.data(), (int)items.size())) {
if (s_idx >= 0 && s_idx < (int)meta.statuses.size()) {
patch_issue_status(sel_id, meta.statuses[s_idx]);
refresh_issue_detail(sel_id);
}
}
}
// Priority combo
int p_idx = find_idx(meta.priorities, iss.priority);
{
std::vector<const char*> items;
for (const auto& s : meta.priorities) items.push_back(s.c_str());
if (!items.empty() && ImGui::Combo("Priority", &p_idx, items.data(), (int)items.size())) {
if (p_idx >= 0 && p_idx < (int)meta.priorities.size()) {
std::string body = "{\"priority\":" + js_str(meta.priorities[p_idx]) + "}";
patch_issue_fields(sel_id, body);
refresh_issue_detail(sel_id);
}
}
}
// Scope combo
int sc_idx = find_idx(meta.scopes, iss.scope);
{
std::vector<const char*> items;
for (const auto& s : meta.scopes) items.push_back(s.c_str());
if (!items.empty() && ImGui::Combo("Scope", &sc_idx, items.data(), (int)items.size())) {
if (sc_idx >= 0 && sc_idx < (int)meta.scopes.size()) {
std::string body = "{\"scope\":" + js_str(meta.scopes[sc_idx]) + "}";
patch_issue_fields(sel_id, body);
refresh_issue_detail(sel_id);
}
}
}
ImGui::Separator();
// Tags / domain / depends / blocks via CSV editors
auto edit_csv = [&](const char* label, std::vector<std::string>& field, const char* json_key) {
static char buf[1024];
std::string cur = join(field, ", ");
std::snprintf(buf, sizeof(buf), "%s", cur.c_str());
if (ImGui::InputText(label, buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue)) {
auto parts = split_csv(buf);
std::string body = "{\"";
body += json_key;
body += "\":";
body += js_arr(parts);
body += "}";
patch_issue_fields(sel_id, body);
refresh_issue_detail(sel_id);
}
};
// For each list, we render in its own scope to keep buf separate.
// ImGui uses widget id to disambiguate; we add ## suffix.
{
static char buf[1024];
std::string cur = join(iss.tags, ", ");
std::snprintf(buf, sizeof(buf), "%s", cur.c_str());
ImGui::PushID("tags");
if (ImGui::InputText("Tags (CSV, Enter to save)", buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue)) {
auto parts = split_csv(buf);
std::string body = "{\"tags\":" + js_arr(parts) + "}";
patch_issue_fields(sel_id, body);
refresh_issue_detail(sel_id);
}
ImGui::PopID();
}
{
static char buf[1024];
std::string cur = join(iss.domain, ", ");
std::snprintf(buf, sizeof(buf), "%s", cur.c_str());
ImGui::PushID("domain");
if (ImGui::InputText("Domain (CSV, Enter to save)", buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue)) {
auto parts = split_csv(buf);
std::string body = "{\"domain\":" + js_arr(parts) + "}";
patch_issue_fields(sel_id, body);
refresh_issue_detail(sel_id);
}
ImGui::PopID();
}
{
static char buf[1024];
std::string cur = join(iss.depends, ", ");
std::snprintf(buf, sizeof(buf), "%s", cur.c_str());
ImGui::PushID("depends");
if (ImGui::InputText("Depends (CSV, Enter)", buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue)) {
auto parts = split_csv(buf);
std::string body = "{\"depends\":" + js_arr(parts) + "}";
patch_issue_fields(sel_id, body);
refresh_issue_detail(sel_id);
}
ImGui::PopID();
}
{
static char buf[1024];
std::string cur = join(iss.blocks, ", ");
std::snprintf(buf, sizeof(buf), "%s", cur.c_str());
ImGui::PushID("blocks");
if (ImGui::InputText("Blocks (CSV, Enter)", buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue)) {
auto parts = split_csv(buf);
std::string body = "{\"blocks\":" + js_arr(parts) + "}";
patch_issue_fields(sel_id, body);
refresh_issue_detail(sel_id);
}
ImGui::PopID();
}
ImGui::Separator();
ImGui::TextDisabled("%s", iss.file_path.c_str());
if (ImGui::CollapsingHeader("Body (read-only)")) {
ImGui::BeginChild("body_ro", ImVec2(0, 320), true);
ImGui::TextWrapped("%s", iss.body.c_str());
ImGui::EndChild();
}
(void)edit_csv; // silence unused (we inline the variants instead)
ImGui::End();
}
} // namespace kanban
-43
View File
@@ -1,43 +0,0 @@
// 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
+78
View File
@@ -0,0 +1,78 @@
#include "panels.h"
#include "data.h"
#include <imgui.h>
#include <mutex>
#include <set>
#include <vector>
#include "core/icons_tabler.h"
namespace kanban {
static void multi_select(const char* label, const std::vector<std::string>& options, std::set<std::string>& selected) {
if (ImGui::CollapsingHeader(label, ImGuiTreeNodeFlags_DefaultOpen)) {
for (const auto& opt : options) {
bool in = selected.find(opt) != selected.end();
std::string id = std::string(label) + "##" + opt;
if (ImGui::Checkbox(id.c_str(), &in)) {
if (in) selected.insert(opt);
else selected.erase(opt);
}
ImGui::SameLine();
ImGui::TextUnformatted(opt.c_str());
}
if (!selected.empty()) {
std::string btn_id = std::string("Clear##") + label;
if (ImGui::SmallButton(btn_id.c_str())) selected.clear();
}
}
}
void draw_filters() {
if (!ImGui::Begin(TI_FILTER " Filters")) {
ImGui::End();
return;
}
std::vector<std::string> domains, tags;
Meta meta;
{
std::lock_guard<std::mutex> g(state().mu);
meta = state().meta;
}
domains = collect_domains();
tags = collect_tags();
// Type toggle (Issues / Flows / Both).
ImGui::TextUnformatted("Show:");
ImGui::SameLine();
ImGui::Checkbox("Issues", &state().filters.show_issues);
ImGui::SameLine();
ImGui::Checkbox("Flows", &state().filters.show_flows);
ImGui::Separator();
multi_select("Priorities", meta.priorities, state().filters.priorities);
multi_select("Scopes", meta.scopes, state().filters.scopes);
multi_select("Domains", domains, state().filters.domains);
multi_select("Tags", tags, state().filters.tags);
ImGui::Separator();
ImGui::Checkbox("Include completed", &state().filters.include_completed);
ImGui::Separator();
if (ImGui::Button(TI_REFRESH " Refresh all")) {
refresh_issues();
refresh_flows();
}
{
std::lock_guard<std::mutex> g(state().mu);
if (!state().last_error.empty()) {
ImGui::TextColored(ImColor(255, 100, 100, 255), "%s", state().last_error.c_str());
}
}
ImGui::End();
}
} // namespace kanban
+69
View File
@@ -0,0 +1,69 @@
#include "panels.h"
#include "data.h"
#include <imgui.h>
#include <mutex>
#include <vector>
#include "core/icons_tabler.h"
namespace kanban {
static int g_selected_flow = -1;
void draw_flows() {
if (!ImGui::Begin(TI_LIST " Flows")) {
ImGui::End();
return;
}
std::vector<Flow> snapshot;
{
std::lock_guard<std::mutex> g(state().mu);
snapshot = state().flows;
}
ImGui::Text("%zu flows", snapshot.size());
ImGui::Separator();
if (ImGui::BeginTable("flows_table", 4,
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY)) {
ImGui::TableSetupColumn("ID", ImGuiTableColumnFlags_WidthFixed, 60.0f);
ImGui::TableSetupColumn("Title", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 100.0f);
ImGui::TableSetupColumn("Kind", ImGuiTableColumnFlags_WidthFixed, 100.0f);
ImGui::TableHeadersRow();
for (int i = 0; i < (int)snapshot.size(); ++i) {
const auto& fl = snapshot[i];
ImGui::TableNextRow();
ImGui::TableNextColumn();
bool selected = (i == g_selected_flow);
if (ImGui::Selectable(fl.id.c_str(), selected, ImGuiSelectableFlags_SpanAllColumns)) {
g_selected_flow = i;
}
ImGui::TableNextColumn();
ImGui::TextUnformatted(fl.title.c_str());
ImGui::TableNextColumn();
ImGui::TextUnformatted(fl.status.c_str());
ImGui::TableNextColumn();
ImGui::TextUnformatted(fl.kind.c_str());
}
ImGui::EndTable();
}
if (g_selected_flow >= 0 && g_selected_flow < (int)snapshot.size()) {
const auto& fl = snapshot[g_selected_flow];
ImGui::Separator();
ImGui::Text("%s — %s", fl.id.c_str(), fl.title.c_str());
if (!fl.tags.empty()) {
ImGui::SameLine();
ImGui::TextColored(ImColor(140, 200, 255, 255), "%s %s", TI_TAG, fl.tags[0].c_str());
}
ImGui::TextWrapped("%s", fl.file_path.c_str());
}
ImGui::End();
}
} // namespace kanban
-91
View File
@@ -1,91 +0,0 @@
// 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
+6 -35
View File
@@ -1,39 +1,10 @@
// 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 #pragma once
#include "data.h" namespace kanban {
#include <mutex>
#include <string>
namespace kanban_cpp { void draw_board();
void draw_flows();
void draw_filters();
void draw_detail();
// Shared app state passed to every panel. Owned by main.cpp. } // namespace kanban
// `mu` guards cards/columns/backend_ok/last_refresh_*/sse_status — refresh_data
// corre en thread aparte y el SSE callback tambien lo hace, ambos escriben a
// traves del mismo mutex.
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;
// SSE live status: "connecting" | "connected" | "disconnected" | "error: <msg>"
std::string sse_status = "connecting";
std::mutex mu;
};
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