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