feat(0130): kanban_cpp v2 — backend Go + 5 registry parser fns + epic/sub-issues

Registry (issue 0130a):
- 5 fns infra: parse_issue_md, write_issue_md, scan_issues_dir,
  scan_flows_dir, watch_dir_fsnotify
- 3 tipos: Issue, Flow, FsEvent
- Tests round-trip + scan reales + watcher fsnotify (all PASS)
- Capability group 'kanban' nuevo (docs/capabilities/kanban.md)

Apps:
- apps/kanban_cpp/ (sub-repo) — frontend ImGui: board drag-drop,
  flows, filters, detail con CSV editors
- apps/kanban_cpp/backend/ — Go service port 8487: REST + SSE +
  fsnotify watcher, parser bidireccional MD<->SQLite cache

Issues:
- dev/issues/0130-kanban-cpp-v2.md (epic)
- 0130a parser, 0130b backend, 0130c frontend

CMakeLists.txt: add_subdirectory apps/kanban_cpp (registrado por
init_cpp_app scaffolder).

End-to-end verde: backend devuelve 189 issues + 9 flows; PATCH a
/api/issues/{id} reescribe .md (solo frontmatter, body intacto);
frontend --self-test exit 0; tests Go infra 5/5 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Egutierrez
2026-05-22 22:20:15 +02:00
parent e387c91b4c
commit c468b24d2b
31 changed files with 1706 additions and 9 deletions
+6
View File
@@ -535,3 +535,9 @@ set(_AGENTS_DASHBOARD_DIR ${CMAKE_SOURCE_DIR}/../projects/element_agents/apps/ag
if(EXISTS ${_AGENTS_DASHBOARD_DIR}/CMakeLists.txt)
add_subdirectory(${_AGENTS_DASHBOARD_DIR} ${CMAKE_BINARY_DIR}/apps/agents_dashboard)
endif()
# --- kanban_cpp (lives in apps/, issue 0096) ---
set(_KANBAN_CPP_DIR ${CMAKE_SOURCE_DIR}/../apps/kanban_cpp)
if(EXISTS ${_KANBAN_CPP_DIR}/CMakeLists.txt)
add_subdirectory(${_KANBAN_CPP_DIR} ${CMAKE_BINARY_DIR}/apps/kanban_cpp)
endif()
+15 -9
View File
@@ -1,22 +1,28 @@
---
id: "0130"
title: "Kanban C++ v2 — gestor de dev/issues y dev/flows con backend Go + frontend ImGui"
title: Kanban C++ v2 — gestor de dev/issues y dev/flows con backend Go + frontend ImGui
status: pendiente
type: epic
domain:
- cpp-stack
- apps-infra
- dev-ux
- cpp-stack
- apps-infra
- dev-ux
scope: multi-app
priority: alta
depends: []
blocks: []
related:
- "0112"
- "0119"
created: 2026-05-22
updated: 2026-05-22
tags: [kanban, cpp, imgui, dev_ux, issues, flows]
- "0112"
- "0119"
tags:
- kanban
- cpp
- imgui
- dev_ux
- issues
- flows
created: "2026-05-22"
updated: "2026-05-22"
---
# 0130 — Kanban C++ v2
+75
View File
@@ -0,0 +1,75 @@
---
id: 0130a
title: 'Funciones registry: parser MD + scan dirs + writer + watcher'
status: pendiente
type: infra
domain:
- registry-quality
- dev-ux
scope: registry-only
priority: alta
depends: []
blocks:
- 0130b
related:
- "0130"
tags:
- registry
- go
- parser
- frontmatter
- fsnotify
flow: "0130"
created: "2026-05-22"
updated: "2026-05-22"
---
# 0130a — Funciones registry para kanban_cpp v2
**Status:** pendiente
## Por que
El backend de kanban_cpp v2 necesita parsear/escribir frontmatter YAML de los `.md` de `dev/issues/` y `dev/flows/`. Estas piezas son reusables (cualquier app del registry puede operar sobre issues/flows), asi que viven en el registry, no en el backend de la app.
## Funciones a crear (delegar a fn-constructor en paralelo)
| ID | Firma | Pureza |
|---|---|---|
| `parse_issue_md_go_infra` | `(path string) (Issue, []byte body, error)` | impure (FS) |
| `write_issue_md_go_infra` | `(path string, issue Issue, body []byte) error` | impure (FS) |
| `scan_issues_dir_go_infra` | `(root string) ([]Issue, error)` | impure (FS) |
| `scan_flows_dir_go_infra` | `(root string) ([]Flow, error)` | impure (FS) |
| `watch_dir_fsnotify_go_infra` | `(ctx, root) (<-chan FsEvent, error)` | impure (FS, async) |
Tipos:
- `Issue_go_infra` — struct con campos del frontmatter (id, title, status, type, domain, scope, priority, depends, blocks, related, flow, tags, created, updated, file_path, mtime_ns).
- `Flow_go_infra` — struct equivalente para flows.
- `FsEvent_go_infra``{path, op}` con `op in {create, write, remove, rename}`.
## Notas de implementacion
- Usar `gopkg.in/yaml.v3` para parsing (preserva orden de keys via `yaml.Node`).
- Writer DEBE preservar:
- Orden de campos del frontmatter original.
- Body MD intacto (todo lo que va despues del segundo `---`).
- Comentarios YAML (libre, best-effort).
- `parse_issue_md` debe ser tolerante: si falta un campo opcional, default empty.
- `watch_dir_fsnotify` recursivo, debounce 200ms.
## DoD
- 5 pares `.go` + `.md` en `functions/infra/`.
- Tests unitarios:
- parse → write → parse round-trip preserva struct.
- scan_issues_dir devuelve >=90 issues actuales.
- watcher detecta creacion + modificacion + borrado.
- `fn index` registra los 5 IDs + 3 tipos.
- `fn doctor uses-functions` limpio.
## Anti-scope
NO incluye en esta tanda:
- Markdown rendering del body (eso lo hace el frontend si quiere).
- Validacion contra TAXONOMY (existe `fn doctor issues`).
- CRUD de issues nuevos (write_issue cubre el caso, pero crear file = scope del backend).
+114
View File
@@ -0,0 +1,114 @@
---
id: "0130b"
title: "Backend Go kanban_cpp v2: schema + handlers + watcher + SSE"
status: pendiente
type: app
domain:
- apps-infra
- dev-ux
scope: app-scoped
priority: alta
depends:
- "0130a"
blocks:
- "0130c"
related:
- "0130"
created: 2026-05-22
updated: 2026-05-22
tags: [service, kanban, go, sqlite, sse]
flow: "0130"
---
# 0130b — Backend Go kanban_cpp v2
**Status:** pendiente
## Por que
Servicio HTTP local que sirve los issues + flows del proyecto al frontend C++. Es un wrapper fino sobre las funciones del registry de 0130a + SQLite cache + watcher.
## Estructura
```
apps/kanban_cpp/backend/
app.md # tag service
go.mod
main.go # entry: flags + run
db.go # open + apply migrations + upsert helpers
handlers.go # endpoints REST
sse_hub.go # broadcaster
watcher.go # bind a watch_dir_fsnotify + re-ingesta + emit SSE
ingest.go # scan → upsert; usa 0130a
migrations/
001_init.sql
operations.db # creada en runtime
```
## Endpoints
| Verbo | Path | Notas |
|---|---|---|
| GET | `/api/health` | `{ok:true, version, count_issues, count_flows}` |
| GET | `/api/issues` | filtros: `status`, `domain`, `priority`, `tag`, `scope` |
| GET | `/api/issues/{id}` | issue + body |
| PATCH | `/api/issues/{id}` | partial update frontmatter → `write_issue_md` + re-ingesta + SSE |
| GET | `/api/flows` | filtros: `status`, `kind` |
| GET | `/api/flows/{id}` | flow + body |
| GET | `/api/meta` | enums leidos de `dev/TAXONOMY.md` |
| GET | `/api/sse` | stream `{type, id, path}` |
CORS abierto local (`*`). Logger middleware.
## Schema (migrations/001_init.sql)
```sql
CREATE TABLE IF NOT EXISTS issues (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL,
type TEXT,
scope TEXT,
priority TEXT,
domain_json TEXT NOT NULL DEFAULT '[]',
tags_json TEXT NOT NULL DEFAULT '[]',
depends_json TEXT NOT NULL DEFAULT '[]',
blocks_json TEXT NOT NULL DEFAULT '[]',
related_json TEXT NOT NULL DEFAULT '[]',
flow_id TEXT,
body TEXT NOT NULL DEFAULT '',
file_path TEXT NOT NULL,
mtime_ns INTEGER NOT NULL,
created_at TEXT,
updated_at TEXT,
completed INTEGER NOT NULL DEFAULT 0 -- 1 si vive en completed/
);
CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status);
CREATE INDEX IF NOT EXISTS idx_issues_priority ON issues(priority);
CREATE TABLE IF NOT EXISTS flows (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT,
kind TEXT,
tags_json TEXT NOT NULL DEFAULT '[]',
body TEXT NOT NULL DEFAULT '',
file_path TEXT NOT NULL,
mtime_ns INTEGER NOT NULL
);
```
## DoD
- `curl http://localhost:8487/api/health` devuelve 200 + counts.
- `curl http://localhost:8487/api/issues | jq 'length' >= 90`.
- `curl -X PATCH /api/issues/0130 -d '{"status":"in-progress"}'` reescribe `dev/issues/0130-*.md` (status updated, body intacto).
- Despues del PATCH, suscriptor SSE recibe evento `{type:"updated", id:"0130"}`.
- Tras `mv dev/issues/0130-*.md dev/issues/completed/`, watcher actualiza fila (`completed=1`).
- `go test ./...` verde.
## Anti-scope
- No expone proposals ni capabilities (eso es MCP registry).
- No autentica (local-only por ahora).
- No persiste estado UI (eso lo hace el frontend).
@@ -0,0 +1,86 @@
---
id: "0130c"
title: "Frontend C++ ImGui kanban_cpp v2: board + flows + filtros + detalle"
status: pendiente
type: app
domain:
- cpp-stack
- dev-ux
scope: app-scoped
priority: alta
depends:
- "0130b"
blocks: []
related:
- "0130"
created: 2026-05-22
updated: 2026-05-22
tags: [cpp, imgui, kanban, frontend]
flow: "0130"
---
# 0130c — Frontend C++ ImGui kanban_cpp v2
**Status:** pendiente
## Por que
UI nativa sobre el backend 0130b. Aprovecha el framework `fn::run_app` (menubar, layouts, settings, about, log) y los componentes del registry (`data_table`, `kpi_card`, `http_request`, `sse_client`).
## Estructura
```
apps/kanban_cpp/
app.md
appicon.ico
CMakeLists.txt
main.cpp # fn::run_app + cfg.panels
data.h / data.cpp # http client + state global (issues, flows, filters)
panel_board.cpp # 4 columnas + drag-drop
panel_flows.cpp # tabla via data_table_cpp_viz
panel_filters.cpp # Aside con multi-select
panel_detail.cpp # form editable del issue seleccionado
panels.h
```
## Trio obligatorio (`app.md`)
```yaml
description: "Kanban C++ v2 para gestionar dev/issues y dev/flows del registry"
icon:
phosphor: "kanban"
accent: "#a855f7"
```
## Paneles
1. **Board** (`TI_KANBAN " Board"`) — 4 columnas (pendiente / in-progress / bloqueado / completado). Cada card: id + title (trunc 60) + priority badge + first domain chip. Drag-drop con `ImGui::BeginDragDropSource/Target` -> PATCH status.
2. **Flows** (`TI_FLOW " Flows"`) — `data_table_cpp_viz` con columnas id/title/status/kind. Click fila → carga detail.
3. **Filters** (`TI_FUNNEL " Filters"`) — AppShell.Aside-equivalente (panel lateral fijo). Multi-select por domain, scope, priority, tags. Estado local; rebuild request query.
4. **Detail** (`TI_INFO " Detail"`) — modal/panel lateral con form: status (combo), priority (combo), scope (combo), tags (chips editables), depends/blocks (listas), body (read-only multiline).
## HTTP client (data.cpp)
- `fetch_issues(filters)` → GET con query string → parse JSON → vector<Issue>.
- `fetch_flows()` → similar.
- `patch_issue(id, partial)` → PATCH JSON → recibe issue actualizado.
- `subscribe_sse()` thread aparte → push events a queue mutex → consumir en main loop → re-fetch afectados.
Usa `http_request_cpp_core` + `sse_client_cpp_core`. JSON via `nlohmann/json` (ya en cpp/vendor o sacar al header-only).
## DoD
- `cmake --build cpp/build/linux --target kanban_cpp -j` verde.
- `./cpp/build/linux/apps/kanban_cpp/kanban_cpp --self-test` exit 0:
- inicializa contexto ImGui sin display.
- parsea respuesta JSON sintetica.
- no toca red salvo si `--backend http://...` se pasa.
- e2e_checks en `app.md`: build + self_test + backend_health (corre backend en background) + smoke (drag-drop reescribe MD).
- Captura screenshot board con 4 columnas pobladas → guardar en `dod_evidence/board_screenshot.png`.
## Anti-scope
- Sin grafo de dependencias (epic 0130 lo describe como anti-scope v1).
- Sin crear issues nuevos (solo editar existentes).
- Sin edicion de body MD (solo frontmatter).
- Sin syntax highlighting markdown.
+1
View File
@@ -40,6 +40,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers |
| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit |
| [backends](backends.md) | — | Stacks backend (Go net/http+SQLite default, MCP, mautrix, bubbletea, httpx, docker-compose): decision tree + esqueleto canonico + funciones del registry a componer |
| [kanban](kanban.md) | 5 | Parser/writer/scanner/watcher de dev/issues/ y dev/flows/: base del backend kanban_cpp v2 |
## Como anadir grupo
+68
View File
@@ -0,0 +1,68 @@
# kanban — Parser/writer de issues y flows del registry
Cluster de funciones para leer, escribir y vigilar los archivos `dev/issues/*.md` y `dev/flows/*.md`. Base del backend de `kanban_cpp v2` (issue 0130b) y de cualquier herramienta que opere sobre el board de desarrollo.
## Funciones
| ID | Firma corta | Que hace |
|---|---|---|
| `parse_issue_md_go_infra` | `(path) → (Issue, []byte, error)` | Lee un .md de issue, extrae frontmatter YAML + body |
| `write_issue_md_go_infra` | `(path, Issue, body) → error` | Serializa Issue a YAML y reescribe el .md preservando body |
| `scan_issues_dir_go_infra` | `(root) → ([]Issue, error)` | Escanea dev/issues/ + completed/, devuelve todos los Issues ordenados |
| `scan_flows_dir_go_infra` | `(root) → ([]Flow, error)` | Escanea dev/flows/, devuelve todos los Flows ordenados |
| `watch_dir_fsnotify_go_infra` | `(ctx, root) → (<-chan FsEvent, error)` | Watcher recursivo con debounce 200ms, emite FsEvent por cambio |
## Tipos
| ID | Que es |
|---|---|
| `issue_go_infra` | Frontmatter de dev/issues/*.md: id, title, status, domain, priority, depends, blocks… |
| `flow_go_infra` | Frontmatter de dev/flows/*.md: id, name/title, status, kind, tags |
| `fs_event_go_infra` | Evento de watcher: {Path, Op} donde Op ∈ {create, write, remove, rename} |
## Ejemplo canónico — arrancar el backend de kanban_cpp
```go
import "fn-registry/functions/infra"
const (
issuesDir = "/home/lucas/fn_registry/dev/issues"
flowsDir = "/home/lucas/fn_registry/dev/flows"
)
// 1. Carga inicial
issues, _ := infra.ScanIssuesDir(issuesDir)
flows, _ := infra.ScanFlowsDir(flowsDir)
fmt.Printf("%d issues, %d flows cargados\n", len(issues), len(flows))
// 2. Actualizar status in-place
iss, body, _ := infra.ParseIssueMd(issuesDir + "/0130-kanban-cpp-v2.md")
iss.Status = "in-progress"
iss.Updated = "2026-05-22"
infra.WriteIssueMd(iss.FilePath, iss, body)
// 3. Vigilar cambios externos (editor de texto, otro agente)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch, _ := infra.WatchDirFsnotify(ctx, issuesDir)
for ev := range ch {
if strings.HasSuffix(ev.Path, ".md") {
updated, _, _ := infra.ParseIssueMd(ev.Path)
cache.Upsert(updated) // invalidar cache SQLite
}
}
```
## Fronteras
- NO incluye markdown rendering del body (eso lo hace el frontend).
- NO valida campos contra TAXONOMY (existe `fn doctor issues`).
- NO crea ni borra archivos de issue (solo lee/escribe los existentes).
- NO incluye endpoints HTTP ni SSE (eso es el backend de la app, issue 0130b).
## Notas
- `parse_issue_md` + `write_issue_md` son el par CRUD atómico. Siempre usarlos juntos.
- `scan_issues_dir` llama a `parse_issue_md` internamente — no reimplementar el walk.
- `watch_dir_fsnotify` emite eventos para cualquier archivo, no solo `.md`. Filtrar por extensión en el consumidor.
- El watcher y el writer pueden producir loops: el writer dispara un evento `write` que el watcher emite. El backend debe ignorar eventos generados por sus propios writes (comparar path + timestamp).
+19
View File
@@ -0,0 +1,19 @@
package infra
// Flow representa el frontmatter de un archivo Markdown de flow en dev/flows/.
// Los campos de runtime (FilePath, MtimeNs) no se serializaran en YAML.
type Flow struct {
ID string `yaml:"id"`
Title string `yaml:"title,omitempty"`
Status string `yaml:"status,omitempty"`
Kind string `yaml:"kind,omitempty"`
Tags []string `yaml:"tags,omitempty"`
// Para flows con formato name/status por separado (ej. hn-top-stories).
Name string `yaml:"name,omitempty"`
Priority string `yaml:"priority,omitempty"`
// Campos de runtime — NO se serializan en YAML.
FilePath string `yaml:"-"`
MtimeNs int64 `yaml:"-"`
}
+8
View File
@@ -0,0 +1,8 @@
package infra
// FsEvent representa un evento del watcher de sistema de archivos.
// Op es uno de: "create", "write", "remove", "rename".
type FsEvent struct {
Path string // ruta absoluta del archivo afectado
Op string // "create" | "write" | "remove" | "rename"
}
+25
View File
@@ -0,0 +1,25 @@
package infra
// Issue representa el frontmatter de un archivo Markdown de issue en dev/issues/.
// Los campos de runtime (FilePath, MtimeNs, Completed) no se serialiaran en YAML.
type Issue struct {
ID string `yaml:"id"`
Title string `yaml:"title"`
Status string `yaml:"status"`
Type string `yaml:"type"`
Domain []string `yaml:"domain"`
Scope string `yaml:"scope"`
Priority string `yaml:"priority"`
Depends []string `yaml:"depends"`
Blocks []string `yaml:"blocks"`
Related []string `yaml:"related"`
Tags []string `yaml:"tags"`
Flow string `yaml:"flow,omitempty"`
Created string `yaml:"created"`
Updated string `yaml:"updated"`
// Campos de runtime — NO se serializan en YAML.
FilePath string `yaml:"-"`
MtimeNs int64 `yaml:"-"`
Completed bool `yaml:"-"` // true si el archivo vive en dev/issues/completed/
}
+87
View File
@@ -0,0 +1,87 @@
package infra
import (
"bytes"
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// ParseIssueMd lee un archivo Markdown de issue, extrae y parsea el frontmatter YAML
// en un struct Issue, y devuelve el body (todo lo que va despues del segundo "---").
// FilePath e MtimeNs se rellenan con los valores del archivo en disco.
// Completed se deduce del path (contiene "/completed/").
func ParseIssueMd(path string) (Issue, []byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return Issue{}, nil, fmt.Errorf("parse_issue_md: read %s: %w", path, err)
}
info, err := os.Stat(path)
if err != nil {
return Issue{}, nil, fmt.Errorf("parse_issue_md: stat %s: %w", path, err)
}
fm, body, err := splitFrontmatter(data)
if err != nil {
return Issue{}, nil, fmt.Errorf("parse_issue_md: %s: %w", path, err)
}
var iss Issue
if err := yaml.Unmarshal(fm, &iss); err != nil {
return Issue{}, nil, fmt.Errorf("parse_issue_md: yaml %s: %w", path, err)
}
iss.FilePath = path
iss.MtimeNs = info.ModTime().UnixNano()
iss.Completed = strings.Contains(path, "/completed/")
return iss, body, nil
}
// splitFrontmatter divide el contenido en bloque YAML y body.
// Espera formato: "---\n<yaml>\n---\n<body>".
// Devuelve el YAML (sin los delimitadores) y el body (incluye el \n posterior al segundo ---).
func splitFrontmatter(data []byte) ([]byte, []byte, error) {
sep := []byte("---")
newline := []byte("\n")
// El archivo debe empezar con "---\n"
if !bytes.HasPrefix(data, append(sep, '\n')) {
return nil, nil, fmt.Errorf("missing opening '---' delimiter")
}
// Buscar el segundo "---" (en su propia linea)
rest := data[len(sep)+1:] // avanza pasado el primer "---\n"
idx := -1
for i := 0; i <= len(rest)-len(sep); i++ {
// Debe estar al inicio de linea: posicion 0 o precedido por '\n'
atLineStart := i == 0 || rest[i-1] == '\n'
if atLineStart && bytes.Equal(rest[i:i+len(sep)], sep) {
// El separador debe ir seguido de '\n' o EOF
end := i + len(sep)
if end == len(rest) || rest[end] == '\n' {
idx = i
break
}
}
}
if idx == -1 {
return nil, nil, fmt.Errorf("missing closing '---' delimiter")
}
fm := rest[:idx]
// El body empieza despues del segundo "---\n"
bodyStart := idx + len(sep)
if bodyStart < len(rest) && rest[bodyStart] == '\n' {
bodyStart++
}
body := rest[bodyStart:]
_ = newline
return fm, body, nil
}
+51
View File
@@ -0,0 +1,51 @@
---
name: parse_issue_md
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func ParseIssueMd(path string) (Issue, []byte, error)"
description: "Lee un archivo Markdown de issue (dev/issues/*.md), extrae el frontmatter YAML en un struct Issue y devuelve el body tal como esta en disco. Rellena FilePath, MtimeNs y Completed (deduce de si el path contiene /completed/)."
tags: [issue, parser, frontmatter, yaml, kanban, dev-ux, kanban]
uses_functions: []
uses_types: [issue_go_infra]
returns: [issue_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: ["bytes", "fmt", "os", "strings", "gopkg.in/yaml.v3"]
params:
- name: path
desc: "Ruta absoluta o relativa al archivo .md del issue (ej: dev/issues/0130-kanban-cpp-v2.md)"
output: "Struct Issue con todos los campos del frontmatter, byte slice con el body MD, y error si el archivo no existe o el YAML es invalido"
tested: true
tests:
- "parsea 0130-kanban-cpp-v2 correctamente"
- "completed flag se deduce del path"
- "error en archivo inexistente"
- "fixture preserva campos"
test_file_path: "functions/infra/parse_issue_md_test.go"
file_path: "functions/infra/parse_issue_md.go"
---
## Ejemplo
```go
iss, body, err := infra.ParseIssueMd("dev/issues/0130-kanban-cpp-v2.md")
if err != nil {
log.Fatal(err)
}
fmt.Printf("ID=%s Status=%s Domain=%v\n", iss.ID, iss.Status, iss.Domain)
// body contiene el Markdown despues del segundo ---
```
## Cuando usarla
Cuando necesites leer el frontmatter de un issue del registry para mostrarlo, modificarlo o indexarlo. Usar como base de `scan_issues_dir_go_infra` (que la llama por cada archivo) o cuando necesites acceso al body MD ademas del struct.
## Gotchas
- El body devuelto incluye el `\n` inmediatamente posterior al segundo `---`. No se normaliza.
- Si el archivo tiene un solo `---` (sin segundo delimitador), retorna error. Issues sin frontmatter no son validos.
- `Completed` se infiere del path, no del campo `status` del YAML — un issue con `status: completado` que vive en `dev/issues/` (no en `completed/`) tendra `Completed=false`.
- Los campos `Depends`, `Blocks`, `Related`, `Tags`, `Domain` son `[]string` — si el YAML los omite quedan como `nil`, no slice vacio.
+101
View File
@@ -0,0 +1,101 @@
package infra
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func registryRoot() string {
_, thisFile, _, _ := runtime.Caller(0)
return filepath.Join(filepath.Dir(thisFile), "..", "..")
}
func TestParseIssueMd(t *testing.T) {
root := registryRoot()
t.Run("parsea 0130-kanban-cpp-v2 correctamente", func(t *testing.T) {
path := filepath.Join(root, "dev", "issues", "0130-kanban-cpp-v2.md")
iss, body, err := ParseIssueMd(path)
if err != nil {
t.Fatalf("ParseIssueMd error: %v", err)
}
if iss.ID != "0130" {
t.Errorf("ID: got %q, want %q", iss.ID, "0130")
}
if !strings.Contains(iss.Title, "Kanban C++ v2") {
t.Errorf("Title %q does not contain 'Kanban C++ v2'", iss.Title)
}
if iss.Status != "pendiente" {
t.Errorf("Status: got %q, want %q", iss.Status, "pendiente")
}
if len(iss.Domain) < 3 {
t.Errorf("Domain: got %d items, want >=3: %v", len(iss.Domain), iss.Domain)
}
if iss.FilePath != path {
t.Errorf("FilePath: got %q, want %q", iss.FilePath, path)
}
if iss.MtimeNs == 0 {
t.Error("MtimeNs should be non-zero")
}
if iss.Completed {
t.Error("Completed should be false for non-completed issue")
}
if len(body) == 0 {
t.Error("body should not be empty")
}
})
t.Run("completed flag se deduce del path", func(t *testing.T) {
fixturePath := filepath.Join(root, "functions", "infra", "testdata", "issue_fixture.fixture")
data, err := os.ReadFile(fixturePath)
if err != nil {
t.Fatalf("read fixture: %v", err)
}
completedDir := filepath.Join(t.TempDir(), "completed")
if err := os.MkdirAll(completedDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
completedPath := filepath.Join(completedDir, "9999-fixture.md")
if err := os.WriteFile(completedPath, data, 0644); err != nil {
t.Fatalf("write: %v", err)
}
iss, _, err := ParseIssueMd(completedPath)
if err != nil {
t.Fatalf("ParseIssueMd error: %v", err)
}
if !iss.Completed {
t.Error("Completed should be true for path with /completed/")
}
})
t.Run("error en archivo inexistente", func(t *testing.T) {
_, _, err := ParseIssueMd("/nonexistent/path/issue.md")
if err == nil {
t.Error("expected error for nonexistent file")
}
})
t.Run("fixture preserva campos", func(t *testing.T) {
fixturePath := filepath.Join(root, "functions", "infra", "testdata", "issue_fixture.fixture")
iss, body, err := ParseIssueMd(fixturePath)
if err != nil {
t.Fatalf("ParseIssueMd error: %v", err)
}
if iss.ID != "9999" {
t.Errorf("ID: got %q, want %q", iss.ID, "9999")
}
if iss.Flow != "0001" {
t.Errorf("Flow: got %q, want %q", iss.Flow, "0001")
}
if len(iss.Depends) != 1 || iss.Depends[0] != "0001" {
t.Errorf("Depends: got %v, want [0001]", iss.Depends)
}
if !strings.Contains(string(body), "Este es el body") {
t.Errorf("body should contain fixture text, got: %s", string(body))
}
})
}
+83
View File
@@ -0,0 +1,83 @@
package infra
import (
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// ScanFlowsDir escanea el directorio root (dev/flows/) y devuelve todos los Flows
// encontrados en *.md directos.
// Si un archivo falla al parsearse, se emite un warning al log y se continua.
// Los flows se devuelven ordenados por ID ascendente.
func ScanFlowsDir(root string) ([]Flow, error) {
matches, err := filepath.Glob(filepath.Join(root, "*.md"))
if err != nil {
return nil, fmt.Errorf("scan_flows_dir: glob: %w", err)
}
var flows []Flow
for _, path := range matches {
base := filepath.Base(path)
if strings.EqualFold(base, "INDEX.md") || strings.EqualFold(base, "README.md") || strings.EqualFold(base, "AGENT_GUIDE.md") {
continue
}
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
continue
}
f, err := parseFlowMd(path)
if err != nil {
log.Printf("scan_flows_dir: warning: skip %s: %v", path, err)
continue
}
flows = append(flows, f)
}
sort.Slice(flows, func(i, j int) bool {
return flows[i].ID < flows[j].ID
})
return flows, nil
}
// parseFlowMd parsea el frontmatter de un archivo dev/flows/*.md en un struct Flow.
func parseFlowMd(path string) (Flow, error) {
data, err := os.ReadFile(path)
if err != nil {
return Flow{}, fmt.Errorf("read %s: %w", path, err)
}
info, err := os.Stat(path)
if err != nil {
return Flow{}, fmt.Errorf("stat %s: %w", path, err)
}
fm, _, err := splitFrontmatter(data)
if err != nil {
return Flow{}, fmt.Errorf("frontmatter %s: %w", path, err)
}
var f Flow
if err := yaml.Unmarshal(fm, &f); err != nil {
return Flow{}, fmt.Errorf("yaml %s: %w", path, err)
}
// Algunos flows usan "name" y no "title" — normalizar
if f.Title == "" && f.Name != "" {
f.Title = f.Name
}
// Algunos flows usan entero como ID en el YAML — yaml.v3 lo convierte a string OK
f.FilePath = path
f.MtimeNs = info.ModTime().UnixNano()
return f, nil
}
+52
View File
@@ -0,0 +1,52 @@
---
name: scan_flows_dir
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func ScanFlowsDir(root string) ([]Flow, error)"
description: "Escanea el directorio dev/flows/ (root) y devuelve todos los Flows encontrados en *.md directos. Skippea INDEX.md, README.md y AGENT_GUIDE.md. Si un archivo falla al parsearse emite warning y continua. Resultado ordenado por ID ascendente."
tags: [flow, scanner, frontmatter, yaml, dev-ux, kanban]
uses_functions: []
uses_types: [flow_go_infra]
returns: [flow_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: ["fmt", "log", "os", "path/filepath", "sort", "strings", "gopkg.in/yaml.v3"]
params:
- name: root
desc: "Ruta al directorio dev/flows/ (absoluta o relativa)."
output: "Slice de Flow ordenado por ID asc con FilePath y MtimeNs rellenados. Flows con YAML malformado se omiten con warning."
tested: true
tests:
- "scan devuelve al menos 5 flows"
- "flow 0001 esta presente"
- "flows tienen FilePath y MtimeNs"
- "flows ordenados por ID asc"
test_file_path: "functions/infra/scan_flows_dir_test.go"
file_path: "functions/infra/scan_flows_dir.go"
---
## Ejemplo
```go
flows, err := infra.ScanFlowsDir("/home/lucas/fn_registry/dev/flows")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Total flows: %d\n", len(flows))
for _, f := range flows {
fmt.Printf(" %s [%s] %s\n", f.ID, f.Status, f.Title)
}
```
## Cuando usarla
Al arrancar el backend de kanban_cpp para cargar el panel Flows. Tambien util para dashboards de estado del proyecto que necesiten listar flujos activos/pendientes.
## Gotchas
- El struct `Flow` tiene campos `Name` y `Title` porque algunos flows del registry usan `name:` y otros `title:` en el frontmatter. `parseFlowMd` normaliza: si `Title` esta vacio pero `Name` no, copia `Name` a `Title`.
- No tiene subdirectorio `completed/` equivalente — todos los flows activos e historicos viven en el mismo directorio raiz.
- La funcion `parseFlowMd` es interna (no exportada). Si necesitas parsear un flow individual, usa directamente `yaml.Unmarshal` o expone una funcion separada.
+66
View File
@@ -0,0 +1,66 @@
package infra
import (
"path/filepath"
"testing"
)
func TestScanFlowsDir(t *testing.T) {
root := registryRoot()
flowsDir := filepath.Join(root, "dev", "flows")
t.Run("scan devuelve al menos 5 flows", func(t *testing.T) {
flows, err := ScanFlowsDir(flowsDir)
if err != nil {
t.Fatalf("ScanFlowsDir: %v", err)
}
if len(flows) < 5 {
t.Errorf("expected >= 5 flows, got %d", len(flows))
}
})
t.Run("flow 0001 esta presente", func(t *testing.T) {
flows, err := ScanFlowsDir(flowsDir)
if err != nil {
t.Fatalf("ScanFlowsDir: %v", err)
}
found := false
for _, f := range flows {
if f.ID == "0001" {
found = true
break
}
}
if !found {
t.Error("flow 0001 not found in scan results")
}
})
t.Run("flows tienen FilePath y MtimeNs", func(t *testing.T) {
flows, err := ScanFlowsDir(flowsDir)
if err != nil {
t.Fatalf("ScanFlowsDir: %v", err)
}
for _, f := range flows {
if f.FilePath == "" {
t.Errorf("flow %q has empty FilePath", f.ID)
}
if f.MtimeNs == 0 {
t.Errorf("flow %q has zero MtimeNs", f.ID)
}
}
})
t.Run("flows ordenados por ID asc", func(t *testing.T) {
flows, err := ScanFlowsDir(flowsDir)
if err != nil {
t.Fatalf("ScanFlowsDir: %v", err)
}
for i := 1; i < len(flows); i++ {
if flows[i].ID < flows[i-1].ID {
t.Errorf("not sorted at index %d: %q < %q", i, flows[i].ID, flows[i-1].ID)
break
}
}
})
}
+62
View File
@@ -0,0 +1,62 @@
package infra
import (
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
)
// ScanIssuesDir escanea el directorio root (dev/issues/) y devuelve todos los Issues
// encontrados en *.md directos y en completed/*.md.
// Si un archivo falla al parsearse, se emite un warning al log y se continua.
// Los issues se devuelven ordenados por ID ascendente.
func ScanIssuesDir(root string) ([]Issue, error) {
// Verificar que el directorio raiz existe.
if _, err := os.Stat(root); err != nil {
return nil, fmt.Errorf("scan_issues_dir: root dir %s: %w", root, err)
}
var issues []Issue
// Patterns a escanear: archivos directos y completed/
patterns := []string{
filepath.Join(root, "*.md"),
filepath.Join(root, "completed", "*.md"),
}
for _, pattern := range patterns {
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, fmt.Errorf("scan_issues_dir: glob %s: %w", pattern, err)
}
for _, path := range matches {
// Saltar INDEX.md y README.md
base := filepath.Base(path)
if strings.EqualFold(base, "INDEX.md") || strings.EqualFold(base, "README.md") {
continue
}
// Verificar que es un archivo regular
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
continue
}
iss, _, err := ParseIssueMd(path)
if err != nil {
log.Printf("scan_issues_dir: warning: skip %s: %v", path, err)
continue
}
issues = append(issues, iss)
}
}
sort.Slice(issues, func(i, j int) bool {
return issues[i].ID < issues[j].ID
})
return issues, nil
}
+54
View File
@@ -0,0 +1,54 @@
---
name: scan_issues_dir
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func ScanIssuesDir(root string) ([]Issue, error)"
description: "Escanea el directorio dev/issues/ (root) y devuelve todos los Issues encontrados en *.md directos y en completed/*.md. Si un archivo falla al parsearse emite un warning al log y continua. Resultado ordenado por ID ascendente."
tags: [issue, scanner, frontmatter, yaml, dev-ux, kanban]
uses_functions: [parse_issue_md_go_infra]
uses_types: [issue_go_infra]
returns: [issue_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: ["fmt", "log", "os", "path/filepath", "sort", "strings"]
params:
- name: root
desc: "Ruta al directorio dev/issues/ (absoluta o relativa). Debe existir o retorna error."
output: "Slice de Issue ordenado por ID asc. Incluye issues de completed/ con Completed=true. Issues con YAML malformado se omiten con warning."
tested: true
tests:
- "scan devuelve al menos 90 issues"
- "issue 0130 esta presente"
- "issues ordenados por ID asc"
- "completed issues tienen Completed=true"
- "directorio inexistente retorna error"
test_file_path: "functions/infra/scan_issues_dir_test.go"
file_path: "functions/infra/scan_issues_dir.go"
---
## Ejemplo
```go
issues, err := infra.ScanIssuesDir("/home/lucas/fn_registry/dev/issues")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Total issues: %d\n", len(issues))
for _, iss := range issues {
fmt.Printf(" %s [%s] %s\n", iss.ID, iss.Status, iss.Title)
}
```
## Cuando usarla
Al arrancar el backend de kanban_cpp para poblar la cache SQLite inicial. Tambien util para cualquier herramienta que necesite un snapshot completo de todos los issues del proyecto (stats, dashboards, fn doctor).
## Gotchas
- Skippea automaticamente `INDEX.md` y `README.md` — no son issues.
- Si `completed/` no existe (no hay issues completados), no retorna error — devuelve los issues directos.
- La ordenacion es lexicografica por ID string, no numerica. `"0099" < "0100"` funciona bien con el formato de 4 digitos del registry.
- Un issue con YAML invalido no aborta el scan entero — solo ese archivo se omite con un `log.Printf` warning. Si necesitas comportamiento strict (abort en primer error), parsea manualmente con `ParseIssueMd`.
+74
View File
@@ -0,0 +1,74 @@
package infra
import (
"path/filepath"
"testing"
)
func TestScanIssuesDir(t *testing.T) {
root := registryRoot()
issuesDir := filepath.Join(root, "dev", "issues")
t.Run("scan devuelve al menos 90 issues", func(t *testing.T) {
issues, err := ScanIssuesDir(issuesDir)
if err != nil {
t.Fatalf("ScanIssuesDir: %v", err)
}
if len(issues) < 90 {
t.Errorf("expected >= 90 issues, got %d", len(issues))
}
})
t.Run("issue 0130 esta presente", func(t *testing.T) {
issues, err := ScanIssuesDir(issuesDir)
if err != nil {
t.Fatalf("ScanIssuesDir: %v", err)
}
found := false
for _, iss := range issues {
if iss.ID == "0130" {
found = true
break
}
}
if !found {
t.Error("issue 0130 not found in scan results")
}
})
t.Run("issues ordenados por ID asc", func(t *testing.T) {
issues, err := ScanIssuesDir(issuesDir)
if err != nil {
t.Fatalf("ScanIssuesDir: %v", err)
}
for i := 1; i < len(issues); i++ {
if issues[i].ID < issues[i-1].ID {
t.Errorf("not sorted at index %d: %q < %q", i, issues[i].ID, issues[i-1].ID)
break
}
}
})
t.Run("completed issues tienen Completed=true", func(t *testing.T) {
issues, err := ScanIssuesDir(issuesDir)
if err != nil {
t.Fatalf("ScanIssuesDir: %v", err)
}
completedCount := 0
for _, iss := range issues {
if iss.Completed {
completedCount++
}
}
if completedCount == 0 {
t.Error("expected at least some completed issues")
}
})
t.Run("directorio inexistente retorna error", func(t *testing.T) {
_, err := ScanIssuesDir("/nonexistent/dev/issues")
if err == nil {
t.Error("expected error for nonexistent directory")
}
})
}
+30
View File
@@ -0,0 +1,30 @@
---
id: "9999"
title: "Fixture issue con caracteres especiales: áéíóú & <test>"
status: pendiente
type: app
domain:
- core
- infra
scope: registry-only
priority: alta
depends:
- "0001"
blocks: []
related:
- "0100"
tags: [test, fixture, round-trip]
flow: "0001"
created: 2026-01-01
updated: 2026-05-22
---
# Fixture issue
Este es el body del issue. Contiene caracteres especiales: áéíóú & <test>.
## Sección
Linea con **negrita** y _cursiva_.
Final del body.
+135
View File
@@ -0,0 +1,135 @@
package infra
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/fsnotify/fsnotify"
)
// WatchDirFsnotify crea un watcher recursivo sobre root y todos sus subdirectorios.
// Emite FsEvent al canal devuelto con debounce de 200ms por path (si llegan multiples
// eventos del mismo archivo en la ventana, se emite solo el ultimo).
// Cierra el canal cuando ctx.Done() se dispara.
func WatchDirFsnotify(ctx context.Context, root string) (<-chan FsEvent, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("watch_dir_fsnotify: new watcher: %w", err)
}
// Anadir root y todos los subdirectorios recursivamente.
if err := addDirsRecursive(watcher, root); err != nil {
watcher.Close()
return nil, fmt.Errorf("watch_dir_fsnotify: add dirs: %w", err)
}
ch := make(chan FsEvent, 64)
go func() {
defer watcher.Close()
defer close(ch)
// Mapa de debounce: path -> (timer, ultimo op)
type pending struct {
timer *time.Timer
op string
}
debounce := make(map[string]*pending)
const debounceDelay = 200 * time.Millisecond
for {
select {
case <-ctx.Done():
// Cancelar todos los timers pendientes antes de salir.
for _, p := range debounce {
p.timer.Stop()
}
return
case event, ok := <-watcher.Events:
if !ok {
return
}
op := fsnotifyOpToString(event.Op)
if op == "" {
continue
}
path := event.Name
// Si el directorio nuevo fue creado, anadirlo al watcher.
if event.Op&fsnotify.Create != 0 {
if info, err := os.Stat(path); err == nil && info.IsDir() {
if err := watcher.Add(path); err != nil {
log.Printf("watch_dir_fsnotify: add new dir %s: %v", path, err)
}
}
}
// Debounce: resetear el timer si ya habia uno para este path.
if p, exists := debounce[path]; exists {
p.timer.Stop()
p.op = op
p.timer.Reset(debounceDelay)
} else {
p = &pending{op: op}
p.timer = time.AfterFunc(debounceDelay, func() {
select {
case ch <- FsEvent{Path: path, Op: p.op}:
case <-ctx.Done():
}
delete(debounce, path)
})
debounce[path] = p
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Printf("watch_dir_fsnotify: watcher error: %v", err)
}
}
}()
return ch, nil
}
// addDirsRecursive anade root y todos sus subdirectorios al watcher.
// Retorna error si root no existe o no es accesible.
func addDirsRecursive(watcher *fsnotify.Watcher, root string) error {
if _, err := os.Stat(root); err != nil {
return fmt.Errorf("root dir %s: %w", root, err)
}
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // ignora errores de acceso en subdirs
}
if info.IsDir() {
return watcher.Add(path)
}
return nil
})
}
// fsnotifyOpToString convierte fsnotify.Op al string canonico del registry.
// Retorna "" para operaciones no mapeadas (CHMOD, etc.).
func fsnotifyOpToString(op fsnotify.Op) string {
switch {
case op&fsnotify.Create != 0:
return "create"
case op&fsnotify.Write != 0:
return "write"
case op&fsnotify.Remove != 0:
return "remove"
case op&fsnotify.Rename != 0:
return "rename"
default:
return ""
}
}
+61
View File
@@ -0,0 +1,61 @@
---
name: watch_dir_fsnotify
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func WatchDirFsnotify(ctx context.Context, root string) (<-chan FsEvent, error)"
description: "Crea un watcher recursivo sobre root y todos sus subdirectorios usando fsnotify. Emite FsEvent al canal con debounce de 200ms por path (multiples eventos del mismo archivo en la ventana = un solo evento con la ultima op). Cierra el canal cuando ctx.Done(). Anade automaticamente nuevos subdirectorios creados en runtime."
tags: [watcher, fsnotify, filesystem, dev-ux, async, kanban]
uses_functions: []
uses_types: [fs_event_go_infra]
returns: [fs_event_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: ["context", "fmt", "log", "os", "path/filepath", "time", "github.com/fsnotify/fsnotify"]
params:
- name: ctx
desc: "Context para cancelar el watcher. Al cancelar, el canal se cierra limpiamente."
- name: root
desc: "Directorio raiz a vigilar recursivamente. Debe existir o retorna error."
output: "Canal de solo lectura que emite FsEvent por cada cambio detectado (tras debounce). El canal se cierra cuando ctx se cancela o el watcher interno falla."
tested: true
tests:
- "detecta escritura de archivo"
- "canal se cierra cuando ctx cancela"
- "error en directorio inexistente"
- "debounce agrupa multiples escrituras"
test_file_path: "functions/infra/watch_dir_fsnotify_test.go"
file_path: "functions/infra/watch_dir_fsnotify.go"
---
## Ejemplo
```go
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch, err := infra.WatchDirFsnotify(ctx, "/home/lucas/fn_registry/dev/issues")
if err != nil {
log.Fatal(err)
}
for ev := range ch {
fmt.Printf("event: op=%s path=%s\n", ev.Op, ev.Path)
// recargar el issue afectado en cache
}
```
## Cuando usarla
En el backend de kanban_cpp para detectar cambios externos en `dev/issues/` y `dev/flows/` (ediciones en el editor de texto del usuario) y propagar via SSE al frontend ImGui. Tambien util para cualquier daemon que necesite invalidar cache ante cambios en disco.
## Gotchas
- **Debounce por path**: si guardas el mismo archivo 5 veces en 200ms (ej. autoguardado del editor), recibes 1 evento, no 5. El `Op` del evento es el de la ultima operacion en la ventana.
- **Subdirectorios dinamicos**: si se crea un subdirectorio nuevo mientras el watcher esta activo, se anade automaticamente al watcher. Los archivos creados dentro del nuevo subdir se detectan.
- **Eventos CHMOD ignorados**: solo se emiten `create`, `write`, `remove`, `rename`. Cambios de permisos no disparan eventos.
- **Canal con buffer 64**: si el consumidor es lento y el buffer se llena, eventos adicionales se bloquean en la goroutine interna. Con debounce 200ms es poco probable en uso normal.
- **No filtra por extension**: emite eventos para cualquier archivo en el arbol, no solo `.md`. El consumidor debe filtrar si solo le interesan ciertos tipos.
- **Linux inotify limit**: en sistemas con muchos subdirectorios, puede alcanzar el limite de `fs.inotify.max_user_watches` (default 8192). Aumentar con `sysctl fs.inotify.max_user_watches=65536` si se observan errores en el log.
+129
View File
@@ -0,0 +1,129 @@
package infra
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
func TestWatchDirFsnotify(t *testing.T) {
t.Run("detecta escritura de archivo", func(t *testing.T) {
tmpDir := t.TempDir()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ch, err := WatchDirFsnotify(ctx, tmpDir)
if err != nil {
t.Fatalf("WatchDirFsnotify: %v", err)
}
// Dar tiempo al watcher para arrancar
time.Sleep(50 * time.Millisecond)
// Escribir un archivo
testFile := filepath.Join(tmpDir, "test.md")
if err := os.WriteFile(testFile, []byte("hello"), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
// Esperar evento (debounce 200ms + margen)
select {
case ev, ok := <-ch:
if !ok {
t.Fatal("channel closed unexpectedly")
}
if ev.Path != testFile {
t.Errorf("Path: got %q, want %q", ev.Path, testFile)
}
if ev.Op != "create" && ev.Op != "write" {
t.Errorf("Op: got %q, want 'create' or 'write'", ev.Op)
}
case <-ctx.Done():
t.Fatal("timeout waiting for fs event")
}
})
t.Run("canal se cierra cuando ctx cancela", func(t *testing.T) {
tmpDir := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
ch, err := WatchDirFsnotify(ctx, tmpDir)
if err != nil {
t.Fatalf("WatchDirFsnotify: %v", err)
}
// Cancelar inmediatamente
cancel()
// El canal debe cerrarse
timeout := time.After(2 * time.Second)
// Drenar cualquier evento pendiente hasta que el canal se cierre
for {
select {
case _, ok := <-ch:
if !ok {
return // canal cerrado correctamente
}
case <-timeout:
t.Fatal("channel not closed after ctx cancel within 2s")
}
}
})
t.Run("error en directorio inexistente", func(t *testing.T) {
ctx := context.Background()
_, err := WatchDirFsnotify(ctx, "/nonexistent/dir/that/does/not/exist")
if err == nil {
t.Error("expected error for nonexistent directory")
}
})
t.Run("debounce agrupa multiples escrituras", func(t *testing.T) {
tmpDir := t.TempDir()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ch, err := WatchDirFsnotify(ctx, tmpDir)
if err != nil {
t.Fatalf("WatchDirFsnotify: %v", err)
}
time.Sleep(50 * time.Millisecond)
testFile := filepath.Join(tmpDir, "debounce.md")
// Escribir 5 veces rapidamente
for i := 0; i < 5; i++ {
_ = os.WriteFile(testFile, []byte("content"), 0644)
time.Sleep(10 * time.Millisecond)
}
// Esperar debounce + margen
time.Sleep(400 * time.Millisecond)
// Debe haber llegado al menos un evento pero no 5
eventCount := 0
drain:
for {
select {
case _, ok := <-ch:
if !ok {
break drain
}
eventCount++
default:
break drain
}
}
if eventCount == 0 {
t.Error("expected at least one debounced event")
}
if eventCount >= 5 {
t.Errorf("debounce failed: got %d events, expected fewer than 5", eventCount)
}
})
}
+33
View File
@@ -0,0 +1,33 @@
package infra
import (
"bytes"
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// WriteIssueMd serializa el frontmatter del Issue a YAML y lo escribe en path junto al body.
// El archivo resultante tiene formato: "---\n<yaml>---\n<body>".
// El body se preserva exactamente tal como fue recibido (sin normalizar trailing newlines).
// Los campos de runtime (FilePath, MtimeNs, Completed) se omiten del YAML via yaml:"-".
func WriteIssueMd(path string, iss Issue, body []byte) error {
var buf bytes.Buffer
yamlBytes, err := yaml.Marshal(iss)
if err != nil {
return fmt.Errorf("write_issue_md: marshal %s: %w", path, err)
}
buf.WriteString("---\n")
buf.Write(yamlBytes)
buf.WriteString("---\n")
buf.Write(body)
if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
return fmt.Errorf("write_issue_md: write %s: %w", path, err)
}
return nil
}
+57
View File
@@ -0,0 +1,57 @@
---
name: write_issue_md
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func WriteIssueMd(path string, iss Issue, body []byte) error"
description: "Serializa el frontmatter de un struct Issue a YAML y escribe el archivo Markdown en disco con formato ---\\nyaml---\\nbody. Preserva el body exactamente sin normalizar trailing newlines ni reordenar. Los campos de runtime (FilePath, MtimeNs, Completed) se omiten del YAML via yaml:\"-\"."
tags: [issue, writer, frontmatter, yaml, dev-ux, kanban]
uses_functions: []
uses_types: [issue_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["bytes", "fmt", "os", "gopkg.in/yaml.v3"]
params:
- name: path
desc: "Ruta de destino del archivo .md (puede ser la misma de la que se leyo para un update in-place)"
- name: iss
desc: "Struct Issue con el frontmatter a serializar. FilePath/MtimeNs/Completed se ignoran en el YAML de salida"
- name: body
desc: "Body MD tal como fue devuelto por ParseIssueMd — se escribe byte a byte sin modificar"
output: "nil en exito, error si el marshal YAML falla o el archivo no se puede escribir"
tested: true
tests:
- "round-trip parse-write-parse preserva struct"
- "archivo resultante empieza con ---"
- "error en path inexistente"
test_file_path: "functions/infra/write_issue_md_test.go"
file_path: "functions/infra/write_issue_md.go"
---
## Ejemplo
```go
// Actualizar status de un issue in-place
iss, body, err := infra.ParseIssueMd("dev/issues/0130-kanban-cpp-v2.md")
if err != nil { log.Fatal(err) }
iss.Status = "in-progress"
iss.Updated = "2026-05-22"
if err := infra.WriteIssueMd("dev/issues/0130-kanban-cpp-v2.md", iss, body); err != nil {
log.Fatal(err)
}
```
## Cuando usarla
Cuando el backend de kanban_cpp necesite actualizar el frontmatter de un issue (cambio de status, priority, tags, etc.) sin tocar el body. Siempre usar en par con `parse_issue_md_go_infra`: parse → modificar struct → write.
## Gotchas
- `yaml.Marshal` de v3 puede reordenar campos respecto al original — el orden del YAML de salida sera el orden de declaracion del struct `Issue`, no el del archivo original. Si el orden importa para diff legibilidad, documentarlo.
- El body se escribe byte a byte. Si lo modificas antes de pasar, lo que escribes es lo que queda.
- No hace backup previo. En sistemas con watcher activo, el write dispara un evento `write` en `watch_dir_fsnotify_go_infra` — el backend debe ignorar sus propios writes para no entrar en loop.
+92
View File
@@ -0,0 +1,92 @@
package infra
import (
"os"
"path/filepath"
"testing"
)
func TestWriteIssueMd(t *testing.T) {
root := registryRoot()
t.Run("round-trip parse-write-parse preserva struct", func(t *testing.T) {
fixturePath := filepath.Join(root, "functions", "infra", "testdata", "issue_fixture.fixture")
// Parse original
iss1, body1, err := ParseIssueMd(fixturePath)
if err != nil {
t.Fatalf("ParseIssueMd: %v", err)
}
// Write a TempDir
tmpPath := filepath.Join(t.TempDir(), "issue_roundtrip.md")
if err := WriteIssueMd(tmpPath, iss1, body1); err != nil {
t.Fatalf("WriteIssueMd: %v", err)
}
// Parse de nuevo
iss2, body2, err := ParseIssueMd(tmpPath)
if err != nil {
t.Fatalf("ParseIssueMd after write: %v", err)
}
// Comparar campos (ignorar FilePath y MtimeNs que son runtime)
if iss1.ID != iss2.ID {
t.Errorf("ID: %q != %q", iss1.ID, iss2.ID)
}
if iss1.Title != iss2.Title {
t.Errorf("Title: %q != %q", iss1.Title, iss2.Title)
}
if iss1.Status != iss2.Status {
t.Errorf("Status: %q != %q", iss1.Status, iss2.Status)
}
if iss1.Flow != iss2.Flow {
t.Errorf("Flow: %q != %q", iss1.Flow, iss2.Flow)
}
if len(iss1.Domain) != len(iss2.Domain) {
t.Errorf("Domain len: %d != %d", len(iss1.Domain), len(iss2.Domain))
}
if len(iss1.Depends) != len(iss2.Depends) {
t.Errorf("Depends len: %d != %d", len(iss1.Depends), len(iss2.Depends))
}
if len(iss1.Tags) != len(iss2.Tags) {
t.Errorf("Tags len: %d != %d", len(iss1.Tags), len(iss2.Tags))
}
// El body debe preservarse exactamente
if string(body1) != string(body2) {
t.Errorf("body mismatch:\ngot: %q\nwant: %q", string(body2), string(body1))
}
})
t.Run("archivo resultante empieza con ---", func(t *testing.T) {
iss := Issue{
ID: "0001",
Title: "Test issue",
Status: "pendiente",
}
tmpPath := filepath.Join(t.TempDir(), "test.md")
if err := WriteIssueMd(tmpPath, iss, []byte("# Body\n")); err != nil {
t.Fatalf("WriteIssueMd: %v", err)
}
data, _ := os.ReadFile(tmpPath)
if len(data) < 4 || string(data[:4]) != "---\n" {
t.Errorf("file should start with '---\\n', got: %q", string(data[:min(10, len(data))]))
}
})
t.Run("error en path inexistente", func(t *testing.T) {
iss := Issue{ID: "0001", Title: "x", Status: "pendiente"}
err := WriteIssueMd("/nonexistent/dir/issue.md", iss, []byte("body"))
if err == nil {
t.Error("expected error writing to nonexistent dir")
}
})
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+1
View File
@@ -7,6 +7,7 @@ require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/fsnotify/fsnotify v1.7.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.1
github.com/marcboeker/go-duckdb v1.8.5
+2
View File
@@ -39,6 +39,8 @@ 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
+38
View File
@@ -0,0 +1,38 @@
---
name: flow
lang: go
domain: infra
version: "0.1.0"
algebraic: product
definition: |
type Flow struct {
ID string `yaml:"id"`
Title string `yaml:"title,omitempty"`
Status string `yaml:"status,omitempty"`
Kind string `yaml:"kind,omitempty"`
Tags []string `yaml:"tags,omitempty"`
Name string `yaml:"name,omitempty"`
Priority string `yaml:"priority,omitempty"`
FilePath string `yaml:"-"`
MtimeNs int64 `yaml:"-"`
}
description: "Frontmatter YAML de un archivo dev/flows/*.md. Campos de runtime (FilePath, MtimeNs) no se serializan en YAML."
tags: [flow, frontmatter, yaml, kanban, dev-ux, registry]
uses_types: []
file_path: "functions/infra/flow_type.go"
---
## Ejemplo
```go
f := infra.Flow{
ID: "0001",
Name: "hn-top-stories",
Status: "pending",
Tags: []string{"scraping", "news"},
}
```
## Notas
Producido por `scan_flows_dir_go_infra`. Los flows del registry usan campos variados en su frontmatter — el struct cubre el subconjunto comun: id/name/title/status/kind/tags/priority. Campos desconocidos se ignoran silenciosamente por yaml.Unmarshal.
+30
View File
@@ -0,0 +1,30 @@
---
name: fs_event
lang: go
domain: infra
version: "0.1.0"
algebraic: product
definition: |
type FsEvent struct {
Path string
Op string // "create" | "write" | "remove" | "rename"
}
description: "Evento del watcher de sistema de archivos. Op es uno de: create, write, remove, rename."
tags: [watcher, fsnotify, event, filesystem, kanban, dev-ux]
uses_types: []
file_path: "functions/infra/fs_event_type.go"
---
## Ejemplo
```go
// Recibido desde el canal de watch_dir_fsnotify_go_infra:
ev := infra.FsEvent{
Path: "/home/lucas/fn_registry/dev/issues/0130a-kanban-cpp-v2-parser.md",
Op: "write",
}
```
## Notas
Producido por `watch_dir_fsnotify_go_infra`. El canal emite un evento por archivo afectado tras el debounce de 200ms. Si se producen multiples operaciones sobre el mismo path en la ventana de debounce, se emite solo la ultima operacion.
+51
View File
@@ -0,0 +1,51 @@
---
name: issue
lang: go
domain: infra
version: "0.1.0"
algebraic: product
definition: |
type Issue struct {
ID string `yaml:"id"`
Title string `yaml:"title"`
Status string `yaml:"status"`
Type string `yaml:"type"`
Domain []string `yaml:"domain"`
Scope string `yaml:"scope"`
Priority string `yaml:"priority"`
Depends []string `yaml:"depends"`
Blocks []string `yaml:"blocks"`
Related []string `yaml:"related"`
Tags []string `yaml:"tags"`
Flow string `yaml:"flow,omitempty"`
Created string `yaml:"created"`
Updated string `yaml:"updated"`
FilePath string `yaml:"-"`
MtimeNs int64 `yaml:"-"`
Completed bool `yaml:"-"`
}
description: "Frontmatter YAML de un archivo dev/issues/*.md. Campos de runtime (FilePath, MtimeNs, Completed) no se serializan en YAML."
tags: [issue, frontmatter, yaml, kanban, dev-ux, registry]
uses_types: []
file_path: "functions/infra/issue_type.go"
---
## Ejemplo
```go
iss := infra.Issue{
ID: "0130",
Title: "Kanban C++ v2",
Status: "pendiente",
Priority: "alta",
Domain: []string{"cpp-stack", "apps-infra"},
Scope: "multi-app",
Tags: []string{"kanban", "cpp"},
Created: "2026-05-22",
Updated: "2026-05-22",
}
```
## Notas
Producido por `parse_issue_md_go_infra`. Los campos `Depends`, `Blocks`, `Related`, `Tags`, `Domain` se deserializan como `[]string` — si el YAML los omite, quedan como slice vacio (no nil). `Completed` se deduce del path (contiene `/completed/`), no del frontmatter.