Compare commits

6 Commits

Author SHA1 Message Date
egutierrez 9abc0571d9 chore: auto-commit (1 archivos)
- operations.db

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:31:32 +02:00
agent 4f5e9f6fbe feat: wire sse_client_cpp_core for live updates from /api/boards/issues/stream 2026-05-18 20:05:14 +02:00
agent 264c5939f3 refactor(backend): trim kanban_web bloat (auth/chat/stickers/mcp) — keep sync layer + cards core 2026-05-18 19:48:46 +02:00
egutierrez c90683da8f merge issue/0119: issues/flows sync layer 2026-05-18 18:57:06 +02:00
agent 0b93a985d6 feat(backend): issues/flows sync layer (issue 0119)
Read dev/issues/*.md and dev/flows/*.md as kanban cards via new
/api/boards/{issues|flows}/cards endpoints. PATCH writes status back
to the frontmatter atomically (tmp + rename), POST .../launch proxies
to agent_runner_api.

- issues_source.go: scan + parse frontmatter (yaml.v3) into IssueCard.
  Skips README/INDEX/AGENT_GUIDE. Malformed YAML yields parse-error
  cards (no crash). Description = first 5 body lines (no full body).
- flows_source.go: same shape, distinct status->column mapping
  (pending/running/done/deferred -> Pending/Running/Done/Deferred).
- frontmatter_edit.go: PatchFrontmatterField — atomic, preserves the
  rest of the file byte-for-byte, inserts key if missing.
- handlers_boards.go: list + patch + launch endpoints, taxonomy 0103
  enforced. Cache 30s in memory, thread-safe (mutex), invalidated on
  PATCH. Launch returns 502 with suggestion when runner is down.
- main.go: SkipPaths += "/api/boards/" so the C++ frontend hits the
  read endpoints without a kanban_web session.

Smoke (FN_REGISTRY_ROOT pointed at the worktree, 87 issues + 9 flows
on disk):
  GET  /api/boards/issues/cards -> 200, 87 cards
  GET  /api/boards/flows/cards  -> 200, 9 cards
  PATCH /api/boards/issues/cards/0119 {status:en-curso} -> 200,
    file mtime changes, frontmatter rewritten, rest preserved
  POST /api/boards/issues/cards/0119/launch -> 502
    agent_runner_unreachable (expected, runner not yet implemented)

Tests: issues_source_test (3 cases incl. malformed + missing status),
flows_source_test (3 cases), frontmatter_edit_test (4 cases incl.
atomic rename + no tmp leftovers). Pre-existing tools_test failure
on TestExecuteTool_MoveCard_BetweenColumns_OpensHistory is unrelated
(CardHistoryResponse type assert, not touched here).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:56:22 +02:00
Egutierrez a76ec74338 feat: initial scaffold kanban_cpp v0.1.0
C++ ImGui kanban for steering LLM agents. Six panels (Board, Calendar,
Dashboard, Agent runs, Worktrees, DoD inspector) wired to registry
functions http_request, kpi_card, sparkline, agent_runs_timeline,
dod_evidence_panel. Backend Go on :8403 (independent operations.db from
kanban_web).
2026-05-18 18:46:09 +02:00
50 changed files with 4248 additions and 1980 deletions
+10 -3
View File
@@ -1,5 +1,12 @@
backend/kanban_cpp_backend
backend/operations.db*
backend/registry.db
build/
*.exe
*.log
backend/operations.db
backend/operations.db-shm
backend/operations.db-wal
backend/kanban_cpp_backend
backend/dist/*
!backend/dist/.gitkeep
local_files/
imgui.ini
app_settings.ini
+14 -7
View File
@@ -2,17 +2,24 @@ add_imgui_app(kanban_cpp
main.cpp
data.cpp
panel_board.cpp
panel_flows.cpp
panel_filters.cpp
panel_detail.cpp
panel_calendar.cpp
panel_dashboard.cpp
panel_agent_runs.cpp
panel_worktrees.cpp
panel_dod.cpp
# Registry functions consumed (see app.md::uses_functions)
${CMAKE_SOURCE_DIR}/functions/core/http_request.cpp
${CMAKE_SOURCE_DIR}/functions/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}
${CMAKE_SOURCE_DIR}/vendor
)
target_include_directories(kanban_cpp PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
if(WIN32)
target_link_libraries(kanban_cpp PRIVATE ws2_32)
set_target_properties(kanban_cpp PROPERTIES WIN32_EXECUTABLE TRUE)
endif()
+40 -38
View File
@@ -2,75 +2,77 @@
name: kanban_cpp
lang: cpp
domain: tools
version: 0.2.0
description: "Kanban C++ v2 — gestor de dev/issues y dev/flows del registry. Board drag-drop, edicion bidireccional de frontmatter MD"
tags: [imgui, kanban, dev_ux, issues, flows]
version: 0.1.0
description: "Clon C++ ImGui de kanban_web — tablero pensado para conducir agentes LLM con DoD evidence"
tags: [kanban, cpp, agents, imgui]
icon:
phosphor: "kanban"
phosphor: "columns"
accent: "#a855f7"
uses_functions:
- http_request_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: []
framework: "imgui"
entry_point: "main.cpp"
dir_path: "apps/kanban_cpp"
repo_url: "https://gitea.organic-machine.com/dataforge/kanban_cpp"
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
cmd: "cmake --build cpp/build/linux --target kanban_cpp -j"
timeout_s: 300
- id: self_test
cmd: "./cpp/build/linux/apps/kanban_cpp/kanban_cpp --self-test"
timeout_s: 30
- id: backend_build
cmd: "cd apps/kanban_cpp/backend && CGO_ENABLED=1 go build -tags fts5 -o kanban_cpp_backend ."
timeout_s: 180
---
# kanban_cpp
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.
Clon C++ ImGui de kanban_web — tablero pensado para conducir agentes LLM con DoD evidence.
Backend Go propio en `backend/` (puerto 8403 por defecto) con `operations.db` independiente del kanban_web original. NO sincroniza datos con `apps/kanban` a proposito.
## Panels
| Panel | Funcion del registry | Notas |
|---|---|---|
| Board | inline | columnas + cards, drag con ImGui::IsItemActive |
| Calendar | inline | vista mensual estatica (MVP) |
| Dashboard | `kpi_card_cpp_viz` + `sparkline_cpp_viz` | KPIs (total, by_status, by_priority) |
| Agent runs | `agent_runs_timeline_cpp_viz` | populated por HTTP poll a agent_runner_api:8486 |
| Worktrees | inline | `git worktree list --porcelain` via popen |
| DoD inspector | `dod_evidence_panel_cpp_viz` | inspecciona DoD items + evidencias |
## Build
```bash
# Backend
cd apps/kanban_cpp/backend && CGO_ENABLED=1 go build -o kanban_cpp_backend .
cd apps/kanban_cpp/backend && CGO_ENABLED=1 go build -tags fts5 -o kanban_cpp_backend .
./kanban_cpp_backend --port 8403 --db operations.db
# Frontend
cmake --build cpp/build/linux --target kanban_cpp -j
# Frontend ImGui
cd cpp && cmake -B build/linux && cmake --build build/linux --target kanban_cpp -j
./build/linux/apps/kanban_cpp/kanban_cpp
```
## Run
## Cuando usarla
```bash
# Terminal 1: backend
apps/kanban_cpp/backend/kanban_cpp_backend --port 8487 --registry $PWD
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.
# Terminal 2: frontend
./cpp/build/linux/apps/kanban_cpp/kanban_cpp
```
## Gotchas
Por defecto el frontend apunta a `http://127.0.0.1:8487`. Cambiar con `--backend http://host:port`.
- 2 services + 2 sqlite locks: kanban_web :8095/8401 y kanban_cpp :8403 NUNCA comparten `operations.db`.
- `agent_runner_api` (puerto 8486) puede no estar corriendo — el panel `Agent runs` muestra `connection_status="disconnected"` en ese caso. No bloquea el resto de paneles.
- Calendar es MVP estatico — TODO: integrarlo con cards filtradas por `due_date`.
- Dashboard usa datos sinteticos hasta wire-up del backend stats endpoint (TODO).
- Auth: cada app tiene sus propios usuarios. NO compartir cookies entre kanban_web y kanban_cpp.
## Self-test
## Capability growth log
`./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.
(v0.1.0 baseline — sin crecimiento aun)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

-72
View File
@@ -1,72 +0,0 @@
---
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.
+1178 -28
View File
File diff suppressed because it is too large Load Diff
View File
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"encoding/json"
"net/http"
"os"
"fn-registry/functions/infra"
)
type FeatureFlag struct {
Enabled bool `json:"enabled"`
Issue string `json:"issue,omitempty"`
Description string `json:"description"`
Added string `json:"added,omitempty"`
EnabledAt string `json:"enabled_at,omitempty"`
}
type FeatureFlags struct {
Flags map[string]FeatureFlag `json:"flags"`
}
func (f FeatureFlags) Enabled(name string) bool {
flag, ok := f.Flags[name]
return ok && flag.Enabled
}
func loadFeatureFlags(path string) (FeatureFlags, error) {
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return FeatureFlags{Flags: map[string]FeatureFlag{}}, nil
}
return FeatureFlags{}, err
}
var f FeatureFlags
if err := json.Unmarshal(b, &f); err != nil {
return FeatureFlags{}, err
}
if f.Flags == nil {
f.Flags = map[string]FeatureFlag{}
}
return f, nil
}
// GET /api/flags → { "<name>": true/false, ... }
func handleListFlags(flags *FeatureFlags) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
out := make(map[string]bool, len(flags.Flags))
for name, fl := range flags.Flags {
out[name] = fl.Enabled
}
infra.HTTPJSONResponse(w, http.StatusOK, out)
}
}
+35
View File
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,60 @@
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
@@ -0,0 +1,163 @@
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
@@ -0,0 +1,104 @@
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())
}
}
}
+5 -4
View File
@@ -1,10 +1,11 @@
module kanban_cpp_backend
module kanban
go 1.25.0
require (
fn-registry v0.0.0-00010101000000-000000000000
github.com/mattn/go-sqlite3 v1.14.37
github.com/fsnotify/fsnotify v1.10.1
gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -13,7 +14,6 @@ require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/apache/arrow-go/v18 v18.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
@@ -27,6 +27,7 @@ require (
github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/marcboeker/go-duckdb v1.8.5 // indirect
github.com/mattn/go-sqlite3 v1.14.37 // indirect
github.com/paulmach/orb v0.12.0 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/segmentio/asm v1.2.1 // indirect
@@ -38,13 +39,13 @@ require (
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.43.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
nhooyr.io/websocket v1.8.17 // indirect
)
+4 -2
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
@@ -126,6 +126,8 @@ 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-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+320 -377
View File
@@ -1,417 +1,360 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"fn-registry/functions/infra"
)
const agentRunnerBase = "http://127.0.0.1:8486"
const maxBodyBytes = 1 << 20 // 1 MiB
func (s *Server) registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/health", s.handleHealth)
mux.HandleFunc("/api/issues", s.handleIssues)
mux.HandleFunc("/api/issues/", s.handleIssueByID)
mux.HandleFunc("/api/flows", s.handleFlows)
mux.HandleFunc("/api/flows/", s.handleFlowByID)
mux.HandleFunc("/api/meta", s.handleMeta)
mux.HandleFunc("/api/sse", s.handleSSE)
mux.HandleFunc("/api/agent_status", s.handleAgentStatus)
mux.HandleFunc("/api/agent_launch", s.handleAgentLaunch)
func badRequest(w http.ResponseWriter, msg string) {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusBadRequest, Code: "bad_request", Message: msg})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
func notFound(w http.ResponseWriter, msg string) {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusNotFound, Code: "not_found", Message: msg})
}
func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
func serverError(w http.ResponseWriter, err error) {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusInternalServerError, Code: "internal", Message: err.Error()})
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
var ni, nf int
s.db.QueryRow(`SELECT COUNT(*) FROM issues`).Scan(&ni)
s.db.QueryRow(`SELECT COUNT(*) FROM flows`).Scan(&nf)
writeJSON(w, 200, map[string]any{
"ok": true,
"version": "0.1.0",
"count_issues": ni,
"count_flows": nf,
"issues_dir": s.issuesDir,
"flows_dir": s.flowsDir,
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
func (s *Server) handleIssues(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeErr(w, 405, "method not allowed")
return
}
q := r.URL.Query()
where := []string{"1=1"}
args := []any{}
if v := q.Get("status"); v != "" {
where = append(where, "status=?")
args = append(args, v)
}
if v := q.Get("priority"); v != "" {
where = append(where, "priority=?")
args = append(args, v)
}
if v := q.Get("scope"); v != "" {
where = append(where, "scope=?")
args = append(args, v)
}
if v := q.Get("domain"); v != "" {
where = append(where, "domain_json LIKE ?")
args = append(args, "%\""+v+"\"%")
}
if v := q.Get("tag"); v != "" {
where = append(where, "tags_json LIKE ?")
args = append(args, "%\""+v+"\"%")
}
if v := q.Get("completed"); v != "" {
if v == "true" || v == "1" {
where = append(where, "completed=1")
} else {
where = append(where, "completed=0")
}
}
sql := "SELECT id,title,status,type,scope,priority,domain_json,tags_json,depends_json,blocks_json,related_json,flow_id,file_path,completed,created_at,updated_at FROM issues WHERE " + strings.Join(where, " AND ") + " ORDER BY id ASC"
rows, err := s.db.Query(sql, args...)
if err != nil {
writeErr(w, 500, err.Error())
return
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var (
id, title, status, typ, scope, priority string
domJ, tagJ, depJ, blkJ, relJ, flow, path string
completedInt int
createdAt, updatedAt string
)
if err := rows.Scan(&id, &title, &status, &typ, &scope, &priority, &domJ, &tagJ, &depJ, &blkJ, &relJ, &flow, &path, &completedInt, &createdAt, &updatedAt); err != nil {
writeErr(w, 500, err.Error())
// GET /api/board → { columns: [...], cards: [...] }
func handleGetBoard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cols, err := db.ListColumns()
if err != nil {
serverError(w, err)
return
}
out = append(out, map[string]any{
"id": id,
"title": title,
"status": status,
"type": typ,
"scope": scope,
"priority": priority,
"domain": parseJSONArr(domJ),
"tags": parseJSONArr(tagJ),
"depends": parseJSONArr(depJ),
"blocks": parseJSONArr(blkJ),
"related": parseJSONArr(relJ),
"flow": flow,
"file_path": path,
"completed": completedInt == 1,
"created": createdAt,
"updated": updatedAt,
cards, err := db.ListCardsWithTime()
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{
"columns": cols,
"cards": cards,
})
}
writeJSON(w, 200, out)
}
func (s *Server) handleIssueByID(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/issues/")
if id == "" {
writeErr(w, 400, "missing id")
return
}
switch r.Method {
case "GET":
s.getIssue(w, id)
case "PATCH":
s.patchIssue(w, r, id)
default:
writeErr(w, 405, "method not allowed")
// POST /api/columns { name }
func handleCreateColumn(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if strings.TrimSpace(body.Name) == "" {
badRequest(w, "name required")
return
}
c, err := db.CreateColumn(body.Name)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusCreated, c)
}
}
func (s *Server) getIssue(w http.ResponseWriter, id string) {
row := s.db.QueryRow(`SELECT id,title,status,type,scope,priority,domain_json,tags_json,depends_json,blocks_json,related_json,flow_id,body,file_path,completed,created_at,updated_at FROM issues WHERE id=?`, id)
var (
iid, title, status, typ, scope, priority string
domJ, tagJ, depJ, blkJ, relJ, flow, body, path string
completedInt int
createdAt, updatedAt string
)
if err := row.Scan(&iid, &title, &status, &typ, &scope, &priority, &domJ, &tagJ, &depJ, &blkJ, &relJ, &flow, &body, &path, &completedInt, &createdAt, &updatedAt); err != nil {
writeErr(w, 404, "not found")
return
// PATCH /api/columns/{id} { name?, position?, location?, width? }
func handleUpdateColumn(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Name *string `json:"name"`
Position *int `json:"position"`
Location *string `json:"location"`
Width *int `json:"width"`
WIPLimit *int `json:"wip_limit"`
IsDone *bool `json:"is_done"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if err := db.UpdateColumn(id, ColumnPatch{Name: body.Name, Position: body.Position, Location: body.Location, Width: body.Width, WIPLimit: body.WIPLimit, IsDone: body.IsDone}); err != nil {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
writeJSON(w, 200, map[string]any{
"id": iid, "title": title, "status": status, "type": typ, "scope": scope, "priority": priority,
"domain": parseJSONArr(domJ), "tags": parseJSONArr(tagJ),
"depends": parseJSONArr(depJ), "blocks": parseJSONArr(blkJ), "related": parseJSONArr(relJ),
"flow": flow, "body": body, "file_path": path, "completed": completedInt == 1,
"created": createdAt, "updated": updatedAt,
})
}
func (s *Server) patchIssue(w http.ResponseWriter, r *http.Request, id string) {
var patch map[string]any
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
writeErr(w, 400, "bad json")
return
// DELETE /api/columns/{id}
func handleDeleteColumn(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := db.DeleteColumn(id); err != nil {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
var filePath string
if err := s.db.QueryRow(`SELECT file_path FROM issues WHERE id=?`, id).Scan(&filePath); err != nil {
writeErr(w, 404, "not found")
return
}
iss, body, err := infra.ParseIssueMd(filePath)
if err != nil {
writeErr(w, 500, fmt.Sprintf("parse: %v", err))
return
}
applyPatch(&iss, patch)
iss.Updated = time.Now().UTC().Format("2006-01-02")
if err := infra.WriteIssueMd(filePath, iss, body); err != nil {
writeErr(w, 500, fmt.Sprintf("write: %v", err))
return
}
info, _ := os.Stat(filePath)
if info != nil {
iss.MtimeNs = info.ModTime().UnixNano()
}
iss.FilePath = filePath
iss.Completed = strings.Contains(filePath, "/completed/")
if err := s.upsertIssueRow(iss); err != nil {
writeErr(w, 500, err.Error())
return
}
s.hub.broadcast(SSEEvent{Type: "updated", ID: id, Path: filePath})
s.getIssue(w, id)
}
func applyPatch(iss *infra.Issue, patch map[string]any) {
if v, ok := patch["status"].(string); ok {
iss.Status = v
// POST /api/columns/reorder { ids: [...] }
func handleReorderColumns(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body struct {
IDs []string `json:"ids"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if err := db.ReorderColumns(body.IDs); err != nil {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
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
}
if v, ok := patch["title"].(string); ok {
iss.Title = v
}
if v, ok := patch["type"].(string); ok {
iss.Type = v
}
if v, ok := patch["flow"].(string); ok {
iss.Flow = v
}
for _, k := range []string{"domain", "tags", "depends", "blocks", "related"} {
if raw, ok := patch[k]; ok {
arr := []string{}
if xs, ok := raw.([]any); ok {
for _, x := range xs {
if s, ok := x.(string); ok {
arr = append(arr, s)
}
// PATCH /api/cards/{id} { requester?, title?, description?, color? }
func handleUpdateCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var raw map[string]any
if err := infra.HTTPParseBody(r, &raw, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
patch := CardPatch{}
if v, ok := raw["requester"].(string); ok {
patch.Requester = &v
}
if v, ok := raw["title"].(string); ok {
patch.Title = &v
}
if v, ok := raw["description"].(string); ok {
patch.Description = &v
}
if v, ok := raw["color"].(string); ok {
patch.Color = &v
}
if v, ok := raw["locked"].(bool); ok {
patch.Locked = &v
}
if v, present := raw["assignee_id"]; present {
patch.HasAssignee = true
if v == nil {
empty := ""
patch.AssigneeID = &empty
} else if s, ok := v.(string); ok {
patch.AssigneeID = &s
}
}
if v, present := raw["deadline"]; present {
patch.HasDeadline = true
if v == nil {
empty := ""
patch.Deadline = &empty
} else if s, ok := v.(string); ok {
patch.Deadline = &s
}
}
if v, present := raw["tags"]; present {
tags := []string{}
if arr, ok := v.([]any); ok {
for _, t := range arr {
if s, ok := t.(string); ok {
tags = append(tags, s)
}
}
}
switch k {
case "domain":
iss.Domain = arr
case "tags":
iss.Tags = arr
case "depends":
iss.Depends = arr
case "blocks":
iss.Blocks = arr
case "related":
iss.Related = arr
patch.Tags = &tags
}
if err := db.UpdateCardWithActor(id, patch, ""); err != nil {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// DELETE /api/cards/{id}
func handleDeleteCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
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
}
}
}
}
func (s *Server) handleFlows(w http.ResponseWriter, r *http.Request) {
rows, err := s.db.Query(`SELECT id,title,status,kind,tags_json,file_path FROM flows ORDER BY id ASC`)
if err != nil {
writeErr(w, 500, err.Error())
return
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var id, title, status, kind, tagJ, path string
rows.Scan(&id, &title, &status, &kind, &tagJ, &path)
out = append(out, map[string]any{
"id": id, "title": title, "status": status, "kind": kind,
"tags": parseJSONArr(tagJ), "file_path": path,
})
}
writeJSON(w, 200, out)
}
func (s *Server) handleFlowByID(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/flows/")
if id == "" {
writeErr(w, 400, "missing id")
return
}
row := s.db.QueryRow(`SELECT id,title,status,kind,tags_json,body,file_path FROM flows WHERE id=?`, id)
var iid, title, status, kind, tagJ, body, path string
if err := row.Scan(&iid, &title, &status, &kind, &tagJ, &body, &path); err != nil {
writeErr(w, 404, "not found")
return
}
writeJSON(w, 200, map[string]any{
"id": iid, "title": title, "status": status, "kind": kind,
"tags": parseJSONArr(tagJ), "body": body, "file_path": path,
})
}
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{
"statuses": []string{"ideas", "pendiente", "in-progress", "bloqueado", "completado", "deferred", "descartado"},
"board_columns": []string{"ideas", "pendiente", "in-progress", "completado"},
"priorities": []string{"critica", "alta", "media", "baja"},
"scopes": []string{"registry-only", "app-scoped", "multi-app", "cross-stack"},
"types": []string{"feature", "bugfix", "refactor", "docs", "chore", "research", "infra", "app", "spike", "epic", "planning"},
})
}
// GET /api/agent_status — proxies agent_runner_api running runs, returns map issue_id -> run_id
func (s *Server) handleAgentStatus(w http.ResponseWriter, r *http.Request) {
resp, err := http.Get(agentRunnerBase + "/api/runs?status=running")
if err != nil {
writeJSON(w, 200, map[string]any{"available": false, "active": map[string]string{}})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var runs []map[string]any
if err := json.Unmarshal(body, &runs); err != nil {
writeJSON(w, 200, map[string]any{"available": false, "active": map[string]string{}})
return
}
active := map[string]string{}
for _, run := range runs {
issueID, _ := run["issue_id"].(string)
runID, _ := run["id"].(string)
if issueID != "" && runID != "" {
active[issueID] = runID
}
}
writeJSON(w, 200, map[string]any{"available": true, "active": active})
}
// POST /api/agent_launch {"issue_id":"NNNN"} — forwards to agent_runner_api
func (s *Server) handleAgentLaunch(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeErr(w, 405, "method not allowed")
return
}
var req struct {
IssueID string `json:"issue_id"`
Mode string `json:"mode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, "bad json")
return
}
if req.IssueID == "" {
writeErr(w, 400, "issue_id required")
return
}
if req.Mode == "" {
req.Mode = "fix-issue"
}
payload, _ := json.Marshal(map[string]string{
"issue_id": req.IssueID,
"mode": req.Mode,
"kanban_app": "kanban_cpp",
})
resp, err := http.Post(agentRunnerBase+"/api/runs", "application/json", bytes.NewReader(payload))
if err != nil {
writeErr(w, 502, fmt.Sprintf("agent_runner_api unreachable: %v", err))
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
writeErr(w, resp.StatusCode, string(body))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
w.Write(body)
}
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
writeErr(w, 500, "streaming unsupported")
return
}
ch := s.hub.subscribe()
defer s.hub.unsubscribe(ch)
pingTick := time.NewTicker(15 * time.Second)
defer pingTick.Stop()
for {
select {
case <-r.Context().Done():
return
case ev := <-ch:
b, _ := json.Marshal(ev)
fmt.Fprintf(w, "data: %s\n\n", b)
flusher.Flush()
case <-pingTick.C:
fmt.Fprintf(w, ": ping\n\n")
flusher.Flush()
}
}
}
func parseJSONArr(s string) []string {
if s == "" {
return []string{}
}
var arr []string
if err := json.Unmarshal([]byte(s), &arr); err != nil {
return []string{}
}
return arr
}
func withMiddleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(204)
serverError(w, err)
return
}
start := time.Now()
h.ServeHTTP(w, r)
fmt.Printf("[%s] %s %s %s\n", time.Now().Format("15:04:05"), r.Method, r.URL.Path, time.Since(start))
})
w.WriteHeader(http.StatusNoContent)
}
}
// POST /api/cards/{id}/duplicate
func handleDuplicateCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
c, err := db.DuplicateCard(id, "")
if err != nil {
if strings.Contains(err.Error(), "not found") {
notFound(w, "card not found")
return
}
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusCreated, c)
}
}
// GET /api/trash
func handleListTrash(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cards, err := db.ListDeletedCards()
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, cards)
}
}
// POST /api/cards/{id}/restore
func handleRestoreCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := db.RestoreCardWithActor(id, ""); err != nil {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// DELETE /api/cards/{id}/purge
func handlePurgeCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := db.PurgeCard(id); err != nil {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func apiRoutes(db *DB, 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
@@ -0,0 +1,346 @@
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
@@ -1,137 +0,0 @@
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
@@ -0,0 +1,387 @@
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
@@ -0,0 +1,119 @@
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")
}
}
Binary file not shown.
+42 -74
View File
@@ -2,106 +2,74 @@ package main
import (
"context"
"database/sql"
"embed"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"fn-registry/functions/infra"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
type Server struct {
db *sql.DB
issuesDir string
flowsDir string
hub *SSEHub
}
const syncLayerVersion = "v0.1.0"
func main() {
port := flag.Int("port", 8487, "HTTP port")
dbPath := flag.String("db", "operations.db", "SQLite path")
registryRoot := flag.String("registry", "", "fn_registry root (default: auto-detect from cwd)")
flag.Parse()
flags := flag.NewFlagSet("kanban_cpp_backend", flag.ExitOnError)
port := flags.Int("port", 8403, "HTTP port")
dbPath := flags.String("db", "operations.db", "SQLite database path")
flagsPath := flags.String("flags", "dev/feature_flags.json", "Feature flags JSON path (missing file → all disabled)")
flags.Parse(os.Args[1:])
root := *registryRoot
if root == "" {
root = detectRegistryRoot()
featureFlags, err := loadFeatureFlags(*flagsPath)
if err != nil {
log.Fatalf("load feature flags: %v", err)
}
issuesDir := filepath.Join(root, "dev", "issues")
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)
}
for name, fl := range featureFlags.Flags {
log.Printf("feature flag %q enabled=%v", name, fl.Enabled)
}
db, err := openDB(*dbPath)
if err != nil {
log.Fatalf("openDB: %v", err)
log.Fatalf("open db: %v", err)
}
defer db.Close()
if err := applyMigrations(db); err != nil {
log.Fatalf("applyMigrations: %v", err)
}
srv := &Server{
db: db,
issuesDir: issuesDir,
flowsDir: flowsDir,
hub: newSSEHub(),
}
// SSE: hub + fsnotify watcher for dev/issues + dev/flows.
globalHub = NewHub()
startBoardsWatcher(globalHub)
if err := srv.ingestAll(); err != nil {
log.Fatalf("initial ingest: %v", err)
}
mux := infra.HTTPRouter(apiRoutes(db, &featureFlags))
mux.HandleFunc("/health", handleHealth(*port))
ctx, cancel := context.WithCancel(context.Background())
chain := infra.HTTPMiddlewareChain(
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()
go srv.watchLoop(ctx, srv.issuesDir, "issue")
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,
if err := infra.HTTPServe(addr, handler, ctx); err != nil {
log.Fatalf("server: %v", err)
}
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)
}
func detectRegistryRoot() string {
wd, _ := os.Getwd()
for d := wd; d != "/" && d != "."; d = filepath.Dir(d) {
if _, err := os.Stat(filepath.Join(d, "registry.db")); err == nil {
return d
}
// handleHealth returns 200 with a small JSON describing the service. No auth.
func handleHealth(port int) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"port": port,
"sync_layer": syncLayerVersion,
})
}
log.Fatalf("could not auto-detect fn_registry root from %s", wd)
return ""
}
+48 -31
View File
@@ -1,34 +1,51 @@
CREATE TABLE IF NOT EXISTS issues (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL,
type TEXT,
scope TEXT,
priority TEXT,
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 TABLE IF NOT EXISTS columns (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
location TEXT NOT NULL DEFAULT 'board' CHECK(location IN ('board','sidebar')),
width INTEGER NOT NULL DEFAULT 300,
wip_limit INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE 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 flows (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT,
kind TEXT,
tags_json TEXT NOT NULL DEFAULT '[]',
body TEXT NOT NULL DEFAULT '',
file_path TEXT NOT NULL,
mtime_ns INTEGER NOT NULL
CREATE TABLE IF NOT EXISTS cards (
id TEXT PRIMARY KEY,
requester TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '',
column_id TEXT NOT NULL REFERENCES columns(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
locked INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS card_column_history (
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
column_id TEXT NOT NULL,
entered_at TEXT NOT NULL,
exited_at TEXT
);
CREATE TABLE IF NOT EXISTS card_lock_history (
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
locked_at TEXT NOT NULL,
unlocked_at TEXT
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_cards_column ON cards(column_id);
CREATE INDEX IF NOT EXISTS idx_cards_position ON cards(column_id, position);
CREATE INDEX IF NOT EXISTS idx_history_card ON card_column_history(card_id);
CREATE INDEX IF NOT EXISTS idx_columns_position ON columns(position);
CREATE INDEX IF NOT EXISTS idx_lock_history_card ON card_lock_history(card_id);
+4
View File
@@ -0,0 +1,4 @@
-- Add stickers column to cards. Idempotent ALTER pattern in db.go ensureColumns.
-- Stickers persist as JSON array: [{"emoji":"🔥","x":0.5,"y":0.5}, ...]
-- x, y in [0, 1] relative to card dimensions for resize survival.
ALTER TABLE cards ADD COLUMN stickers TEXT NOT NULL DEFAULT '[]';
@@ -0,0 +1,6 @@
-- Columnas extra de `columns` (location, width, wip_limit, is_done).
-- Antes vivian en ensureColumns Go. Reextraidas a migration por consistencia.
ALTER TABLE columns ADD COLUMN location TEXT NOT NULL DEFAULT 'board';
ALTER TABLE columns ADD COLUMN width INTEGER NOT NULL DEFAULT 300;
ALTER TABLE columns ADD COLUMN wip_limit INTEGER NOT NULL DEFAULT 0;
ALTER TABLE columns ADD COLUMN is_done INTEGER NOT NULL DEFAULT 0;
+9
View File
@@ -0,0 +1,9 @@
-- Columnas extra de `cards` (color, locked, assignee_id, completed_at, deleted_at, tags).
-- Antes vivian en ensureColumns Go. La columna stickers va aparte en 002.
ALTER TABLE cards ADD COLUMN color TEXT NOT NULL DEFAULT '';
ALTER TABLE cards ADD COLUMN locked INTEGER NOT NULL DEFAULT 0;
ALTER TABLE cards ADD COLUMN assignee_id TEXT;
ALTER TABLE cards ADD COLUMN completed_at TEXT;
ALTER TABLE cards ADD COLUMN deleted_at TEXT;
ALTER TABLE cards ADD COLUMN tags TEXT NOT NULL DEFAULT '[]';
CREATE INDEX IF NOT EXISTS idx_cards_assignee ON cards(assignee_id);
+3
View File
@@ -0,0 +1,3 @@
-- actor_id en histories (quien movió la card / quien bloqueó).
ALTER TABLE card_column_history ADD COLUMN actor_id TEXT;
ALTER TABLE card_lock_history ADD COLUMN actor_id TEXT;
+2
View File
@@ -0,0 +1,2 @@
-- Color del avatar del usuario (Mantine color name o '#rrggbb' personalizado).
ALTER TABLE users ADD COLUMN color TEXT NOT NULL DEFAULT '';
+11
View File
@@ -0,0 +1,11 @@
-- Eventos cronologicos por card. Complementa column_history (moves) y lock_history (locks).
-- Captura: created, assigned, unassigned, title_changed, description_changed, color_changed, tags_changed.
CREATE TABLE IF NOT EXISTS card_events (
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
actor_id TEXT,
payload TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_card_events_card ON card_events(card_id, created_at);
+7
View File
@@ -0,0 +1,7 @@
-- ID secuencial humano por card. Distinto del id hex (PK interna).
-- Backfill por orden de creacion.
ALTER TABLE cards ADD COLUMN seq_num INTEGER NOT NULL DEFAULT 0;
UPDATE cards SET seq_num = (
SELECT COUNT(*) FROM cards c2 WHERE c2.created_at <= cards.created_at
) WHERE seq_num = 0;
CREATE UNIQUE INDEX IF NOT EXISTS idx_cards_seq_num ON cards(seq_num) WHERE seq_num > 0;
+4
View File
@@ -0,0 +1,4 @@
-- Deadline opcional por card. Fecha RFC3339 (precision dia o instante).
-- NULL = sin deadline (default). El frontend muestra countdown hasta la fecha.
ALTER TABLE cards ADD COLUMN deadline TEXT;
CREATE INDEX IF NOT EXISTS idx_cards_deadline ON cards(deadline) WHERE deadline IS NOT NULL;
+14
View File
@@ -0,0 +1,14 @@
-- Per-card chat messages (human-to-human comments).
-- Distinct from card_events (which records system events like title_changed)
-- and from /api/chat (which is the board-level LLM chat).
CREATE TABLE IF NOT EXISTS card_messages (
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL,
author_id TEXT,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_card_messages_card ON card_messages(card_id, created_at);
+45 -13
View File
@@ -4,43 +4,75 @@ import (
"sync"
)
type SSEEvent struct {
Type string `json:"type"`
ID string `json:"id"`
Path string `json:"path,omitempty"`
// ServerEvent is a board-scoped event broadcast to all SSE subscribers
// of a given board. It is emitted both by the fsnotify watcher (file
// changes on disk under dev/issues or dev/flows) and by handlers that
// mutate cards (PATCH /api/boards/{board}/cards/{id}) so the C++ client
// 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"
}
type SSEHub struct {
// Hub fans out ServerEvent messages to N concurrent subscribers. Each
// 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
clients map[chan SSEEvent]struct{}
clients map[chan ServerEvent]struct{}
}
func newSSEHub() *SSEHub {
return &SSEHub{clients: map[chan SSEEvent]struct{}{}}
// NewHub returns an empty Hub ready to use.
func NewHub() *Hub {
return &Hub{
clients: make(map[chan ServerEvent]struct{}),
}
}
func (h *SSEHub) subscribe() chan SSEEvent {
ch := make(chan SSEEvent, 16)
// Subscribe registers a new subscriber. The returned channel is buffered
// (16) so a brief stall on the consumer side doesn't block the producer.
func (h *Hub) Subscribe() chan ServerEvent {
ch := make(chan ServerEvent, 16)
h.mu.Lock()
h.clients[ch] = struct{}{}
h.mu.Unlock()
return ch
}
func (h *SSEHub) unsubscribe(ch chan SSEEvent) {
// Unsubscribe removes a subscriber and closes its channel. Idempotent.
func (h *Hub) Unsubscribe(ch chan ServerEvent) {
h.mu.Lock()
defer h.mu.Unlock()
if _, ok := h.clients[ch]; !ok {
return
}
delete(h.clients, ch)
h.mu.Unlock()
close(ch)
}
func (h *SSEHub) broadcast(ev SSEEvent) {
// Broadcast sends ev to every current subscriber. Non-blocking: if a
// subscriber's channel is full the event is dropped for that subscriber.
func (h *Hub) Broadcast(ev ServerEvent) {
h.mu.RLock()
defer h.mu.RUnlock()
for ch := range h.clients {
select {
case ch <- ev:
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
@@ -0,0 +1,73 @@
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
@@ -0,0 +1,191 @@
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
@@ -0,0 +1,126 @@
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
@@ -1,80 +0,0 @@
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
}
}
}
+148 -281
View File
@@ -1,316 +1,183 @@
// 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 <atomic>
#include <chrono>
#include <memory>
#include <thread>
#include "core/http_request.h"
#include "core/sse_client.h"
#include "core/logger.h"
#include <nlohmann/json.hpp>
namespace kanban {
#include <cstring>
#include <cstdio>
using json = nlohmann::json;
namespace kanban_cpp {
State& state() {
static State s;
return s;
}
namespace {
static std::unique_ptr<fn_sse::Client> g_sse;
static std::vector<std::string> j_arr(const json& j, const char* key) {
std::vector<std::string> out;
if (!j.contains(key) || !j[key].is_array()) return out;
for (const auto& v : j[key]) {
if (v.is_string()) out.push_back(v.get<std::string>());
// Tiny helpers: scan JSON strings out of a raw buffer. NOT a real parser —
// only handles flat-ish payloads our backend emits. Good enough for MVP.
std::string find_str_field(const std::string& s, const std::string& key) {
std::string needle = "\"" + key + "\":";
size_t p = s.find(needle);
if (p == std::string::npos) return "";
p += needle.size();
while (p < s.size() && (s[p] == ' ' || s[p] == '\t')) ++p;
if (p >= s.size() || s[p] != '"') return "";
++p;
std::string out;
while (p < s.size() && s[p] != '"') {
if (s[p] == '\\' && p + 1 < s.size()) {
char c = s[p + 1];
if (c == 'n') out += '\n';
else if (c == 't') out += '\t';
else out += c;
p += 2;
continue;
}
out += s[p++];
}
return out;
}
static std::string j_str(const json& j, const char* key) {
if (!j.contains(key) || !j[key].is_string()) return "";
return j[key].get<std::string>();
int64_t find_int_field(const std::string& s, const std::string& key) {
std::string needle = "\"" + key + "\":";
size_t p = s.find(needle);
if (p == std::string::npos) return 0;
p += needle.size();
while (p < s.size() && (s[p] == ' ' || s[p] == '\t')) ++p;
char* end = nullptr;
long long v = std::strtoll(s.c_str() + p, &end, 10);
return static_cast<int64_t>(v);
}
static Issue parse_issue(const json& j) {
Issue i;
i.id = j_str(j, "id");
i.title = j_str(j, "title");
i.status = j_str(j, "status");
i.type = j_str(j, "type");
i.scope = j_str(j, "scope");
i.priority = j_str(j, "priority");
i.domain = j_arr(j, "domain");
i.tags = j_arr(j, "tags");
i.depends = j_arr(j, "depends");
i.blocks = j_arr(j, "blocks");
i.related = j_arr(j, "related");
i.flow = j_str(j, "flow");
i.file_path = j_str(j, "file_path");
if (j.contains("completed") && j["completed"].is_boolean())
i.completed = j["completed"].get<bool>();
i.body = j_str(j, "body");
return i;
// Split JSON array of objects at depth 1. Returns each object as a substring.
std::vector<std::string> split_objects(const std::string& s) {
std::vector<std::string> out;
int depth = 0;
size_t start = 0;
bool in_obj = false;
for (size_t i = 0; i < s.size(); ++i) {
char c = s[i];
if (c == '{') {
if (depth == 0) { start = i; in_obj = true; }
++depth;
} else if (c == '}') {
--depth;
if (depth == 0 && in_obj) {
out.push_back(s.substr(start, i - start + 1));
in_obj = false;
}
}
}
return out;
}
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::Response do_get(const std::string& url, int timeout_ms) {
fn_http::Request req;
req.method = "GET";
req.url = state().backend_url + path;
req.timeout_ms = 5000;
req.method = "GET";
req.url = url;
req.timeout_ms = timeout_ms;
return fn_http::request(req);
}
static fn_http::Response http_patch_json(const std::string& path, const std::string& body) {
fn_http::Response do_post_json(const std::string& url, const std::string& body, int timeout_ms) {
fn_http::Request req;
req.method = "PATCH";
req.url = state().backend_url + path;
req.timeout_ms = 5000;
req.method = "POST";
req.url = url;
req.timeout_ms = timeout_ms;
req.body = body;
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);
} // 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;
}
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;
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);
}
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 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;
}
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;
std::vector<AgentRunSummary> list_runs(const ClientConfig& cfg, std::string& err) {
auto r = do_get(cfg.agent_runner_url + "/api/runs", cfg.timeout_ms);
if (r.status == 0) { err = "transport: " + r.error; return {}; }
if (r.status >= 400) { err = "http " + std::to_string(r.status); return {}; }
std::vector<AgentRunSummary> out;
for (const auto& obj : split_objects(r.body)) {
AgentRunSummary s;
s.id = find_str_field(obj, "id");
s.card_id = find_str_field(obj, "card_id");
s.branch = find_str_field(obj, "branch");
s.status = find_str_field(obj, "status");
s.started_at = find_int_field(obj, "started_at");
s.finished_at = find_int_field(obj, "finished_at");
if (!s.id.empty()) out.push_back(s);
}
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 out;
}
bool launch_workflow(const ClientConfig& cfg, const std::string& card_id,
std::string& out_run_id, std::string& err) {
std::string body = "{\"card_id\":\"" + card_id + "\"}";
auto r = do_post_json(cfg.agent_runner_url + "/api/runs", body, cfg.timeout_ms);
if (r.status == 0) { err = "transport: " + r.error; return false; }
if (r.status >= 400) { err = "http " + std::to_string(r.status); return false; }
out_run_id = find_str_field(r.body, "id");
return true;
}
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 true;
}
bool refresh_agent_status() {
auto resp = http_get("/api/agent_status");
if (resp.status != 200) {
std::lock_guard<std::mutex> g(state().mu);
state().agent_runner_up = false;
state().agent_active.clear();
return false;
}
auto j = json::parse(resp.body, nullptr, false);
if (j.is_discarded()) return false;
std::map<std::string, std::string> active;
if (j.contains("active") && j["active"].is_object()) {
for (auto it = j["active"].begin(); it != j["active"].end(); ++it) {
if (it.value().is_string()) active[it.key()] = it.value().get<std::string>();
}
}
bool up = j.value("available", false);
std::lock_guard<std::mutex> g(state().mu);
state().agent_runner_up = up;
state().agent_active = std::move(active);
return true;
}
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
} // namespace kanban_cpp
+42 -76
View File
@@ -1,95 +1,61 @@
// data.h — HTTP client wrapper for kanban_cpp backend at :8403.
//
// Wraps fn_http::request() (cpp/functions/core/http_request.h) with
// kanban-specific shapes (Card, Column, AgentRunSummary).
#pragma once
#include <map>
#include <mutex>
#include <set>
#include <string>
#include <vector>
#include <cstdint>
namespace kanban {
namespace kanban_cpp {
struct Issue {
struct Card {
std::string id;
std::string title;
std::string status;
std::string type;
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
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 Flow {
struct Column {
std::string id;
std::string title;
std::string name;
int order = 0;
};
struct AgentRunSummary {
std::string id;
std::string card_id;
std::string branch;
std::string status;
std::string kind;
std::vector<std::string> tags;
std::string file_path;
std::string body;
int64_t started_at = 0;
int64_t finished_at = 0;
};
struct Meta {
std::vector<std::string> statuses;
std::vector<std::string> priorities;
std::vector<std::string> scopes;
std::vector<std::string> types;
struct ClientConfig {
std::string base_url = "http://127.0.0.1:8403";
std::string agent_runner_url = "http://127.0.0.1:8486";
int timeout_ms = 3000;
};
struct Filters {
std::set<std::string> domains;
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;
};
// HTTP GETs ---------------------------------------------------------------
std::vector<Card> list_cards(const ClientConfig& cfg, std::string& err);
std::vector<Column> list_columns(const ClientConfig& cfg, std::string& err);
bool health(const ClientConfig& cfg); // GET /health
struct State {
std::mutex mu;
std::string backend_url = "http://127.0.0.1:8487";
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;
};
// HTTP mutations ----------------------------------------------------------
bool move_card(const ClientConfig& cfg, const std::string& card_id,
const std::string& new_column_id, std::string& err);
State& state();
// agent_runner_api -------------------------------------------------------
std::vector<AgentRunSummary> list_runs(const ClientConfig& cfg, std::string& err);
bool launch_workflow(const ClientConfig& cfg, const std::string& card_id,
std::string& out_run_id, std::string& err);
// 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
} // namespace kanban_cpp
+77 -82
View File
@@ -1,114 +1,109 @@
#include <imgui.h>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <string>
#include <thread>
// main.cpp — kanban_cpp entry point.
//
// Six panels declared via cfg.panels. fn::run_app paints the menubar /
// dockspace / about / layouts automatically.
#include "app_base.h"
#include "core/panel_menu.h"
#include "core/icons_tabler.h"
#include "core/logger.h"
#include "core/sse_client.h"
#include "panels.h"
#include "data.h"
static bool g_show_board = true;
static bool g_show_flows = true;
static bool g_show_filters = true;
static bool g_show_detail = true;
#include <imgui.h>
#include <chrono>
#include <cstring>
#include <cstdio>
#include <mutex>
#include <string>
#include <thread>
static std::atomic<bool> g_refresh_thread_alive{false};
static std::thread g_refresh_thread;
static bool g_show_board = true;
static bool g_show_calendar = true;
static bool g_show_dashboard = true;
static bool g_show_runs = true;
static bool g_show_worktrees = true;
static bool g_show_dod = true;
static void start_refresh_thread() {
g_refresh_thread_alive = true;
g_refresh_thread = std::thread([]() {
kanban::refresh_meta();
kanban::refresh_issues();
kanban::refresh_flows();
kanban::refresh_agent_status();
kanban::start_sse();
int tick = 0;
while (g_refresh_thread_alive) {
// Fast loop (every 3s) for agent status; full refresh every 30s.
std::this_thread::sleep_for(std::chrono::seconds(3));
if (!g_refresh_thread_alive) break;
kanban::refresh_agent_status();
if (++tick >= 10) {
tick = 0;
kanban::refresh_issues();
kanban::refresh_flows();
}
}
kanban::stop_sse();
});
}
static kanban_cpp::AppState g_state;
static void stop_refresh_thread() {
g_refresh_thread_alive = false;
if (g_refresh_thread.joinable()) g_refresh_thread.join();
}
// SSE client: receives push notifications from the backend stream so the
// board updates without polling. Lifetime tied to main() — stop() before
// returning so the worker thread joins cleanly.
static fn_sse::Client g_sse_client;
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();
if (g_show_board) kanban_cpp::draw_board (g_state, &g_show_board);
if (g_show_calendar) kanban_cpp::draw_calendar (g_state, &g_show_calendar);
if (g_show_dashboard) kanban_cpp::draw_dashboard (g_state, &g_show_dashboard);
if (g_show_runs) kanban_cpp::draw_agent_runs(g_state, &g_show_runs);
if (g_show_worktrees) kanban_cpp::draw_worktrees (g_state, &g_show_worktrees);
if (g_show_dod) kanban_cpp::draw_dod (g_state, &g_show_dod);
}
// Headless self-test: verifies the binary links, panels include compile,
// and the data layer accepts a config. Used by app.md e2e_checks.
static int run_self_test() {
kanban::State& s = kanban::state();
s.backend_url = "http://127.0.0.1:1";
bool ok_issues = kanban::refresh_issues();
bool ok_flows = kanban::refresh_flows();
if (ok_issues || ok_flows) {
std::fprintf(stderr, "[self-test] expected refresh to fail on unreachable backend\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");
std::printf("kanban_cpp --self-test\n");
kanban_cpp::AppState s;
s.cfg.base_url = "http://127.0.0.1:65535"; // unreachable on purpose
bool ok = kanban_cpp::health(s.cfg);
std::printf(" health(unreachable) = %s (expected: false)\n", ok ? "true" : "false");
if (ok) return 1;
std::printf("OK\n");
return 0;
}
int main(int argc, char** argv) {
for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], "--self-test") == 0) {
return run_self_test();
}
if (std::strcmp(argv[i], "--backend") == 0 && i + 1 < argc) {
kanban::state().backend_url = argv[++i];
}
if (std::strcmp(argv[i], "--self-test") == 0) return run_self_test();
}
static fn_ui::PanelToggle panels[] = {
{ "Board", nullptr, &g_show_board },
{ "Flows", nullptr, &g_show_flows },
{ "Filters", nullptr, &g_show_filters },
{ "Detail", nullptr, &g_show_detail },
{ "Board", nullptr, &g_show_board },
{ "Calendar", nullptr, &g_show_calendar },
{ "Dashboard", nullptr, &g_show_dashboard },
{ "Agent runs", nullptr, &g_show_runs },
{ "Worktrees", nullptr, &g_show_worktrees },
{ "DoD inspector", nullptr, &g_show_dod },
};
fn::AppConfig cfg;
cfg.title = "kanban_cpp v2 — dev/issues + dev/flows";
cfg.about = { "kanban_cpp", "0.2.0", "Kanban C++ v2 — gestor de dev/issues y dev/flows del registry" };
cfg.title = "kanban_cpp — agentes LLM con DoD";
cfg.about = { "kanban_cpp", "0.1.0",
"Clon C++ ImGui de kanban_web — agentes LLM con DoD evidence" };
cfg.log = { "kanban_cpp.log", 1 };
cfg.panels = panels;
cfg.panel_count = sizeof(panels) / sizeof(panels[0]);
start_refresh_thread();
// First refresh on startup en thread separado: no bloquea primer frame
// 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);
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;
}
BIN
View File
Binary file not shown.
+72
View File
@@ -0,0 +1,72 @@
// panel_agent_runs.cpp — wraps the registry fn_viz::render_agent_runs_timeline.
//
// HTTP polling against agent_runner_api:8486. If the API is offline the
// panel shows `disconnected` and the table stays empty — never blocks the
// UI thread (calls happen lazily via Refresh button).
#include "panels.h"
#include "core/icons_tabler.h"
#include "viz/agent_runs_timeline.h"
#include <imgui.h>
#include <mutex>
namespace kanban_cpp {
namespace {
fn_viz::TimelineState& state_singleton() {
static fn_viz::TimelineState s;
static bool inited = false;
if (!inited) {
s.sse_url = "http://127.0.0.1:8486/api/runs/stream";
s.connection_status = "disconnected";
inited = true;
}
return s;
}
void poll_runs(AppState& app) {
auto& ts = state_singleton();
std::string err;
auto runs = list_runs(app.cfg, err);
if (!err.empty()) {
std::lock_guard<std::mutex> lk(ts.runs_mutex);
ts.connection_status = "disconnected";
return;
}
std::lock_guard<std::mutex> lk(ts.runs_mutex);
ts.runs.clear();
for (const auto& r : runs) {
fn_viz::AgentRun ar;
ar.id = r.id;
ar.app = "kanban_cpp";
ar.card_id = r.card_id;
ar.branch = r.branch;
ar.status = r.status;
ar.started_at = r.started_at;
ar.finished_at = r.finished_at;
ts.runs.push_back(ar);
}
ts.connection_status = "connected";
}
} // namespace
void draw_agent_runs(AppState& app, bool* p_open) {
if (!ImGui::Begin(TI_ROBOT " Agent runs", p_open)) {
ImGui::End();
return;
}
auto& ts = state_singleton();
if (ImGui::Button(TI_REFRESH " Poll agent_runner_api")) poll_runs(app);
ImGui::SameLine();
ImGui::TextDisabled("%s", ts.connection_status.c_str());
ImGui::Separator();
fn_viz::render_agent_runs_timeline(ts);
ImGui::End();
}
} // namespace kanban_cpp
+132 -293
View File
@@ -1,320 +1,159 @@
// panel_board.cpp — columns + cards Kanban panel.
#include "panels.h"
#include "data.h"
#include <imgui.h>
#include <imgui_internal.h>
#include <map>
#include <mutex>
#include <string>
#include <utility>
#include <vector>
#include "core/icons_tabler.h"
namespace kanban {
#include <imgui.h>
#include <ctime>
#include <thread>
#include <mutex>
static const char* k_columns[] = {"ideas", "pendiente", "in-progress", "completado"};
static const char* k_column_icons[] = {TI_BULB, TI_CLIPBOARD, TI_TOOLS, TI_CIRCLE_CHECK};
static const int k_n_columns = sizeof(k_columns) / sizeof(k_columns[0]);
namespace kanban_cpp {
// Map flow status (free-form) → one of the 4 board columns.
static int flow_column_index(const std::string& s) {
if (s == "draft" || s == "ideas") return 0;
if (s == "active" || s == "in-progress") return 2;
if (s == "done" || s == "completed" || s == "completado") return 3;
// pending, paused, "" → pendiente
return 1;
void refresh_data(AppState& s) {
std::string err;
auto cards = list_cards(s.cfg, err);
std::string err_cards = err; err.clear();
auto columns = list_columns(s.cfg, err);
std::string err_cols = err;
bool ok = health(s.cfg);
int64_t ts = std::time(nullptr);
std::lock_guard<std::mutex> lock(s.mu);
s.cards = std::move(cards);
s.columns = std::move(columns);
s.last_refresh_error.clear();
if (!err_cards.empty()) s.last_refresh_error = "cards: " + err_cards;
if (!err_cols.empty()) s.last_refresh_error += " columns: " + err_cols;
s.backend_ok = ok;
s.last_refresh_ts = ts;
}
static ImU32 priority_color(const std::string& p) {
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")) {
void draw_board(AppState& s, bool* p_open) {
if (!ImGui::Begin(TI_LAYOUT_KANBAN " Board", p_open)) {
ImGui::End();
return;
}
std::vector<Issue> issue_snap;
std::vector<Flow> flow_snap;
bool agent_up = false;
int agent_running_count = 0;
std::string last_launch;
bool show_issues_flag = true;
bool show_flows_flag = true;
// Snapshot bajo lock — refresh corre en thread separado.
std::vector<Card> cards_snap;
std::vector<Column> cols_snap;
bool backend_ok_snap;
std::string err_snap;
std::string sse_snap;
{
std::lock_guard<std::mutex> g(state().mu);
issue_snap = state().issues;
flow_snap = state().flows;
agent_up = state().agent_runner_up;
agent_running_count = (int)state().agent_active.size();
last_launch = state().last_launch_msg;
show_issues_flag = state().filters.show_issues;
show_flows_flag = state().filters.show_flows;
std::lock_guard<std::mutex> lock(s.mu);
cards_snap = s.cards;
cols_snap = s.columns;
backend_ok_snap = s.backend_ok;
err_snap = s.last_refresh_error;
sse_snap = s.sse_status;
}
// Bucket issues per column.
std::vector<std::vector<const Issue*>> issue_buckets(k_n_columns);
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;
}
}
}
// Toolbar — refresh corre en thread separado (no bloquea frame).
if (ImGui::Button(TI_REFRESH " Refresh")) {
std::thread([&s](){ refresh_data(s); }).detach();
}
// 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::TextDisabled(" | ");
ImGui::SameLine();
if (agent_up) {
ImGui::TextColored(ImColor(120, 220, 120, 255), TI_ROBOT " agent_runner_api OK — %d active", agent_running_count);
if (backend_ok_snap) {
ImGui::TextColored(ImVec4(0.4f, 0.85f, 0.4f, 1.0f), TI_CHECK " backend :8403");
} else {
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_ALERT_TRIANGLE " backend offline (:8403)");
}
if (!last_launch.empty()) {
// SSE live badge — refleja el estado del stream push del backend.
ImGui::SameLine();
if (sse_snap == "connected") {
ImGui::TextColored(ImVec4(0.4f, 0.85f, 0.4f, 1.0f), TI_BROADCAST " live");
} 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 {
// "error: <msg>" o cualquier otro string
ImGui::TextColored(ImVec4(0.85f, 0.4f, 0.4f, 1.0f), TI_PLUG_CONNECTED_X " %s", sse_snap.c_str());
}
if (!err_snap.empty()) {
ImGui::SameLine();
ImGui::TextDisabled(" | %s", last_launch.c_str());
ImGui::TextColored(ImVec4(0.85f, 0.6f, 0.2f, 1.0f), "%s", err_snap.c_str());
}
ImGui::Separator();
if (ImGui::BeginTable("board_table", k_n_columns,
ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_Resizable | ImGuiTableFlags_SizingStretchSame)) {
ImGui::TableNextRow();
for (int c = 0; c < k_n_columns; ++c) {
draw_column(k_columns[c], k_column_icons[c], issue_buckets[c], flow_buckets[c]);
}
ImGui::EndTable();
// Empty state
if (cols_snap.empty()) {
ImGui::TextDisabled("No columns yet. Pulsa Refresh o lanza el backend en :8403.");
ImGui::End();
return;
}
// Render columns left-to-right
const float col_w = 280.0f;
if (ImGui::BeginChild("##board_scroll", ImVec2(0, 0), false,
ImGuiWindowFlags_HorizontalScrollbar)) {
for (size_t ci = 0; ci < 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::EndChild();
ImGui::End();
}
} // namespace kanban
} // namespace kanban_cpp
+104
View File
@@ -0,0 +1,104 @@
// 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
@@ -0,0 +1,79 @@
// 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
@@ -1,229 +0,0 @@
#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
@@ -0,0 +1,43 @@
// panel_dod.cpp — DoD evidence inspector wrapping the registry panel.
//
// MVP: uses a synthetic in-memory DodPanelState so the panel renders without
// the agent_runner_api wired up. When that API exposes /api/dod_items +
// /api/dod_evidences endpoints, this will fetch them like panel_agent_runs.
#include "panels.h"
#include "core/icons_tabler.h"
#include "viz/dod_evidence_panel.h"
#include <imgui.h>
namespace kanban_cpp {
namespace {
fn_viz::DodPanelState& state_singleton() {
static fn_viz::DodPanelState st;
static bool inited = false;
if (!inited) {
st.run_id = "(none)";
// Empty until wired to backend. Helpers count zeros gracefully.
inited = true;
}
return st;
}
} // namespace
void draw_dod(AppState& /*s*/, bool* p_open) {
if (!ImGui::Begin(TI_LIST_CHECK " DoD inspector", p_open)) {
ImGui::End();
return;
}
auto& st = state_singleton();
ImGui::TextDisabled("run_id: %s (TODO: wire to agent_runner_api)", st.run_id.c_str());
ImGui::Separator();
fn_viz::render_dod_evidence_panel(st);
ImGui::End();
}
} // namespace kanban_cpp
-78
View File
@@ -1,78 +0,0 @@
#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
@@ -1,69 +0,0 @@
#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
@@ -0,0 +1,91 @@
// panel_worktrees.cpp — lists git worktrees via `git worktree list --porcelain`.
//
// Read-only MVP: shows path, head, branch. Future work: create/remove from
// inside the panel (TODO in app.md ## Gotchas).
#include "panels.h"
#include "core/icons_tabler.h"
#include <imgui.h>
#include <cstdio>
#include <string>
#include <vector>
#include <array>
namespace kanban_cpp {
namespace {
struct WT {
std::string path;
std::string head;
std::string branch;
};
std::vector<WT> scan_worktrees() {
std::vector<WT> out;
#ifdef _WIN32
FILE* fp = _popen("git worktree list --porcelain 2>nul", "r");
#else
FILE* fp = popen("git worktree list --porcelain 2>/dev/null", "r");
#endif
if (!fp) return out;
std::array<char, 1024> buf;
WT cur;
while (std::fgets(buf.data(), static_cast<int>(buf.size()), fp)) {
std::string line(buf.data());
while (!line.empty() && (line.back() == '\n' || line.back() == '\r')) line.pop_back();
if (line.empty()) {
if (!cur.path.empty()) out.push_back(cur);
cur = WT();
continue;
}
if (line.rfind("worktree ", 0) == 0) cur.path = line.substr(9);
else if (line.rfind("HEAD ", 0) == 0) cur.head = line.substr(5);
else if (line.rfind("branch ", 0) == 0) cur.branch = line.substr(7);
}
if (!cur.path.empty()) out.push_back(cur);
#ifdef _WIN32
_pclose(fp);
#else
pclose(fp);
#endif
return out;
}
} // namespace
void draw_worktrees(AppState& /*s*/, bool* p_open) {
if (!ImGui::Begin(TI_GIT_BRANCH " Worktrees", p_open)) {
ImGui::End();
return;
}
static std::vector<WT> wts;
static bool first = true;
if (first) { wts = scan_worktrees(); first = false; }
if (ImGui::Button(TI_REFRESH " Rescan")) wts = scan_worktrees();
ImGui::SameLine();
ImGui::TextDisabled("%zu worktrees", wts.size());
ImGui::Separator();
if (ImGui::BeginTable("##wts", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) {
ImGui::TableSetupColumn("Branch");
ImGui::TableSetupColumn("HEAD");
ImGui::TableSetupColumn("Path");
ImGui::TableHeadersRow();
for (const auto& w : wts) {
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImGui::TextUnformatted(w.branch.empty() ? "(detached)" : w.branch.c_str());
ImGui::TableSetColumnIndex(1);
ImGui::TextUnformatted(w.head.substr(0, 10).c_str());
ImGui::TableSetColumnIndex(2);
ImGui::TextUnformatted(w.path.c_str());
}
ImGui::EndTable();
}
ImGui::End();
}
} // namespace kanban_cpp
+35 -6
View File
@@ -1,10 +1,39 @@
// 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
namespace kanban {
#include "data.h"
#include <mutex>
#include <string>
void draw_board();
void draw_flows();
void draw_filters();
void draw_detail();
namespace kanban_cpp {
} // namespace kanban
// Shared app state passed to every panel. Owned by main.cpp.
// `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