7 Commits

Author SHA1 Message Date
egutierrez 6aa874f2b6 done(0131): agents v0.2 — unified control + uptime/msg_24h + data_table + clear/cache 2026-05-22 23:10:32 +02:00
egutierrez 93352a7780 feat(issues): 0131 agents v0.2 — unified control + uptime/msg_24h + data_table + clear/cache 2026-05-22 22:47:02 +02:00
Egutierrez 0ffae6daa4 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>
2026-05-22 22:20:15 +02:00
egutierrez 74b58cf0d0 fix(http_request): drop "2>&1" on Windows — CreateProcessW has no shell
POSIX popen routes via /bin/sh -c, so "2>&1" is a shell redirect. On
Windows we use CreateProcessW directly (no shell): curl receives "2>&1"
as a positional arg, treats it as a second URL, and fails with exit 3
"URL rejected: Bad hostname".

Stderr is already merged into the same pipe via STARTUPINFOW.hStdError
on Windows, so the redirect is also unnecessary there. Guard with
#ifndef _WIN32.

Also adds FN_HTTP_DEBUG env var to dump the cmdline + req.url for
future bug triage (zero-cost when unset).

Detected via agents_dashboard.exe --connect-test against
https://agents.organic-machine.com — same .exe with the fix now returns
"OK 11" in <2s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:15:37 +02:00
egutierrez 9752fb106a done: 0128 + 0129 — agents_and_robots HTTP API + agents_dashboard C++ ImGui
Both issues delivered end-to-end:

0128 (backend, merged via dataforge/agents_and_robots/pulls/1):
- HTTP daemon in cmd/launcher with apikey Bearer auth + SSE
- LIVE at https://agents.organic-machine.com via Coolify Traefik + LE cert
- systemd Restart=always
- Unified status autodetect fix applied

0129 (frontend, merged via dataforge/agents_dashboard/pulls/1):
- C++ ImGui app in projects/element_agents/apps/agents_dashboard
- 4 panels: Connection / Agents / Logs / Status
- secret_store_cpp_infra new function (DPAPI Windows / XOR Linux)
- Deployed to Windows Desktop, App Hub tarjeta visible

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:58:05 +02:00
egutierrez 8cb0121573 merge: 0129 agents_dashboard scaffold + secret_store_cpp_infra + CMakeLists register 2026-05-22 21:57:04 +02:00
egutierrez 90115270d2 chore: auto-commit (3 archivos)
- cpp/functions/infra/secret_store.cpp
- cpp/functions/infra/secret_store.h
- cpp/functions/infra/secret_store.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:52:37 +02:00
35 changed files with 2062 additions and 2 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 -2
View File
@@ -269,8 +269,21 @@ Response request(const Request& req) {
}
cmd << ' ' << sh_q(req.url)
<< " -o " << sh_q(tmp_body_out)
<< " 2>&1";
<< " -o " << sh_q(tmp_body_out);
// On POSIX we go through /bin/sh -c via popen, so `2>&1` is a shell redirect.
// On Windows we use CreateProcessW (no shell): `2>&1` would be passed as an
// extra positional arg to curl, which treats it as a second URL → "Bad
// hostname" (exit 3). stderr is already merged via STARTUPINFOW.hStdError.
#ifndef _WIN32
cmd << " 2>&1";
#endif
if (std::getenv("FN_HTTP_DEBUG")) {
fprintf(stderr, "[fn_http debug] cmdline: %s\n", cmd.str().c_str());
fprintf(stderr, "[fn_http debug] req.url=[%s] len=%zu\n",
req.url.c_str(), req.url.size());
}
// Capture stderr (curl prints transport errors to stderr with -sS).
std::string curl_stderr;
+121
View File
@@ -0,0 +1,121 @@
---
id: "0130"
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
scope: multi-app
priority: alta
depends: []
blocks: []
related:
- "0112"
- "0119"
tags:
- kanban
- cpp
- imgui
- dev_ux
- issues
- flows
created: "2026-05-22"
updated: "2026-05-22"
---
# 0130 — Kanban C++ v2
**Status:** pendiente
## Por que
La v1 (`apps/kanban_cpp` borrada el 2026-05-22) mezclaba paneles ajenos al dominio kanban (agent runs, DoD, worktrees, calendar) y un backend que no era reutilizable. Para gestionar los 98 issues activos + 12 flows del proyecto necesitamos una vista board nativa, sin web, con edicion bidireccional de los archivos markdown.
## Que entrega
App kanban_cpp v2 con dos piezas:
1. **Backend Go** (`apps/kanban_cpp/backend/`) — service HTTP en puerto 8487.
- Parser bidireccional MD <-> SQLite (cache).
- Watcher fsnotify sobre `dev/issues/` (+ `completed/`) y `dev/flows/`.
- Endpoints REST: `/api/issues`, `/api/issues/{id}` (GET/PATCH), `/api/flows`, `/api/flows/{id}`, `/api/meta`, `/api/sse`.
- PATCH a issue reescribe el frontmatter en disco preservando body + orden de campos.
2. **Frontend C++ ImGui** (`apps/kanban_cpp/`) sobre el framework `fn::run_app`.
- Panel **Board**: columnas por status (pendiente / in-progress / bloqueado / completado). Drag-drop = PATCH status.
- Panel **Flows**: lista de flows con detalle.
- Panel **Filtros** (Aside): multi-select domain, scope, priority, tags.
- Panel **Detalle**: edicion de campos frontmatter de un issue (status, priority, scope, tags, depends, blocks).
- SSE para refrescar tras cambios externos en disco.
## Sub-issues
- **0130a** — parser MD + scan dirs (funciones registry).
- **0130b** — backend Go: schema + handlers + watcher + SSE.
- **0130c** — frontend C++: paneles + http client.
Cada sub-issue mergeable independiente en su rama corta TBD.
## Reusa del registry
Backend Go:
- `sqlite_open_go_infra`, `sqlite_apply_migrations_go_infra`
- `http_router_go_infra`, `http_serve_go_infra`, `http_middleware_chain_go_infra`
- `http_cors_middleware_go_infra`, `http_logger_middleware_go_infra`
- `http_json_response_go_infra`, `http_error_response_go_infra`, `http_parse_body_go_infra`
- `random_hex_id_go_core`
Frontend C++:
- `http_request_cpp_core`
- `sse_client_cpp_core`
- `data_table_cpp_viz` (lista flows)
- `kpi_card_cpp_viz` (contadores por status)
## Crea (delegadas a fn-constructor en 0130a)
- `parse_issue_md_go_infra` — lee .md → struct (frontmatter YAML + body).
- `write_issue_md_go_infra` — escribe struct → .md preservando body + orden de campos.
- `scan_issues_dir_go_infra` — walk `dev/issues/` + `dev/issues/completed/`.
- `scan_flows_dir_go_infra` — walk `dev/flows/`.
- `watch_dir_fsnotify_go_infra` (si no existe) — events channel.
## DoD
- `fn doctor` verde para ambas apps (artefacts + e2e).
- `e2e_checks` en ambos `app.md` (build + health + self-test).
- Drag-drop en frontend reescribe el `.md` correspondiente y `git diff` lo muestra (solo frontmatter, body intacto).
- Trio obligatorio (`description` + `icon.phosphor` + `icon.accent`) en ambos `app.md`.
- Sub-repos Gitea creados (`dataforge/kanban_cpp` reactivado o nuevo, mismo nombre).
dod_evidence_schema:
- id: backend_health
kind: cmd
expected: "curl -fsS http://localhost:8487/api/health == 200"
required: true
- id: api_issues_count
kind: cmd
expected: "curl -fsS http://localhost:8487/api/issues | jq 'length' >= 90"
required: true
- id: patch_writes_md
kind: cmd
expected: "PATCH /api/issues/0130 status=in-progress reescribe dev/issues/0130-*.md (git diff muestra solo status)"
required: true
- id: frontend_self_test
kind: cmd
expected: "./cpp/build/linux/apps/kanban_cpp/kanban_cpp --self-test exit 0"
required: true
- id: board_screenshot
kind: screenshot
expected: "kanban_cpp Board panel con 4 columnas pobladas con issues reales"
required: true
## Anti-scope
NO incluye en esta version:
- Grafo de dependencias (depends/blocks/related visual).
- Edicion de body MD desde la app (solo frontmatter).
- Multi-PC sync (backend es local).
- Crear issues nuevos desde la UI (solo editar existentes).
- DoD evidence panel, agent runs, calendar, worktrees (la v1 los mezclaba — fuera).
+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.
@@ -0,0 +1,235 @@
---
id: "0131"
title: "agents v0.2: control per-agent unified mode + uptime/msg_24h + data_table_cpp_viz + clear/cache actions"
status: pendiente
type: feature
domain:
- agents
- tui
- infra
scope: app
priority: alta
depends:
- "0128"
- "0129"
blocks: []
related: []
created: 2026-05-22
updated: 2026-05-22
tags: [agents_and_robots, agents_dashboard, http, unified-mode, data-table, control]
dod_evidence_schema:
# Backend: agents_and_robots
- id: build_backend
kind: cmd
expected: "cd projects/element_agents/apps/agents_and_robots && go build -tags goolm ./... → exit 0"
required: true
- id: tests_backend
kind: cmd
expected: "cd projects/element_agents/apps/agents_and_robots && go test -tags goolm -count=1 ./internal/api/... → exit 0"
required: true
- id: stop_unified_works
kind: cmd
expected: "POST /agents/test-bot/stop devuelve {status:stopped}; GET /agents/test-bot → running=false en <2s"
required: true
- id: start_unified_works
kind: cmd
expected: "POST /agents/test-bot/start tras stop devuelve {status:started}; GET /agents/test-bot → running=true en <5s"
required: true
- id: restart_unified_works
kind: cmd
expected: "POST /agents/test-bot/restart sobre agente running deja running=true en <8s sin error"
required: true
- id: clear_memory_endpoint
kind: cmd
expected: "POST /agents/test-bot/clear_memory devuelve {status:cleared, messages_deleted:N}; SELECT COUNT(*) FROM messages WHERE agent_id='test-bot' == 0"
required: true
- id: delete_cache_endpoint
kind: cmd
expected: "POST /agents/test-bot/delete_cache devuelve {status:cleared, paths_deleted:[...]}; verificar que crypto.db cache borrado"
required: true
- id: uptime_exposed
kind: cmd
expected: "GET /agents incluye campo uptime_seconds:int >0 para agents running"
required: true
- id: msg_24h_exposed
kind: cmd
expected: "GET /agents incluye campo messages_24h:int (puede ser 0) calculado de tabla messages"
required: true
# Frontend: agents_dashboard
- id: build_frontend
kind: cmd
expected: "cmake --build cpp/build/windows --target agents_dashboard -j → exit 0"
required: true
- id: data_table_cpp_viz_used
kind: cmd
expected: "grep -E 'BeginTable|EndTable' projects/element_agents/apps/agents_dashboard/main.cpp devuelve 0 lineas (migrado a data_table_cpp_viz); grep data_table_cpp_viz app.md uses_functions = 1"
required: true
- id: per_agent_buttons_rendered
kind: screenshot
expected: "Tabla Agents muestra >=5 botones por fila: Start, Stop, Restart, Clear Memory, Delete Cache (puede iconos+tooltip)"
required: true
- id: uptime_visible
kind: screenshot
expected: "Tabla Agents columna uptime muestra valor humanizado (ej 12h, 3d) para agents running"
required: true
- id: msg_24h_visible
kind: screenshot
expected: "Tabla Agents columna msg/24h muestra contador real (no 'instances' como hack)"
required: true
# E2E: pytest
- id: e2e_tests_pass
kind: cmd
expected: "AGENTS_API_KEY=... pytest tests/test_connect_e2e.py → todos PASS (>=20 tests)"
required: true
- id: e2e_control_roundtrip
kind: cmd
expected: "Nuevo test_control_roundtrip: stop → poll running=false → start → poll running=true → restart → poll running=true. Todo dentro de 30s."
required: true
- id: e2e_clear_memory
kind: cmd
expected: "Nuevo test_clear_memory: insert filas en messages → POST /clear_memory → COUNT == 0"
required: true
---
# 0131 — agents v0.2: full per-agent control + data_table + nuevos botones
## Contexto
v0.1 (issues 0128+0129) entrego:
- HTTP API + apikey + TLS + SSE
- C++ frontend con Connection/Agents/Logs/Status feed
- Tabla agents con `running` derivado de backend
**Gaps detectados durante uso real:**
1. **Control individual roto en unified mode** — Manager.Start/Stop esperan PID files por agente; en unified mode no existen → endpoints devuelven errores confusos ("not running" sobre agente que SI corre).
2. **No hay uptime ni msg_24h reales** — backend no expone esos campos. UI muestra `instances` como hack para msg_24h.
3. **Faltan acciones de gestion** — clear memory (mensajes en SQLite), delete cache (crypto E2EE), reset state.
4. **Tabla manual**`ImGui::BeginTable` inline en main.cpp. El registry tiene `data_table_cpp_viz` (funcion canonica). Migrar.
## Scope v0.2
### Backend (`projects/element_agents/apps/agents_and_robots/`)
**1. Control per-agent en unified mode**
Hoy launcher arranca todos los agents como goroutines bajo 1 PID via mode "unified". `Manager.Start/Stop/Restart` actuales solo funcionan en mode multi-process (PID por agente).
Anadir registro de cancel-context por agente en el launcher:
- Por cada agente que arranca como goroutine, guardar `context.CancelFunc` en `Manager.unifiedCancels map[string]context.CancelFunc`.
- `Manager.StopUnifiedAgent(id)` llama cancel del agente especifico.
- `Manager.StartUnifiedAgent(id)` re-arranca solo ese agente sin restart del launcher entero.
- `Manager.RestartUnifiedAgent(id)` = Stop + Start.
Handlers `handleStart/Stop/Restart` autodetectan via `IsUnifiedRunning()` y delegan a las nuevas variantes unified.
**2. Uptime real**
- `Manager.startedAt map[string]time.Time` poblado al arrancar cada goroutine.
- En `AgentStatus.UptimeSeconds`, calcular `time.Since(startedAt[id]).Seconds()` si running, else 0.
- Exponer en `agentResponse` como `uptime_seconds: int`.
**3. Messages_24h**
Cada agent persiste mensajes en su SQLite (`agents/<id>/data/memory.db`). El handler `handleListAgents` debe agregar por agente:
- Abrir DB del agente readonly
- `SELECT COUNT(*) FROM messages WHERE created_at > datetime('now', '-24 hours')`
- Cache 30s para no abrir DB en cada request
Exponer como `messages_24h: int`.
**4. Endpoint `POST /agents/{id}/clear_memory`**
- Stop agent (si running)
- Open agent's memory.db
- `DELETE FROM messages` + `DELETE FROM facts`
- Optionally start back si estaba running (deber `?restart=true` opcional)
- Return `{status:"cleared", messages_deleted:N, facts_deleted:M}`
**5. Endpoint `POST /agents/{id}/delete_cache`**
- Stop agent (si running)
- Delete `agents/<id>/data/crypto/` directory (E2EE cache; agent re-init on next start)
- Delete `agents/<id>/data/cache/*` si existe
- Return `{status:"cleared", paths_deleted:[...]}`
- Optionally start back si estaba running (`?restart=true`)
NOTA: delete_cache fuerza re-verificacion E2EE. El agente debe re-autenticarse via SSSS recovery key on next start. Documentar.
### Frontend (`projects/element_agents/apps/agents_dashboard/`)
**1. Migrar a `data_table_cpp_viz`**
Hoy main.cpp usa `ImGui::BeginTable` inline. Sustituir por `data_table::Table` del registry (funcion `data_table_cpp_viz`). Anadir a `app.md::uses_functions`. Verificar via `fn doctor cpp-apps` que la app pasa de `CANDIDATE` a limpio.
**2. Columnas tabla:**
- id
- status icon (running=green, stopped=gray, disabled=yellow, crashed=red)
- uptime (humanized via `human_duration_secs`)
- msg/24h (numero real, NO instances)
- actions (5 botones agrupados):
- `▶ Start` (disabled si running)
- `⏹ Stop` (disabled si !running)
- `↻ Restart`
- `🧠 Clear Memory` (confirmacion modal)
- `🗑 Delete Cache` (confirmacion modal)
**3. Sort + filter** mantener via data_table_cpp_viz API.
### E2E (`tests/`)
Anadir 7 tests nuevos:
- `test_control_roundtrip` — stop → poll → start → poll → restart → poll. Usa `test-bot`.
- `test_clear_memory` — POST clear_memory, verifica COUNT(*) FROM messages == 0.
- `test_delete_cache` — POST delete_cache, verifica crypto/ borrado.
- `test_uptime_field_present` — /agents response incluye uptime_seconds key
- `test_msg_24h_field_present` — /agents response incluye messages_24h key
- `test_unified_stop_does_not_kill_launcher` — tras stop de 1 agente, otros siguen running.
- `test_clear_memory_requires_apikey` — sin Bearer → 401
## Tareas
### Fase A — Backend (agents_and_robots)
1. Agregar `unifiedCancels map[string]context.CancelFunc` + `startedAt map[string]time.Time` + mutex a `shell/process.Manager`.
2. Hook en `launcher` runtime para registrar/desregistrar cancels al arrancar/parar cada agent goroutine.
3. Implementar `StopUnifiedAgent`, `StartUnifiedAgent`, `RestartUnifiedAgent` (Stop+Start).
4. Refactor handlers `handleStartAgent/Stop/Restart` para autodetect unified vs multi.
5. Anadir `uptime_seconds` y `messages_24h` a `AgentResponse`. Implementar query 24h con cache 30s.
6. Implementar handlers `handleClearMemory`, `handleDeleteCache`.
7. Anadir rutas en `server.go`.
8. Tests Go unit `internal/api/*_test.go`.
### Fase B — Frontend (agents_dashboard)
1. Cambiar `parse_agents` para leer `uptime_seconds` y `messages_24h` del backend.
2. Migrar tabla a `data_table_cpp_viz`. Mantener filter + sort.
3. Anadir 5 botones por fila (Start/Stop/Restart/Clear/Delete).
4. Confirmacion modal para Clear/Delete.
5. Actualizar app.md::uses_functions con `data_table_cpp_viz`.
### Fase C — E2E + verify
1. Anadir 7 pytest tests.
2. Run all e2e from registry venv. >=20 tests pass.
3. Rebuild .exe + redeploy Windows.
4. Visual confirm: botones, uptime, msg_24h.
## Acceptance
- [ ] All 14 DoD items green (cmd + screenshots).
- [ ] >=20 e2e tests passing.
- [ ] App C++ deployed to Windows Desktop, visible buttons + working roundtrip.
- [ ] Backend unit tests pass.
- [ ] No regression: 0128 + 0129 funcionalidad existente intacta (curl smoke del v0.1 sigue green).
## DoD humano
- **Donde**: Windows Desktop → agents_dashboard.exe → tabla Agents.
- **Latencia**: stop → running=false reflected in UI within 2s (via SSE status diff). msg/24h refresh cada 30s ok.
- **Onboarding**: tooltip en boton "Clear Memory" explica que borra mensajes; "Delete Cache" explica que el agente tendra que re-autenticar via SSSS al volver a arrancar.
## Riesgos
- Refactor de Manager unified-mode toca el ciclo de vida del launcher (paso ~7 del create_agent pipeline). Tests existentes deben pasar.
- delete_cache borra crypto store; agente debe poder re-verify via env var `SSSS_RECOVERY_KEY_<NORM>`. Si esa env var no esta, agente queda en estado degradado. Validar antes de borrar.
- data_table_cpp_viz puede tener limites de API que ImGui inline no tiene (sort custom, alignment). Verificar antes de migrar.
+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.