chore: snapshot WIP previo + flow 0008 + 7 sub-issues (0112-0119)

Snapshot de WIP acumulado de sesiones previas antes de merge wave 1
del flow 0008 (kanban_cpp + agent_runner_api + DoD schema).

Incluye:
- dev/flows/0008-kanban-cpp-and-agent-workflows.md
- dev/issues/0112-0119*.md (7 sub-issues)
- WIP previo en cmd/fn/doctor.go, registry/*, modules/, cpp/, etc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 18:17:08 +02:00
parent ddb5366884
commit b9716a7cd6
119 changed files with 14929 additions and 3084 deletions
+1
View File
@@ -258,6 +258,7 @@ fn check params # Lista funciones sin params_schema
fn doctor # Corre todos los checks fn doctor # Corre todos los checks
fn doctor artefacts # git/venv/app.md/upstream de cada app y analysis fn doctor artefacts # git/venv/app.md/upstream de cada app y analysis
fn doctor services # apps tag 'service' + systemctl + puerto fn doctor services # apps tag 'service' + systemctl + puerto
fn doctor services-spec # audita bloque `service:` del app.md (issue 0105)
fn doctor sync # drift pc_locations BD vs disco fn doctor sync # drift pc_locations BD vs disco
fn doctor uses-functions # imports reales vs uses_functions del app.md fn doctor uses-functions # imports reales vs uses_functions del app.md
fn doctor unused # funciones del registry sin consumidores fn doctor unused # funciones del registry sin consumidores
+274
View File
@@ -0,0 +1,274 @@
# /cpp-app — Crear o modificar app C++ del registry sin olvidar nada
Recopila TODOS los datos necesarios (frontmatter, trio app_hub, panels, AppConfig, service block, e2e_checks, uses_functions) **antes** de tocar el disco. Tras confirmar, ejecuta scaffolder o edits, regenera iconos, refresca app_hub, compila y deploya a Windows.
Sustituye al flujo manual "edito main.cpp + app.md + CMakeLists.txt a mano". Wrapper sobre `init_cpp_app_bash_pipelines` (create) o edits directos sobre `app.md` (modify) + `regenerate_app_icons` + `refresh_app_hub` + `redeploy_cpp_app_windows`.
---
## Uso
```
/cpp-app # interactivo, modo create
/cpp-app <name> # interactivo, modo create con name pre-rellenado
/cpp-app modify <name> # editar app existente
```
---
## Modo CREATE — flujo turno a turno
Si `$ARGUMENTS` no empieza por `modify`, es create. Si trae `<name>`, lo usas como default; si no, pregunta name.
### Paso 0 — verificar que no existe
```bash
test -d "/home/lucas/fn_registry/apps/<name>" \
|| ls /home/lucas/fn_registry/projects/*/apps/<name> 2>/dev/null
```
Si existe en cualquier ubicacion: **abortar** y sugerir `/cpp-app modify <name>`. NO sobreescribir.
### Paso 1 — Identidad (AskUserQuestion)
1. **name** (texto libre — valida snake_case + contiene verbo segun `ids_naming.md`). Verbos canonicos: `show, render, view, plot, edit, manage, monitor, browse, explore, run, launch, scan, audit, debug, profile, ...`. Si no trae verbo, sugerir alternativas (`viewer` -> `<name>_viewer`).
2. **project** (select: ninguno / lista de `projects/*/`). Si ninguno -> `apps/<name>/`.
3. **domain** (select: `tools` (default), `gfx`, `tui`, `infra`, `finance`, `datascience`, `cybersecurity`, `shell`, `pipelines`, `browser`).
4. **description** 1 linea (texto libre, max 80 chars). **OBLIGATORIO** — sin esto el hub muestra tarjeta vacia.
### Paso 2 — Trio app_hub OBLIGATORIO
Regla dura `cpp_apps.md`: description + icon.phosphor + icon.accent SIEMPRE juntos.
5. **icon.phosphor** glyph name. Antes de preguntar, ofrece busqueda:
```bash
ls /home/lucas/fn_registry/sources/phosphor-core/assets/fill/ | grep -i "<keyword>"
```
Sugiere 3-5 candidatos basados en `description`. Default segun domain: `gfx`->`palette`, `tui`->`terminal`, `tools`->`wrench`, `infra`->`gear`, `finance`->`chart-line-up`, `datascience`->`graph`, `cybersecurity`->`shield`.
6. **icon.accent** hex `#rrggbb` (palette select):
- sky `#0ea5e9`, indigo `#4f46e5`, violet `#7c3aed`, pink `#ec4899`, rose `#f43f5e`, red `#dc2626`, orange `#ea580c`, amber `#d97706`, green `#16a34a`, teal `#0d9488`, cyan `#0891b2`, slate `#475569`. Default segun domain.
### Paso 3 — Tags
7. **tags** (multiSelect): `service`, `launcher`, `dashboard`, `viewer`, `editor`, `monitor`, `debug`, `prototype`. Si selecciona `service` -> activar bloque service (Paso 7).
### Paso 4 — Panels iniciales
8. **panels** (texto libre o select):
- Default: 1 panel `Main` (Ctrl+1).
- Opcion lista: hasta 4 paneles. Por cada uno: `{label, shortcut}`. Generara `PanelToggle k_panels[]` en `main.cpp`.
### Paso 5 — AppConfig flags
9. (multiSelect):
- `init_gl_loader` (true si la app llama `gl*` directo, ej. shaders, GPU renderer custom). Default false.
- `viewports` true (default) / false (single-window).
- `auto_dockspace` true (default) / false (solo si gestiona DockSpace propio tipo `shaders_lab`).
- `fps_overlay` activo de inicio? (controla solo el default; el menu Settings lo toggle).
### Paso 6 — Funciones del registry a usar
10. **uses_functions** lista IDs. Antes de preguntar, busca candidatas segun description:
```
mcp__registry__fn_search query="<keyword>" entity="functions"
```
Y muestra capability groups relevantes (`docs/capabilities/INDEX.md`). El usuario puede aceptar lista, anadir IDs, o dejar vacio (se rellena tras codear).
Cada ID que no este en el registry -> ofrecer spawn `fn-constructor` antes de continuar (regla `delegation.md`).
### Paso 7 — Bloque `service:` (solo si tag=service)
11. Si paso 3 marco `service`, recopilar (regla `function_tags.md` + issue 0105):
- `port` int o null
- `health_endpoint` ruta GET o null
- `health_timeout_s` (default 3)
- `runtime` (select: `systemd-user`, `systemd-system`, `docker-compose`, `stdio`, `manual`)
- `systemd_unit` (obligatorio si runtime empieza por `systemd-`)
- `systemd_scope` (`user|system|null`)
- `restart_policy` (select: `always` (Recommended — gotcha: `on-failure` NO reinicia SIGTERM limpio), `on-failure`, `none`)
- `pc_targets` (multiSelect de pc_locations actuales: `aurgi-pc`, `home-wsl`, ...)
- `is_local_only` (true/false default false)
### Paso 8 — Persistencia
12. (multiSelect):
- BD propia SQLite `<name>.db` en `local_files/`? -> recordar usar `fn::local_path("<name>.db")` (cpp_apps.md §7)
- operations.db (para entities/relations)? -> ejecutar `fn ops init` tras crear
- Archivos config en `local_files/`?
### Paso 9 — e2e_checks (issue 0068)
13. Default sugerido (modificable):
```yaml
e2e_checks:
- id: build
cmd: "cmake --build cpp/build --target <name> -j"
timeout_s: 300
- id: self_test
cmd: "./cpp/build/apps/<name>/<name> --self-test"
timeout_s: 30
severity: warning # si todavia no implementa --self-test
```
Pregunta: ¿anadir mas checks (ops_audit, pytest, smoke)?
### Paso 10 — Resumen y confirmacion
Mostrar bloque YAML completo del `app.md` que se va a generar + flags del scaffolder + post-acciones. Pedir confirmacion antes de ejecutar.
---
## Modo CREATE — ejecucion
Una vez confirmado:
```bash
cd /home/lucas/fn_registry
# 1. Scaffolder
./fn run init_cpp_app <name> \
[--project <p>] \
[--domain <d>] \
--desc "<description>" \
[--tags "<csv>"]
# 2. Editar app.md generado para anadir:
# - icon: {phosphor, accent}
# - service: {...} (si aplica)
# - uses_functions: [...]
# - e2e_checks: [...]
# (el scaffolder no rellena estos; editarlos con Edit tool)
# 3. Editar main.cpp generado para reflejar:
# - panels[] custom (si != default)
# - cfg.init_gl_loader / cfg.auto_dockspace / cfg.viewports
# - includes de funciones registry usadas
# 4. Editar CMakeLists.txt para anadir paths de funciones del registry:
# ${CMAKE_SOURCE_DIR}/functions/<d>/<f>.cpp
# 5. Si es service -> ofrecer crear systemd unit (skipear si runtime=stdio|manual)
# 6. Si pidio operations.db
./fn ops init apps/<name> # o projects/<p>/apps/<name>
# 7. Generar icono
./fn run generate_app_icon "<phosphor>" "<accent>" "<dir>/appicon.ico"
# 8. Indexar
./fn index
# 9. Compilar Windows
./fn run redeploy_cpp_app_windows <name> <dir> --build
# 10. Refrescar app_hub
./fn run refresh_app_hub
# 11. Auditoria
./fn doctor cpp-apps
[[ "<tag>" == *service* ]] && ./fn doctor services-spec
```
---
## Modo MODIFY — flujo
`/cpp-app modify <name>`
### Paso 0 — Localizar
```bash
# Buscar apps/<name>/ o projects/*/apps/<name>/
sqlite3 /home/lucas/fn_registry/registry.db \
"SELECT id, dir_path FROM apps WHERE name='<name>' AND lang='cpp';"
```
Si no existe: abortar, sugerir `/cpp-app` (sin args) para crear.
### Paso 1 — Mostrar config actual
```bash
mcp__registry__fn_show id="<id>"
cat <dir>/app.md
```
### Paso 2 — Que cambiar (multiSelect)
- `description` (1 linea)
- `icon.phosphor` o `icon.accent`
- `tags` (anadir/quitar; si toca `service` -> Paso 7 del create)
- `uses_functions` (anadir/quitar — recordar editar CMakeLists.txt)
- `panels` (anadir/quitar/renombrar)
- `service:` block (si tag=service)
- `e2e_checks`
- `domain`
- `rename` (cambia name, dir, IDs derivados, repo Gitea — operacion delicada, requiere doble confirmacion)
### Paso 3 — Aplicar cambios
Para cada cambio: usa `Edit` sobre los archivos correspondientes. NUNCA `Write` completo de `app.md` (preserva campos que no toques).
### Paso 4 — Post-acciones (segun lo que toco)
```bash
# Siempre
cd /home/lucas/fn_registry && ./fn index
# Si toco icon.* -> regenerar appicon
./fn run generate_app_icon "<phosphor>" "<accent>" "<dir>/appicon.ico"
# Si toco trio o panels o uses_functions o cambia code:
./fn run redeploy_cpp_app_windows <name> <dir> --build
# Si toco description o icon o tags:
./fn run refresh_app_hub
# Si toco service: o tag service
./fn doctor services-spec
# Siempre al final
./fn doctor cpp-apps
```
---
## Reglas duras
- **NUNCA** crear `main.cpp` + `CMakeLists.txt` + `app.md` a mano. Siempre via `init_cpp_app_bash_pipelines` (regla `cpp_apps.md`).
- **NUNCA** poner el codigo en `cpp/apps/<n>/`. Solo `apps/<n>/` o `projects/<p>/apps/<n>/`.
- **NUNCA** dejar `app.md` sin el trio (description + icon.phosphor + icon.accent). Tarjeta del hub queda gris.
- **NUNCA** declarar funciones del registry en `uses_functions` sin listar su `.cpp` en `CMakeLists.txt` (drift detectado por `fn doctor uses-functions`).
- **NUNCA** usar `Restart=on-failure` en systemd unit de un service C++ — gotcha 2026-05-17 (`sqlite_api.service` cayo 20h). Default `Restart=always`.
- Despues de **cualquier** cambio en el trio: `regenerate_app_icons <name>` + `refresh_app_hub`.
---
## Auto-verificacion final
Tras crear o modificar, reportar al usuario:
```
=== app <name> ===
dir: <abs_dir>
domain: <d>
description: "<desc>"
icon: <phosphor> + <accent>
tags: [<csv>]
uses_functions: N funciones (<list_top_5>)
panels: N (<labels>)
e2e_checks: N checks
service: <si/no — port:<p> health:<h>>
Acciones ejecutadas:
[✓] scaffolder / edits
[✓] generate_app_icon
[✓] fn index (registry.db actualizado)
[✓] redeploy_cpp_app_windows (Desktop/apps/<name>/<name>.exe)
[✓] refresh_app_hub (tarjeta visible en hub)
[✓] fn doctor cpp-apps (limpio | N warnings)
Siguiente paso sugerido:
- Abrir app_hub_launcher en Windows y verificar tarjeta
- Anadir tests visuales si la app tiene paneles propios (cpp/PATTERNS.md §11)
```
$ARGUMENTS
+186
View File
@@ -0,0 +1,186 @@
---
name: fix-issue
description: Implementar un issue de dev/issues/ end-to-end. Crea rama, ejecuta tareas, bumpa version si toca modulos/framework/apps (via /version), tests, cierra issue, integra a master.
---
# /fix-issue
Ejecuta el flujo completo de implementacion/cierre de un issue de `dev/issues/`. Adaptado al stack del registry: Go (`-tags fts5 CGO_ENABLED=1`), Python (`python/.venv/bin/python3`), Bash, TypeScript (`pnpm`), C++ (`cmake`+`mingw-w64` toolchain).
## Inputs
```
/fix-issue <NNNN[a|b|c...]>
```
- `NNNN`: numero del issue (ej. `0107`).
- Si es sub-issue, sufijo letra: `0107a`, `0107b`, ...
Si no se proporciona, preguntar.
## Flujo obligatorio
### 1. Resolver el issue
- `dev/issues/<NNNN>-*.md` → si no existe, STOP.
- Si ya en `dev/issues/completed/`, STOP.
- Si es sub-issue, leer tambien el principal para contexto.
### 2. Leer y extraer
- Objetivo, tareas, arquitectura, prerequisitos, riesgos.
- Identificar archivos afectados — anotar si toca:
- `modules/<X>/` o `cpp/framework/` → bumpa version (paso 8).
- `functions/`, `python/functions/`, `bash/functions/`, `frontend/functions/` → indexer + `fn index` al cerrar.
- Apps en `apps/<X>/` o `projects/*/apps/<X>/` → requiere rama TBD (regla `apps_tbd.md`) **+ bumpa version per-app (paso 8)**. Si el issue toca multiples apps, una llamada `/version` por app.
- Registry meta (CLAUDE.md, rules, templates) → push directo a master OK.
### 3. Estrategia de rama
**Registry-only changes** (functions/types/docs/rules):
- Push directo a master OK. NO crear rama.
**Apps changes** (apps/, projects/*/apps/):
- Crear rama TBD:
```bash
git checkout master
git pull --rebase
git checkout -b issue/<NNNN>-<slug>
```
La rama es del registry. Si la app es sub-repo, ademas crear rama dentro del sub-repo.
**Modules/framework changes** (`modules/`, `cpp/framework/`):
- Rama TBD obligatoria (afecta a todas las apps que linkean).
### 4. Plan con TaskCreate
- Crear tarea por bloque logico del issue.
- Incluir SIEMPRE:
- Tarea de tests (unit + smoke).
- Tarea de `fn index` si toco metadata.
- Tarea de `/version` si toco `modules/`, `cpp/framework/`, `apps/<X>/` o `projects/*/apps/<X>/` (una llamada por target).
- Tarea de cleanup/docs.
### 5. Implementar
Reglas registry-first (CLAUDE.md):
- ANTES de escribir codigo reutilizable → `mcp__registry__fn_search` para encontrar lo que existe.
- Si falta funcion reutilizable → spawn `fn-constructor` (no escribir inline).
- Si patron se repite >2x → propose nueva funcion.
- NUNCA `sqlite3 registry.db "SELECT ..."` plano — usar MCP.
Convenciones del stack:
| Stack | Build/test |
|---|---|
| Go | `CGO_ENABLED=1 go build -tags fts5 -o fn ./cmd/fn/` y `CGO_ENABLED=1 go test -tags fts5 ./...` |
| Python | `python/.venv/bin/python3 -m pytest <path>` |
| Bash | `bash -n <script>.sh` + tests inline |
| TypeScript | `cd frontend && pnpm build && pnpm test` |
| C++ (Linux) | `cmake --build build --target <app>` |
| C++ (Windows MinGW) | `cmake -B build/windows -DCMAKE_TOOLCHAIN_FILE=cpp/toolchains/mingw-w64.cmake && cmake --build build/windows --target <app>` |
Commits atomicos por bloque logico con prefijos: `feat:`, `fix:`, `test:`, `docs:`, `refactor:`, `chore:`. Mensajes en espanol. NO WIP.
### 6. Tests
Stack-dependent (ver arriba). Si tests pasan parcialmente con failures pre-existentes no causadas por la rama, documentar en cuerpo del commit/PR.
### 7. Feature flags (si aplica)
Si el issue forma parte de un feature multi-issue:
- Editar `dev/feature_flags.json` con el flag (desactivado).
- Activar el flag en el ultimo sub-issue del set.
Flag != WIP. Codigo detras de flag debe compilar + testear.
### 8. Version bump (si toco modulos/framework/apps)
**OBLIGATORIO si el issue toco** alguno de:
- `modules/<X>/` → bumpa `modules/<X>/module.md::version`.
- `cpp/framework/` → bumpa `modules/framework/module.md::version`.
- `apps/<X>/` → bumpa `apps/<X>/app.md::version`.
- `projects/<P>/apps/<X>/` → bumpa `projects/<P>/apps/<X>/app.md::version`.
```
/version <path> <major|minor|patch> "<reason>"
```
Reglas (modulos/framework):
- Major: breaking ABI/API publica.
- Minor: additive (nuevo helper, refactor interno sin cambio de API, nuevo miembro).
- Patch: bugfix puro.
Reglas (apps):
- Major: breaking observable (CLI args, schema BBDD propia, formato wire).
- Minor: feature aditiva visible (nuevo panel, endpoint, opcion).
- Patch: bugfix sin cambio observable, refactor interno, mejora perf.
**Una llamada `/version` por target afectado**. Si el issue toca 1 modulo + 2 apps -> 3 llamadas a `/version` (cada una con su `reason` y bump-type apropiado; pueden diferir).
Diff guard: cambios que solo tocan el `app.md` (correccion typo descripcion, anadir tag) NO requieren bump — son metadata, no comportamiento. Detectar con `git diff --name-only | grep -v '\.md$'` para decidir si hay cambio de codigo real.
`/version` solo edita + stage. NO commit. El bump va junto con el codigo correspondiente en el mismo commit (`feat:` o `fix:` o `refactor:`).
Si NO toco modulos/framework/apps, saltar este paso.
### 9. Cerrar el issue
Mover archivo:
```bash
mv dev/issues/<NNNN>-<slug>.md dev/issues/completed/
```
Actualizar `dev/issues/README.md`:
- Link → `completed/<NNNN>-<slug>.md`
- Estado → `completado`
Si es feature multi-issue y este es el ultimo sub-issue:
- Flip flag en `dev/feature_flags.json` a `enabled: true` con `enabled_at: <YYYY-MM-DD>`.
- Verificar que todos los sub-issues estan en `completed/`.
### 10. Integrar
**Registry-only changes**: push directo a master.
**Apps/modules/framework changes**: `/full-git-push` o `/git-push` (merge --no-ff de la rama a master, push, delete rama).
### 11. Verificar post-cierre
- `fn index` — registry.db al dia.
- `fn doctor` (subcomandos relevantes: `artefacts`, `services`, `cpp-apps`, `uses-functions`).
- Si toco modulos: `fn doctor modules` (post 0107a) — 0 drift.
## Reglas criticas
- **Registry-first**: SIEMPRE buscar antes de escribir; delegar a `fn-constructor` antes que inline.
- **TBD para apps**: NUNCA push directo a master en apps. Rama corta, merge --no-ff.
- **TBD NO para registry**: push directo OK para functions/types/docs/rules.
- **`/version` obligatorio** si tocas modulos, framework o apps (con cambio de codigo real, no solo metadata). Si no, drift entre `version:` y `## Capability growth log` y se pierde trazabilidad.
- **Tests siempre**: no cerrar issue sin tests pasando (salvo failures pre-existentes documentados).
- **Commits atomicos**: 1 commit = 1 bloque logico. No mezclar `feat:` + `test:` en mismo commit.
- **Cerrar siempre**: nunca dejar issue implementado sin mover a `completed/` + actualizar README.
## Referenciado desde
- `.claude/commands/version.md` — bump semver de modulos.
- `.claude/commands/full-git-push.md` — push del registry + sub-repos.
- `.claude/rules/apps_tbd.md` — politica de TBD por tipo de cambio.
## Ejemplo: implementar 0107c (refactor data_table)
```
/fix-issue 0107c
1. Resolver: dev/issues/0107c-split-data-table.md ✓
2. Extraer: refactor 4777 LOC → 6 sub-funciones. Toca modules/ → /version obligatorio.
3. Rama: issue/0107c-split-data-table desde master.
4. Plan: 8 tareas (lectura + 6 sub-funciones + entrypoint thin + version bump).
5. Implementar: spawn fn-constructor en paralelo si hay >1 sub-funcion independiente.
6. Tests: build + smoke + primitives_gallery --capture diff.
7. Flag: parte de modules-v2, NO activar todavia (espera 0107a-f cerrar).
8. /version modules/data_table major "split data_table.cpp into 6 sub-functions"
9. Cerrar: mv → completed/ + README.
10. /git-push.
11. fn index + fn doctor modules → 0 drift en consumidores limpiados.
```
+170
View File
@@ -0,0 +1,170 @@
---
name: version
description: Bumpear semver de un modulo, framework, paquete o app del registry. Edita <target>.md::version + ## Capability growth log. NO commitea.
---
# /version
Bumpea la version de un **modulo, framework, paquete o app** del registry siguiendo SemVer estricto y mantiene el `## Capability growth log` sincronizado con `<target>.md::version`.
Disenado para usarse desde `/fix-issue` cuando el cambio afecte:
- `modules/<X>/` (cualquier modulo C++) — edita `module.md`
- `cpp/framework/` — edita `modules/framework/module.md`
- `apps/<X>/` o `projects/<P>/apps/<X>/` — edita `app.md`
- Otros paquetes versionados con `<target>.md` y campo `version:`
## Inputs
```
/version <path> <major|minor|patch> "<reason>"
```
- `<path>`: directorio del target (ej. `modules/data_table`, `cpp/framework`, `apps/chart_demo`, `projects/fn_monitoring/apps/registry_dashboard`).
- `<major|minor|patch>`: tipo de bump SemVer.
- `<reason>`: 1-frase humana — lo que cambia. Se inserta en el log.
## Resolucion del archivo target
| Path empieza por | Archivo a editar |
|---|---|
| `modules/` | `<path>/module.md` |
| `cpp/framework` | `modules/framework/module.md` |
| `apps/` | `<path>/app.md` |
| `projects/*/apps/` | `<path>/app.md` |
| `projects/*/analysis/` | `<path>/analysis.md` |
Si no encuentra archivo target -> ERROR.
## Reglas SemVer
### Modulos / framework
| Bump | Cuando |
|---|---|
| `major` | Cambios breaking en API publica: firma de entry function, layout de State struct expuesto, eliminacion de members, cambio incompatible de comportamiento. |
| `minor` | Adiciones backwards-compatible: nuevo evento opt-in, nuevo renderer, nuevo helper, nuevo miembro. |
| `patch` | Bugfix sin cambio de API. |
Refactor interno SIN cambio de API publica -> `minor` (no major).
### Apps
| Bump | Cuando |
|---|---|
| `major` | Breaking observable por usuarios: CLI args incompatibles, schema BBDD propia rompe lectores viejos, formato wire (HTTP/gRPC) incompatible, eliminacion de panel/feature que la gente usaba. |
| `minor` | Feature aditiva: nuevo panel, nuevo endpoint, nueva opcion CLI, nueva tab, mejora visible no rompedora. |
| `patch` | Bugfix sin cambio observable. Refactor interno. Mejoras de perf. |
Bump de **dependencia** (modulo/funcion del registry) que mejora la app pero la app no cambia su API -> `patch` (la app no es responsable de la mejora; el modulo si).
## Flujo
### 1. Validar input
- `<target_file>` existe -> si no, ERROR.
- Bump type en {major, minor, patch} -> si no, ERROR.
- Reason no vacia -> si no, ERROR.
### 2. Leer version actual
Parsear frontmatter. Buscar `version: X.Y.Z`. Si no existe:
- Para `module.md` -> ERROR "module.md sin campo version".
- Para `app.md` -> asumir `0.1.0` (baseline) e insertar el campo despues de `domain:`.
### 3. Calcular proxima version
```
1.4.0 + major = 2.0.0
1.4.0 + minor = 1.5.0
1.4.0 + patch = 1.4.1
```
Major bump -> minor y patch a 0. Minor bump -> patch a 0.
### 4. Editar `<target_file>`
Cambiar linea `version: <old>` por `version: <new>`.
### 5. Anadir entrada a `## Capability growth log`
Insertar al inicio de la lista (lineas posteriores al header `## Capability growth log`):
```markdown
- v<new> (<fecha YYYY-MM-DD>) — <reason>
```
Si la seccion no existe -> crearla al final del archivo antes de `## Notes` (o al final si no hay Notes).
### 6. Verificar drift de members (solo modulos, opcional)
Si la herramienta `fn doctor modules` existe (post 0107a) y el target es modulo:
- Compara `members:` actual vs ultima version registrada en `registry.db::modules_history`.
- Si hay diff en members y bump es `patch` -> WARNING.
- Si hay diff en API publica y bump no es `major` -> ERROR (require `--force`).
No aplica a apps (no tienen `members:`).
### 7. Stage en git
`git add <target_file>`. NO commit. El commit final lo hace el flujo padre.
### 8. Reportar
```
/version apps/chart_demo minor "anade tab radar chart"
apps/chart_demo/app.md
version: 1.2.0 -> 1.3.0
## Capability growth log: + v1.3.0 (2026-05-18) — anade tab radar chart
Staged. NO committed.
Next: terminar el fix-issue y hacer commit con el resto de cambios.
```
## Reglas criticas
- **NUNCA commit**. `/version` solo edita + stage. El commit lo hace el flujo padre (`/fix-issue`, `/git-push`).
- **NUNCA saltar version**. No 1.4.0 -> 1.4.2 directo.
- **NUNCA bajar version**. Si rollback, crea nueva version superior con comportamiento viejo restaurado.
- **fecha = HOY** (`date +%Y-%m-%d`).
- **reason** comprensible sin contexto del PR actual.
## Referenciado desde
- `/fix-issue` — al detectar cambios en `modules/`, `cpp/framework/`, `apps/<X>/` o `projects/*/apps/<X>/`, sugiere ejecutar `/version` antes del commit final.
- `.claude/rules/cpp_apps.md` — politica de bump.
- `dev/issues/0107-modules-standardization.md` — origen del flujo (modulos).
## Ejemplos
```
# Bug fix en data_table (modulo)
/version modules/data_table patch "fix off-by-one en seleccion multi-row con shift+click"
# -> 1.4.0 -> 1.4.1
# Feature opt-in en framework
/version cpp/framework minor "anade cfg.auto_dockspace para overlay de paneles flotantes"
# -> 1.1.0 -> 1.2.0
# Feature en app C++
/version apps/chart_demo minor "anade tab radar chart con datos sinteticos"
# -> 1.2.0 -> 1.3.0
# Bug fix en app de proyecto
/version projects/fn_monitoring/apps/registry_dashboard patch "fix tooltip que mostraba duration_ms en segundos"
# -> 0.4.1 -> 0.4.2
# Breaking en app: cambia schema de su BBDD propia
/version apps/kanban major "cards.assignee_id pasa a ser TEXT[] (era TEXT); requiere migracion 008"
# -> 1.0.0 -> 2.0.0
```
## Anti-patrones
| Anti-patron | Por que es malo |
|---|---|
| Editar `version:` a mano sin `## Capability growth log` | Drift entre version y log; nadie sabe que cambio. |
| Bumpear major en app por refactor interno | Confunde al usuario; refactor es patch. |
| Patch para feature visible | Usuario no se entera que esta disponible. |
| Reason "cambios varios" / "mejoras" | Inutil para auditar. Una frase concreta. |
| Bump de app sin tocar codigo de la app (solo dep) | Bump va al modulo, no a la app. |
+24
View File
@@ -84,6 +84,7 @@ Plantilla minima para apps C++:
name: <name> name: <name>
lang: cpp lang: cpp
domain: <gfx|tui|tools|infra|...> domain: <gfx|tui|tools|infra|...>
version: 0.1.0 # semver per-app, bumped via /version
description: "Frase corta — lo que hace y por que existe." description: "Frase corta — lo que hace y por que existe."
tags: [imgui, ...] # si es service, anadir 'service' tags: [imgui, ...] # si es service, anadir 'service'
uses_functions: # IDs del registry — el indexer NO deduce C++ uses_functions: # IDs del registry — el indexer NO deduce C++
@@ -102,6 +103,7 @@ Reglas:
- `framework: "imgui"` siempre que use `fn::run_app`. Otros valores solo si la app NO usa el shell (raro). - `framework: "imgui"` siempre que use `fn::run_app`. Otros valores solo si la app NO usa el shell (raro).
- `tags`: incluir `service` si es daemon de larga duracion (ver `function_tags.md`). - `tags`: incluir `service` si es daemon de larga duracion (ver `function_tags.md`).
- `repo_url` apunta al sub-repo en Gitea (ver §6). - `repo_url` apunta al sub-repo en Gitea (ver §6).
- `version`: semver per-app. Baseline `0.1.0` para apps nuevas. Bump obligatorio via `/version apps/<name> {major|minor|patch} "<reason>"` cuando `/fix-issue` toque codigo de la app. Trazabilidad humana en seccion `## Capability growth log` al final del `app.md` (una linea por bump). Ver `.claude/commands/version.md`.
### 5. Registro en `cpp/CMakeLists.txt` ### 5. Registro en `cpp/CMakeLists.txt`
@@ -385,11 +387,33 @@ generate_app_icon(
Mapping vive en el frontmatter de cada `app.md` C++: Mapping vive en el frontmatter de cada `app.md` C++:
```yaml ```yaml
description: "Frase corta de 1 linea — que hace la app y por que existe."
icon: icon:
phosphor: "chart-bar" phosphor: "chart-bar"
accent: "#0ea5e9" accent: "#0ea5e9"
``` ```
### Trio obligatorio: description + icon.phosphor + icon.accent
**REGLA DURA:** TODA app C++/imgui declara los **3 campos JUNTOS** en su `app.md`:
1. `description:` (string corta, 1 linea) — texto que el `app_hub_launcher` muestra en la tarjeta y que el dashboard usa para tooltips.
2. `icon.phosphor:` (nombre del glyph Phosphor sin sufijo `-fill`) — glyph del icono.
3. `icon.accent:` (hex `#rrggbb`) — color del fondo redondeado del icono **Y** color del boton/border de la tarjeta en `app_hub_launcher`.
Los 3 se consumen como un set unico: el icono visual + el texto + el color de marca de la app. Una app sin descripcion aparece como tarjeta gris sin texto; sin `icon:` cae al default (`app-window` slate); sin accent el boton del hub aparece blanco. **Documentar uno sin los otros es bug**, no estilo.
### Refrescar el App Hub tras editar el trio
`app_hub_launcher` cachea iconos (PNG) y manifest (TSV) al arrancar. Cambiar `description`/`icon.*` en un `app.md` requiere regenerar ambos sidecars + relanzar el hub. Pipeline canonico:
```bash
./fn run refresh_app_hub # icons + manifest + restart hub
./fn run refresh_app_hub --no-restart # solo regenera, util si el hub esta cerrado
./fn run refresh_app_hub --size 128 # PNGs 128px en vez de 64
```
ID: `refresh_app_hub_bash_pipelines`. Compone `export_hub_icons_py_infra` + `export_hub_manifest_py_infra` + `is_cpp_app_running_windows_bash_infra` + `launch_cpp_app_windows_bash_infra`.
Regeneracion batch via pipeline del registry — escanea `app.md`s y compone Regeneracion batch via pipeline del registry — escanea `app.md`s y compone
`generate_app_icon` por app. Anadir app nueva: declarar `icon:` en su `generate_app_icon` por app. Anadir app nueva: declarar `icon:` en su
`app.md` y lanzar: `app.md` y lanzar:
+23
View File
@@ -28,3 +28,26 @@ Documentar en el `app.md` del service:
- El puerto que usa (si expone HTTP/gRPC) - El puerto que usa (si expone HTTP/gRPC)
- Como lanzarlo y pararlo - Como lanzarlo y pararlo
- Como comprobar que esta vivo (health check) - Como comprobar que esta vivo (health check)
### Bloque `service:` obligatorio (issue 0105)
Toda app con `tag: service` declara el bloque `service:` en su frontmatter. El indexer lo persiste en columnas dedicadas de `apps` + tabla `service_targets`. Consumido por `services_api`/`services_monitor` (issue 0106) y por `fn doctor services-spec`.
```yaml
service:
port: 8484 # null si no expone HTTP (stdio, daemon sin API)
health_endpoint: /api/databases # ruta GET, 2xx/3xx = sano; null si no aplica
health_timeout_s: 3
systemd_unit: sqlite_api.service # obligatorio si runtime empieza con `systemd-`
systemd_scope: user # user|system|null (docker-compose)
restart_policy: always # always|on-failure|none
runtime: systemd-user # systemd-user|systemd-system|docker-compose|stdio|manual
pc_targets: # >=1, pc_id de pc_locations
- aurgi-pc
- home-wsl
is_local_only: false # true => no se monitoriza por SSH (siempre local)
```
Validacion: `fn doctor services-spec` (`functions/infra/audit_services_spec.go`). Hoy 11/11 services con bloque completo.
**Gotcha critico:** usar `Restart=always` (no `on-failure`) en el unit systemd. Un `SIGTERM` limpio es exit success → `on-failure` NO reinicia y el service se queda muerto silenciosamente. `sqlite_api.service` cayo 20h asi el 2026-05-17.
@@ -0,0 +1,121 @@
#!/bin/bash
# integrate-worktrees.sh — Integra branches de worktrees a master con --no-ff
#
# Uso: ./integrate-worktrees.sh <slug-1> <slug-2> ...
# Ejemplo: ./integrate-worktrees.sh 0026-split-runtime 0027-prune-config-schema
#
# Para cada slug:
# 1. git merge --no-ff issue/<slug> a master
# 2. Verificar que master compila después del merge
# 3. Si hay conflict o fallo de build, PARAR inmediatamente
#
# Los slugs deben pasarse en el orden correcto (waves ya resueltas).
# NO hace push — eso lo decide el usuario.
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
if [ $# -eq 0 ]; then
echo "ERROR: se necesita al menos un slug"
echo "Uso: $0 <slug-1> <slug-2> ..."
exit 1
fi
# Asegurar que estamos en master
echo "=== Cambiando a master ==="
cd "$REPO_ROOT"
git checkout master
MERGED=0
FAILED_AT=""
for slug in "$@"; do
branch="issue/${slug}"
echo ""
echo "=== Integrando: ${branch} ==="
# Verificar que la branch existe
if ! git show-ref --verify --quiet "refs/heads/${branch}"; then
echo "FAIL: branch ${branch} no existe"
FAILED_AT="$slug"
break
fi
# Merge --no-ff
if ! git merge --no-ff "$branch" -m "merge: ${branch} — implementación paralela"; then
echo ""
echo "CONFLICT: merge de ${branch} tiene conflictos"
echo "Resolver manualmente y luego continuar con los slugs restantes"
echo ""
echo "Para resolver:"
echo " 1. git status (ver archivos en conflicto)"
echo " 2. Resolver conflictos en cada archivo"
echo " 3. git add <archivos>"
echo " 4. git commit"
echo ""
echo "Slugs pendientes después de ${slug}:"
FOUND=0
for remaining in "$@"; do
if [ "$FOUND" -eq 1 ]; then
echo " - ${remaining}"
fi
if [ "$remaining" = "$slug" ]; then
FOUND=1
fi
done
exit 1
fi
echo "MERGED: ${branch}"
# Verificar que master sigue compilando (si BUILD_CMD esta definido)
if [ -n "${BUILD_CMD:-}" ]; then
echo "--- Verificando build post-merge ($BUILD_CMD) ---"
if ! (cd "$REPO_ROOT" && bash -c "$BUILD_CMD" 2>&1); then
echo ""
echo "FAIL: master no compila despues de mergear ${branch}"
echo "Revertir con: git reset --hard HEAD~1"
echo "Investigar el problema antes de continuar."
FAILED_AT="$slug"
break
fi
echo "OK: build post-merge exitoso"
else
echo "--- Build post-merge SKIPPED (BUILD_CMD no definido) ---"
fi
MERGED=$((MERGED + 1))
done
echo ""
echo "=== Resumen de integración ==="
echo "Mergeados: ${MERGED} de $#"
if [ -n "$FAILED_AT" ]; then
echo "Falló en: ${FAILED_AT}"
echo ""
echo "Worktrees NO limpiados (resolver primero el fallo)"
exit 1
fi
# Limpieza de worktrees y branches
echo ""
echo "=== Limpieza ==="
for slug in "$@"; do
path="${REPO_ROOT}/worktrees/${slug}"
branch="issue/${slug}"
if [ -d "$path" ]; then
git worktree remove "$path" 2>/dev/null && echo "REMOVED: worktree ${path}" || echo "WARN: no se pudo eliminar worktree ${path}"
fi
git branch -d "$branch" 2>/dev/null && echo "DELETED: branch ${branch}" || echo "WARN: no se pudo eliminar branch ${branch}"
done
echo ""
echo "=== Integración completa ==="
echo "Master tiene ${MERGED} merges nuevos."
echo ""
echo "Para publicar: git push"
@@ -0,0 +1,76 @@
#!/bin/bash
# setup-worktrees.sh — Crea git worktrees para ejecución paralela de issues
#
# Uso: ./setup-worktrees.sh <slug-1> <slug-2> ...
# Ejemplo: ./setup-worktrees.sh 0026-split-runtime 0027-prune-config-schema
#
# Cada slug genera:
# worktrees/<slug>/ (worktree completo)
# branch: issue/<slug>
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
WORKTREE_DIR="${REPO_ROOT}/worktrees"
if [ $# -eq 0 ]; then
echo "ERROR: se necesita al menos un slug de issue"
echo "Uso: $0 <slug-1> <slug-2> ..."
exit 1
fi
# Asegurar que master está actualizado
echo "=== Actualizando master ==="
CURRENT_BRANCH="$(git branch --show-current)"
git checkout master 2>/dev/null
git pull --rebase 2>/dev/null || echo "WARN: no se pudo pull (sin remote o sin conexión)"
# Volver a la rama original si no era master
if [ "$CURRENT_BRANCH" != "master" ] && [ -n "$CURRENT_BRANCH" ]; then
git checkout "$CURRENT_BRANCH" 2>/dev/null
fi
mkdir -p "$WORKTREE_DIR"
CREATED=0
SKIPPED=0
FAILED=0
for slug in "$@"; do
branch="issue/${slug}"
path="${WORKTREE_DIR}/${slug}"
if [ -d "$path" ]; then
echo "SKIP: worktree ya existe: ${path}"
SKIPPED=$((SKIPPED + 1))
continue
fi
# Verificar que la branch no existe ya
if git show-ref --verify --quiet "refs/heads/${branch}" 2>/dev/null; then
echo "WARN: branch ${branch} ya existe, creando worktree desde ella"
git worktree add "$path" "$branch" 2>/dev/null || {
echo "FAIL: no se pudo crear worktree para ${slug}"
FAILED=$((FAILED + 1))
continue
}
else
echo "CREATE: worktree ${path} (branch ${branch})"
git worktree add -b "$branch" "$path" master 2>/dev/null || {
echo "FAIL: no se pudo crear worktree para ${slug}"
FAILED=$((FAILED + 1))
continue
}
fi
CREATED=$((CREATED + 1))
done
echo ""
echo "=== Resumen ==="
echo "Creados: ${CREATED}"
echo "Existentes: ${SKIPPED}"
echo "Fallidos: ${FAILED}"
echo ""
echo "=== Worktrees activos ==="
git worktree list
@@ -0,0 +1,165 @@
#!/bin/bash
# verify-worktree.sh — Verifica build, tests y cierre de issue en un worktree.
#
# Uso:
# ./verify-worktree.sh <worktree-path> [build-cmd] [test-cmd]
#
# Ejemplos:
# ./verify-worktree.sh worktrees/0026-foo
# ./verify-worktree.sh worktrees/0026-foo "go build -tags fts5 ./..." "go test -tags fts5 ./..."
# BUILD_CMD="cmake --build cpp/build" TEST_CMD="ctest --test-dir cpp/build" ./verify-worktree.sh worktrees/0026-foo
#
# Resolucion de comandos (en orden de prioridad):
# 1. Argumentos posicionales (build-cmd, test-cmd)
# 2. Variables de entorno BUILD_CMD / TEST_CMD
# 3. Archivo .parallel-fix-issues.yml en la raiz del worktree (claves: build, test)
# 4. Auto-deteccion segun ficheros del proyecto:
# - go.mod → "go build ./..." + "go test ./..."
# - CMakeLists.txt → "cmake -S . -B build && cmake --build build" + "ctest --test-dir build"
# - Cargo.toml → "cargo build" + "cargo test"
# - package.json → "npm run build" + "npm test"
# - pyproject.toml → "" + "pytest"
# 5. Si nada se detecta, salta build/test con WARN.
#
# Auto-deteccion adicional: si hay go.mod, intenta extraer build tag de //go:build.
#
# Exit codes:
# 0 = todo OK
# 1 = error de argumento
# 2 = build fallo
# 3 = tests fallaron
# 4 = issue no cerrado (solo WARN, no falla)
# 5 = sin commits propios
set -euo pipefail
if [ $# -lt 1 ]; then
echo "ERROR: se necesita el path del worktree"
echo "Uso: $0 <worktree-path> [build-cmd] [test-cmd]"
exit 1
fi
WORKTREE="$1"
ARG_BUILD_CMD="${2:-}"
ARG_TEST_CMD="${3:-}"
# Resolver path absoluto
if [[ "$WORKTREE" != /* ]]; then
REPO_ROOT="$(git rev-parse --show-toplevel)"
WORKTREE="${REPO_ROOT}/${WORKTREE}"
fi
if [ ! -d "$WORKTREE" ]; then
echo "ERROR: worktree no encontrado: ${WORKTREE}"
exit 1
fi
SLUG="$(basename "$WORKTREE")"
echo "=== Verificando: ${SLUG} ==="
# --- Resolver build/test commands ---
BUILD_CMD="${ARG_BUILD_CMD:-${BUILD_CMD:-}}"
TEST_CMD="${ARG_TEST_CMD:-${TEST_CMD:-}}"
# Manifest opcional
MANIFEST="${WORKTREE}/.parallel-fix-issues.yml"
if [ -z "$BUILD_CMD" ] && [ -f "$MANIFEST" ]; then
M_BUILD=$(grep -E "^build:" "$MANIFEST" 2>/dev/null | sed -E 's/^build:[[:space:]]*"?([^"]*)"?[[:space:]]*$/\1/' | head -1 || true)
if [ -n "$M_BUILD" ]; then BUILD_CMD="$M_BUILD"; echo "INFO: build desde manifest"; fi
fi
if [ -z "$TEST_CMD" ] && [ -f "$MANIFEST" ]; then
M_TEST=$(grep -E "^test:" "$MANIFEST" 2>/dev/null | sed -E 's/^test:[[:space:]]*"?([^"]*)"?[[:space:]]*$/\1/' | head -1 || true)
if [ -n "$M_TEST" ]; then TEST_CMD="$M_TEST"; echo "INFO: test desde manifest"; fi
fi
# Auto-deteccion
if [ -z "$BUILD_CMD" ] || [ -z "$TEST_CMD" ]; then
AUTO_BUILD=""
AUTO_TEST=""
if [ -f "${WORKTREE}/go.mod" ]; then
# Detectar build tag
AUTO_TAG=$(grep -rh "^//go:build " --include="*.go" "$WORKTREE" 2>/dev/null \
| sed -E 's|^//go:build ([a-zA-Z0-9_]+).*|\1|' \
| sort -u | head -1 || true)
TAG_FLAG=""
[ -n "$AUTO_TAG" ] && TAG_FLAG="-tags $AUTO_TAG"
AUTO_BUILD="go build $TAG_FLAG ./..."
AUTO_TEST="go test $TAG_FLAG ./..."
echo "INFO: stack detectado: Go${TAG_FLAG:+ ($TAG_FLAG)}"
elif [ -f "${WORKTREE}/CMakeLists.txt" ] || ls "${WORKTREE}"/cpp/CMakeLists.txt >/dev/null 2>&1; then
CMAKE_DIR="."
[ -f "${WORKTREE}/cpp/CMakeLists.txt" ] && [ ! -f "${WORKTREE}/CMakeLists.txt" ] && CMAKE_DIR="cpp"
AUTO_BUILD="cmake -S ${CMAKE_DIR} -B ${CMAKE_DIR}/build -DCMAKE_BUILD_TYPE=Release && cmake --build ${CMAKE_DIR}/build -j"
AUTO_TEST="ctest --test-dir ${CMAKE_DIR}/build --output-on-failure || true"
echo "INFO: stack detectado: C++/CMake (dir=${CMAKE_DIR})"
elif [ -f "${WORKTREE}/Cargo.toml" ]; then
AUTO_BUILD="cargo build"
AUTO_TEST="cargo test"
echo "INFO: stack detectado: Rust"
elif [ -f "${WORKTREE}/package.json" ]; then
AUTO_BUILD="npm run build --if-present"
AUTO_TEST="npm test --if-present"
echo "INFO: stack detectado: Node"
elif [ -f "${WORKTREE}/pyproject.toml" ] || [ -f "${WORKTREE}/setup.py" ]; then
AUTO_BUILD="" # python normalmente no tiene build step
AUTO_TEST="pytest"
echo "INFO: stack detectado: Python"
else
echo "WARN: no se detecto stack; usar BUILD_CMD/TEST_CMD env o manifest .parallel-fix-issues.yml"
fi
[ -z "$BUILD_CMD" ] && BUILD_CMD="$AUTO_BUILD"
[ -z "$TEST_CMD" ] && TEST_CMD="$AUTO_TEST"
fi
# 1. Verificar commits propios
echo ""
echo "--- Commits propios ---"
COMMIT_COUNT=$(cd "$WORKTREE" && git log master..HEAD --oneline 2>/dev/null | wc -l)
if [ "$COMMIT_COUNT" -eq 0 ]; then
echo "FAIL: sin commits propios en la branch"
exit 5
fi
echo "OK: ${COMMIT_COUNT} commits desde master"
cd "$WORKTREE" && git log master..HEAD --oneline
# 2. Build
echo ""
if [ -n "$BUILD_CMD" ]; then
echo "--- Build ($BUILD_CMD) ---"
if (cd "$WORKTREE" && bash -c "$BUILD_CMD" 2>&1); then
echo "OK: build exitoso"
else
echo "FAIL: build fallo"
exit 2
fi
else
echo "--- Build SKIPPED (sin comando) ---"
fi
# 3. Tests
echo ""
if [ -n "$TEST_CMD" ]; then
echo "--- Tests ($TEST_CMD) ---"
if (cd "$WORKTREE" && bash -c "$TEST_CMD" 2>&1); then
echo "OK: tests pasaron"
else
echo "FAIL: tests fallaron"
exit 3
fi
else
echo "--- Tests SKIPPED (sin comando) ---"
fi
# 4. Issue cerrado
echo ""
echo "--- Cierre de issue ---"
COMPLETED_FILES=$(cd "$WORKTREE" && git diff --name-only master -- dev/issues/completed/ 2>/dev/null | wc -l)
if [ "$COMPLETED_FILES" -gt 0 ]; then
echo "OK: issue movido a completed/"
cd "$WORKTREE" && git diff --name-only master -- dev/issues/completed/
else
echo "WARN: no se detecto issue movido a completed/ (verificar manualmente)"
fi
echo ""
echo "=== RESULTADO: ${SLUG} — OK ==="
+22
View File
@@ -8,6 +8,28 @@ Para contexto detallado del trabajo diario ver `docs/diary/`. Para decisiones ar
## [Unreleased] ## [Unreleased]
## 2026-05-17
### Added
- **Bloque `service:` en frontmatter de `app.md`** (issue 0105) — toda app con `tag: service` declara ahora `port`, `health_endpoint`, `health_timeout_s`, `systemd_unit`, `systemd_scope`, `restart_policy`, `runtime` (`systemd-user|systemd-system|docker-compose|stdio|manual`), `pc_targets[]`, `is_local_only`. 11 apps actualizadas: `sqlite_api`, `dag_engine`, `call_monitor`, `kanban`, `deploy_server`, `registry_mcp`, `registry_api`, `footprint_geo_stack`, `element_matrix_chat`, `agents_and_robots`, `services_api`.
- **Migration `014_service_metadata.sql`** — anade 8 columnas (`service_port`, `service_health_endpoint`, `service_health_timeout_s`, `service_systemd_unit`, `service_systemd_scope`, `service_restart_policy`, `service_runtime`, `service_is_local_only`) a `apps` + tabla nueva `service_targets (app_id, pc_id, role)` con indices por `app_id` y `pc_id`.
- **`registry.App.Service *ServiceSpec`** + parser `rawService` + escritura/lectura en `InsertApp`/`scanApps`/`Purge` (preserva `service_targets`). API publica `db.GetServicePCTargets(appID) []string`.
- **`audit_services_spec_go_infra`** (`functions/infra/audit_services_spec.{go,md}`) — audita apps `tag: service` y reporta drift del bloque `service:` (runtime allowlist, pc_targets >=1, systemd_unit obligatorio si `runtime` empieza con `systemd-`, restart_policy en `always|on-failure|none`).
- **`fn doctor services-spec`** — subcomando nuevo en `cmd/fn/doctor.go`. Salida tabwriter + `--json`. Hoy: `11/11 services with complete service: block`.
- **App `services_api`** (`apps/services_api/`, issue 0106) — Go HTTP daemon en `127.0.0.1:8485`. Loop paralelo cada 15s (max 8 in-flight, timeout 20s/probe) que reconcilia esperado vs real para cada `(app, pc)` cruzado de `service_targets`. Probes locales (`systemctl is-active` + TCP dial + `http.Client`) o remotos (`ssh_exec_go_infra`). Persiste en `operations.db`: `service_state` (snapshot actual) + `service_transition` (cambios de overall append-only). Endpoints `GET /api/health`, `GET /api/services`, `POST /api/check`, `GET /api/pcs`. systemd unit `~/.config/systemd/user/services_api.service` con `Restart=always`.
- **App `services_monitor`** (`apps/services_monitor/`, issue 0106) — frontend C++ ImGui. Polling auto cada 5s configurable + boton "Force check" (POST `/api/check`). Tabla 9-col agrupada por app: overall pill, systemd state, port + listening flag (`TI_PLUG`/`TI_PLUG_CONNECTED`), HTTP status+latency, runtime, last change age, error/note. JSON via `vendor/nlohmann/json.hpp` (copiado de data_factory). HTTP socket TCP via `http_client.{cpp,h}` (copiado de data_factory). Build linux + windows con `add_imgui_app` + ws2_32 en Win. Deploy automatico via `redeploy_cpp_app_windows`.
- **Issues 0105 + 0106** (`dev/issues/`) — estandarizacion del bloque `service:` y app `services_monitor`.
### Fixed
- **`sqlite_api.service` murio 20h sin alerta el 2026-05-17** — Raiz: el unit tenia `Restart=on-failure` y el ultimo exit fue por `SIGTERM` (limpio, no failure). systemd NO reinicia exit success. Fix: cambio a `Restart=always` + `RestartSec=5`. Reload + restart inmediato. Detectado mientras se debuggeaba `data_factory` cargando lento (raiz: data_factory llama a `sqlite_api:8484`, timeout 3s, no responde). Aplicado el mismo `Restart=always` al unit nuevo `services_api.service`.
- **`sqlite_api/app.md` health_endpoint** — declaraba `/api/status` que devuelve 404. Cambiado a `/api/databases` (200, lista de bases registradas). Detectado por el primer ciclo del propio `services_api` que marcaba sqlite_api como `degraded`.
### Changed
- **`services_monitor` tags** — sin `service`/`services` en `tags` para evitar falso positivo en el matcher `tags LIKE '%service%'` del audit `services-spec`. La app es desktop client (frontend), no daemon.
## 2026-05-16 ## 2026-05-16
### Added ### Added
@@ -0,0 +1,83 @@
---
name: refresh_app_hub
kind: pipeline
lang: bash
domain: pipelines
version: "1.1.0"
purity: impure
signature: "refresh_app_hub([--hub-dir <path>] [--size <px>] [--no-restart] [--style <s>]) -> void"
description: "Pipeline orquestador que regenera los iconos PNG y el manifest TSV del App Hub desde registry.db y reinicia el proceso app_hub_launcher en Windows. Cubre el ciclo completo: export icons → export manifest → taskkill → relaunch. Default style=white_duotone (duotone Phosphor blanco sobre bg accent). Override con --style fill_white | adaptive_duotone."
tags: [hub, launcher, icons, manifest, cpp, windows, wsl, cpp-windows, deploy]
uses_functions:
- export_hub_icons_py_infra
- export_hub_manifest_py_infra
- is_cpp_app_running_windows_bash_infra
- launch_cpp_app_windows_bash_infra
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/pipelines/refresh_app_hub.sh"
params:
- name: "--hub-dir"
desc: "Directorio local_files del hub en Windows (accesible desde WSL). Default: /mnt/c/Users/lucas/Desktop/apps/app_hub_launcher/local_files. Los PNGs se escriben en <hub-dir>/icons/ y el manifest en <hub-dir>/hub_manifest.tsv."
- name: "--size"
desc: "Lado en pixels de cada PNG de icono. Default 64. Pasar 128 para pantallas HiDPI."
- name: "--no-restart"
desc: "Si está presente, solo regenera icons + manifest sin tocar el proceso del hub (pasos 3 y 4 se marcan como skipped). Útil para actualizar los archivos mientras el hub está cerrado manualmente."
output: "Imprime resumen de 4 pasos (icons exported, manifest rows, hub killed/skipped, hub launched) y finaliza con 'OK: app_hub refreshed'. Exit 1 si falla cualquier paso, con mensaje indicando cuál."
---
## Ejemplo
```bash
# Caso habitual: regenerar todo y reiniciar el hub
./fn run refresh_app_hub
# Solo regenerar iconos y manifest, sin tocar el proceso
./fn run refresh_app_hub --no-restart
# Iconos a 128px para pantalla HiDPI o hub-dir personalizado
./fn run refresh_app_hub --size 128
./fn run refresh_app_hub --hub-dir /mnt/d/MiDesktop/apps/app_hub_launcher/local_files
# Combinado
./fn run refresh_app_hub --size 128 --hub-dir /mnt/d/MiDesktop/apps/app_hub_launcher/local_files
```
Salida esperada:
```
[1/4] Exporting PNG icons (size=64px) → /mnt/c/Users/lucas/Desktop/apps/app_hub_launcher/local_files/icons ...
[1/4] PNG icons exported: 12
[2/4] Exporting manifest → .../hub_manifest.tsv ...
[2/4] Manifest exported: 12 rows
[3/4] Hub running → killing app_hub_launcher.exe ...
[3/4] Hub running → killed
[4/4] Launching app_hub_launcher ...
[4/4] Hub launched
OK: app_hub refreshed
```
## Cuando usarla
Ejecutar este pipeline después de:
- Cambiar el bloque `icon:` (`phosphor`, `accent`) en cualquier `app.md` C++.
- Modificar la `description` de una app C++ (se refleja en el manifest TSV).
- Añadir una app nueva con `lang: cpp` y `framework: imgui` al registry.
- Cambiar el `name` de una app imgui (el PNG y la fila TSV usan el `name`).
En todos esos casos el hub cachea los archivos al arrancar, así que el reinicio es obligatorio para ver los cambios.
## Gotchas
- **Cache al arrancar**: `app_hub_launcher` carga `local_files/icons/*.png` y `local_files/hub_manifest.tsv` una sola vez al inicio. Sin el reinicio los cambios no se ven aunque los archivos estén actualizados. Usar `--no-restart` solo si el hub está cerrado o si quieres preparar los archivos antes de lanzarlo a mano.
- **Paths Windows requieren WSL `/mnt/c/`**: el `--hub-dir` debe ser una ruta accesible desde WSL2. Las rutas `C:\...` nativas no funcionan en Bash — convertirlas con `wslpath -u 'C:\...'` antes de pasar el flag.
- **`taskkill` solo funciona en WSL con acceso a Windows tools**: si `tasklist.exe` y `taskkill.exe` no están en `$PATH` (instalación WSL sin interop habilitado), el paso 3 fallará. Verificar con `command -v tasklist.exe`.
- **`powershell.exe` necesario para el lanzamiento**: `launch_cpp_app_windows` usa `Start-Process` de PowerShell. Si PowerShell no está en `$PATH`, el paso 4 fallará.
- **`export_hub_icons` requiere Phosphor SVGs**: los SVGs deben existir en `sources/phosphor-core/assets/fill/`. Si no están clonados, las apps sin SVG se omiten (skip) sin abortar; el count final puede ser menor al esperado. Clonar con: `git clone https://github.com/phosphor-icons/core.git sources/phosphor-core`.
- **Idempotente**: lanzable N veces. Si el hub no está corriendo, el paso 3 se salta y el paso 4 lo lanza igualmente. Si ya está corriendo, lo mata y lo relanza.
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# refresh_app_hub — Pipeline: regenera icons + manifest del App Hub y reinicia el proceso
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
source "$SCRIPT_DIR/../infra/is_cpp_app_running_windows.sh"
source "$SCRIPT_DIR/../infra/launch_cpp_app_windows.sh"
PYTHON="${REGISTRY_ROOT}/python/.venv/bin/python3"
HUB_APP="app_hub_launcher"
refresh_app_hub() {
local hub_dir="/mnt/c/Users/lucas/Desktop/apps/app_hub_launcher/local_files"
local size=64
local no_restart=0
local style="white_duotone"
# Parsear flags
while [[ $# -gt 0 ]]; do
case "$1" in
--hub-dir)
hub_dir="$2"
shift 2
;;
--size)
size="$2"
shift 2
;;
--no-restart)
no_restart=1
shift
;;
--style)
style="$2"
shift 2
;;
-*)
echo "refresh_app_hub: flag desconocido: $1" >&2
return 1
;;
*)
echo "refresh_app_hub: argumento inesperado: $1" >&2
return 1
;;
esac
done
local icons_dir="${hub_dir}/icons"
local manifest_path="${hub_dir}/hub_manifest.tsv"
# Paso 1: exportar PNGs de iconos
echo "[1/4] Exporting PNG icons (size=${size}px) → ${icons_dir} ..."
local icons_json
icons_json=$(
PYTHONPATH="${REGISTRY_ROOT}/python/functions" \
FN_REGISTRY_ROOT="${REGISTRY_ROOT}" \
"$PYTHON" \
"${REGISTRY_ROOT}/python/functions/infra/export_hub_icons.py" \
"$icons_dir" \
--size "$size" \
--registry-root "$REGISTRY_ROOT" \
--style "$style"
) || {
echo "ERROR [1/4]: export_hub_icons falló" >&2
return 1
}
local icon_count
icon_count=$(echo "$icons_json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['count'])" 2>/dev/null || echo "?")
echo "[1/4] PNG icons exported: ${icon_count}"
# Paso 2: exportar TSV manifest
echo "[2/4] Exporting manifest → ${manifest_path} ..."
local manifest_json
manifest_json=$(
PYTHONPATH="${REGISTRY_ROOT}/python/functions" \
FN_REGISTRY_ROOT="${REGISTRY_ROOT}" \
"$PYTHON" \
"${REGISTRY_ROOT}/python/functions/infra/export_hub_manifest.py" \
"$manifest_path" \
--registry-root "$REGISTRY_ROOT"
) || {
echo "ERROR [2/4]: export_hub_manifest falló" >&2
return 1
}
local manifest_count
manifest_count=$(echo "$manifest_json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['count'])" 2>/dev/null || echo "?")
echo "[2/4] Manifest exported: ${manifest_count} rows"
# Si --no-restart, terminar aqui
if [[ $no_restart -eq 1 ]]; then
echo "[3/4] Kill skipped (--no-restart)"
echo "[4/4] Launch skipped (--no-restart)"
echo "OK: app_hub refreshed (no-restart)"
return 0
fi
# Paso 3: matar el hub si está corriendo
local running=0
if is_cpp_app_running_windows "$HUB_APP" >/dev/null 2>&1; then
running=1
fi
if [[ $running -eq 1 ]]; then
echo "[3/4] Hub running → killing ${HUB_APP}.exe ..."
taskkill.exe /IM "${HUB_APP}.exe" /F >/dev/null 2>&1 || {
echo "ERROR [3/4]: taskkill falló para ${HUB_APP}.exe" >&2
return 1
}
# Pequeña pausa para que Windows libere el handle antes del relanzamiento
sleep 1
echo "[3/4] Hub running → killed"
else
echo "[3/4] Hub not running → skip kill"
fi
# Paso 4: relanzar el hub
echo "[4/4] Launching ${HUB_APP} ..."
if ! launch_cpp_app_windows "$HUB_APP"; then
echo "ERROR [4/4]: launch_cpp_app_windows falló para '${HUB_APP}'" >&2
return 1
fi
echo "[4/4] Hub launched"
echo "OK: app_hub refreshed"
}
# Ejecutar si se llama directamente (fn run lo invoca como script)
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
refresh_app_hub "$@"
fi
+55
View File
@@ -39,6 +39,8 @@ func cmdDoctor(args []string) {
doctorArtefacts(r, jsonOut) doctorArtefacts(r, jsonOut)
case "services": case "services":
doctorServices(r, jsonOut) doctorServices(r, jsonOut)
case "services-spec":
doctorServicesSpec(r, jsonOut)
case "sync": case "sync":
doctorSync(r, jsonOut) doctorSync(r, jsonOut)
case "uses-functions": case "uses-functions":
@@ -80,6 +82,7 @@ Subcommands:
(none)|all Corre todos los checks (none)|all Corre todos los checks
artefacts Salud de apps y analyses (git, venv, app.md, upstream) artefacts Salud de apps y analyses (git, venv, app.md, upstream)
services Estado de apps con tag 'service' (systemd + puerto) services Estado de apps con tag 'service' (systemd + puerto)
services-spec Audit del bloque service: en app.md de apps tag 'service' (issue 0105)
sync Drift entre pc_locations BD y disco sync Drift entre pc_locations BD y disco
uses-functions Audit imports reales vs uses_functions del app.md uses-functions Audit imports reales vs uses_functions del app.md
unused Funciones del registry sin consumidores unused Funciones del registry sin consumidores
@@ -291,6 +294,58 @@ func doctorServices(root string, jsonOut bool) {
w.Flush() w.Flush()
} }
func doctorServicesSpec(root string, jsonOut bool) {
audits, err := infra.AuditServicesSpec(root)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if jsonOut {
emit(audits)
return
}
if len(audits) == 0 {
fmt.Println("No services declared (no apps with tag 'service').")
return
}
bad := 0
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "STATUS\tAPP\tRUNTIME\tPORT\tHEALTH\tUNIT\tTARGETS\tISSUES")
for _, a := range audits {
status := "OK"
issues := "-"
if !a.OK {
status = "FAIL"
issues = strings.Join(a.Issues, "; ")
bad++
}
port := "-"
if a.Port > 0 {
port = fmt.Sprintf("%d", a.Port)
}
health := a.HealthPath
if health == "" {
health = "-"
}
unit := a.SystemdUnit
if unit == "" {
unit = "-"
}
targets := strings.Join(a.PCTargets, ",")
if targets == "" {
targets = "-"
}
runtime := a.Runtime
if runtime == "" {
runtime = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
status, a.Name, runtime, port, health, unit, targets, issues)
}
w.Flush()
fmt.Printf("\n%d/%d services with complete service: block.\n", len(audits)-bad, len(audits))
}
func doctorSync(root string, jsonOut bool) { func doctorSync(root string, jsonOut bool) {
drifts, err := infra.PcLocationsDrift(root, "") drifts, err := infra.PcLocationsDrift(root, "")
if err != nil { if err != nil {
+40 -4
View File
@@ -381,10 +381,16 @@ if(EXISTS ${_PG_DIR}/CMakeLists.txt)
add_subdirectory(${_PG_DIR} ${CMAKE_BINARY_DIR}/apps/primitives_gallery) add_subdirectory(${_PG_DIR} ${CMAKE_BINARY_DIR}/apps/primitives_gallery)
endif() endif()
# --- Tables playground (vive dentro de primitives_gallery/playground/tables/) --- # --- Tables playground DEPRECATED (issue 0108) ---
if(EXISTS ${_PG_DIR}/playground/tables/CMakeLists.txt) # Sustituido por apps/tables_qa. El playground legacy queda solo como historia
add_subdirectory(${_PG_DIR}/playground/tables ${CMAKE_BINARY_DIR}/apps/primitives_gallery/playground/tables) # del split data_table 0107c. NO se builda mas — su self_test (430 checks
endif() # contra logica legacy) ya esta cubierto por:
# - cpp/tests/ (Catch2 unit tests de la logica pura del registry)
# - apps/tables_qa/ (testbed del modulo data_table v2.0.0+)
# Para revivirlo (temporal, debugging): descomentar el bloque if(EXISTS ...).
# if(EXISTS ${_PG_DIR}/playground/tables/CMakeLists.txt)
# add_subdirectory(${_PG_DIR}/playground/tables ${CMAKE_BINARY_DIR}/apps/primitives_gallery/playground/tables)
# endif()
# --- text_editor + file_watcher smoke test (lives in apps/) --- # --- text_editor + file_watcher smoke test (lives in apps/) ---
if(NOT DEFINED _TES_DIR) if(NOT DEFINED _TES_DIR)
@@ -493,3 +499,33 @@ set(_APP_HUB_LAUNCHER_DIR ${CMAKE_SOURCE_DIR}/../apps/app_hub_launcher)
if(EXISTS ${_APP_HUB_LAUNCHER_DIR}/CMakeLists.txt) if(EXISTS ${_APP_HUB_LAUNCHER_DIR}/CMakeLists.txt)
add_subdirectory(${_APP_HUB_LAUNCHER_DIR} ${CMAKE_BINARY_DIR}/apps/app_hub_launcher) add_subdirectory(${_APP_HUB_LAUNCHER_DIR} ${CMAKE_BINARY_DIR}/apps/app_hub_launcher)
endif() endif()
# --- services_monitor (lives in apps/, issue 0096) ---
set(_SERVICES_MONITOR_DIR ${CMAKE_SOURCE_DIR}/../apps/services_monitor)
if(EXISTS ${_SERVICES_MONITOR_DIR}/CMakeLists.txt)
add_subdirectory(${_SERVICES_MONITOR_DIR} ${CMAKE_BINARY_DIR}/apps/services_monitor)
endif()
# --- app_gestion (lives in apps/, issue 0096) ---
set(_APP_GESTION_DIR ${CMAKE_SOURCE_DIR}/../apps/app_gestion)
if(EXISTS ${_APP_GESTION_DIR}/CMakeLists.txt)
add_subdirectory(${_APP_GESTION_DIR} ${CMAKE_BINARY_DIR}/apps/app_gestion)
endif()
# --- skill_tree (lives in apps/, issue 0096) ---
set(_SKILL_TREE_DIR ${CMAKE_SOURCE_DIR}/../apps/skill_tree)
if(EXISTS ${_SKILL_TREE_DIR}/CMakeLists.txt)
add_subdirectory(${_SKILL_TREE_DIR} ${CMAKE_BINARY_DIR}/apps/skill_tree)
endif()
# --- tables_qa (lives in apps/, issue 0096) ---
set(_TABLES_QA_DIR ${CMAKE_SOURCE_DIR}/../apps/tables_qa)
if(EXISTS ${_TABLES_QA_DIR}/CMakeLists.txt)
add_subdirectory(${_TABLES_QA_DIR} ${CMAKE_BINARY_DIR}/apps/tables_qa)
endif()
# --- process_explorer (lives in apps/, issue 0096) ---
set(_PROCESS_EXPLORER_DIR ${CMAKE_SOURCE_DIR}/../apps/process_explorer)
if(EXISTS ${_PROCESS_EXPLORER_DIR}/CMakeLists.txt)
add_subdirectory(${_PROCESS_EXPLORER_DIR} ${CMAKE_BINARY_DIR}/apps/process_explorer)
endif()
+293
View File
@@ -2,6 +2,7 @@
#include "version_generated.h" #include "version_generated.h"
#include "imgui.h" #include "imgui.h"
#include "imgui_internal.h"
#include "imgui_impl_glfw.h" #include "imgui_impl_glfw.h"
#include "imgui_impl_opengl3.h" #include "imgui_impl_opengl3.h"
#include "implot.h" #include "implot.h"
@@ -16,9 +17,11 @@
#include "core/log_window.h" #include "core/log_window.h"
#include "core/layout_storage.h" #include "core/layout_storage.h"
#include "gfx/gl_loader.h" #include "gfx/gl_loader.h"
#include "app_modules.h"
#include <GLFW/glfw3.h> #include <GLFW/glfw3.h>
#include <atomic> #include <atomic>
#include <cmath>
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
#include <filesystem> #include <filesystem>
@@ -26,6 +29,7 @@
#include <sys/stat.h> #include <sys/stat.h>
#include <unordered_map> #include <unordered_map>
#include <unordered_set> #include <unordered_set>
#include <vector>
#ifdef _WIN32 #ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN #ifndef WIN32_LEAN_AND_MEAN
@@ -224,6 +228,43 @@ static void prune_dead_icon_attached() {
} }
} }
// Pinta la title bar (caption + bordes) en oscuro via DWM, para que el
// header del SO no se quede blanco mientras el cliente es dark. DWM la pinta
// el OS, no GLFW/ImGui — sin esta llamada queda en blanco aunque el resto
// este oscuro.
//
// Carga dwmapi.dll dinamicamente: evita anadir la dep al toolchain. Si la
// DLL/atributo no existe (Win10 < 1809), no hace nada — silencioso.
// Attr 20 = DWMWA_USE_IMMERSIVE_DARK_MODE (Win11 / Win10 >= build 18985).
// Attr 19 = nombre antiguo (Win10 1809..18984). Probar 20 primero, fallback 19.
// Force repaint con SWP_FRAMECHANGED — la ventana ya fue mostrada por GLFW
// antes de que lleguemos aqui.
typedef HRESULT (WINAPI *PFN_DwmSetWindowAttribute)(HWND, DWORD, LPCVOID, DWORD);
static std::unordered_set<HWND> g_dark_titlebar_applied;
static void attach_dark_titlebar_to_hwnd(HWND hwnd, bool dark) {
if (!hwnd) return;
if (g_dark_titlebar_applied.count(hwnd)) return; // idempotent
static HMODULE h_dwmapi = LoadLibraryW(L"dwmapi.dll");
if (!h_dwmapi) return;
static auto p_set = (PFN_DwmSetWindowAttribute)GetProcAddress(h_dwmapi, "DwmSetWindowAttribute");
if (!p_set) return;
BOOL value = dark ? TRUE : FALSE;
HRESULT hr = p_set(hwnd, 20 /* DWMWA_USE_IMMERSIVE_DARK_MODE */, &value, sizeof(value));
if (FAILED(hr)) {
p_set(hwnd, 19 /* legacy name on Win10 1809..18984 */, &value, sizeof(value));
}
SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
g_dark_titlebar_applied.insert(hwnd);
}
static void prune_dead_dark_titlebar() {
for (auto it = g_dark_titlebar_applied.begin(); it != g_dark_titlebar_applied.end();) {
if (!IsWindow(*it)) it = g_dark_titlebar_applied.erase(it);
else ++it;
}
}
static void install_sizemove_subclass(GLFWwindow* w) { static void install_sizemove_subclass(GLFWwindow* w) {
if (!w) return; if (!w) return;
install_sizemove_subclass_hwnd(glfwGetWin32Window(w)); install_sizemove_subclass_hwnd(glfwGetWin32Window(w));
@@ -391,6 +432,221 @@ const char* framework_description() {
return FN_MODULE_FRAMEWORK_DESCRIPTION; return FN_MODULE_FRAMEWORK_DESCRIPTION;
} }
// ----------------------------------------------------------------------------
// Header badge overlay — identidad por app en viewports secundarios.
// ----------------------------------------------------------------------------
// Cuando una app C++ tiene N panels y el usuario arrastra varios fuera del
// main window, cada panel se convierte en su propio OS viewport. Sin marcas
// visuales adicionales, si tienes 3 apps abiertas a la vez no sabes de cual
// viene cada panel flotante. Este overlay dibuja un cuadrado redondeado de
// ~18px con la inicial de la app en la esquina top-left de la title bar de
// cada viewport secundario. Solo en secundarios — el main ya tiene icono
// del SO en titlebar/taskbar (attach_app_icon_to_hwnd).
//
// Filosofia:
// - Defaults producen identidad util sin tocar la app (color hash-derivado
// desde about.name, glyph = primera letra).
// - Apps con icon.accent en su app.md pueden pasar el mismo hex para
// coherencia con App Hub.
// - ForegroundDrawList: dibuja por encima del titlebar de ImGui sin
// necesidad de envolver Begin() ni hookear el render del titulo.
// ----------------------------------------------------------------------------
// Parsea "#RRGGBB" o "RRGGBB" a ImU32 ABGR. Devuelve 0 si invalido.
static ImU32 parse_hex_color_abgr(const char* s) {
if (!s || !*s) return 0;
if (*s == '#') ++s;
auto h = [](char c) -> int {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
};
int v[6];
for (int i = 0; i < 6; ++i) {
v[i] = h(s[i]);
if (v[i] < 0) return 0;
}
int r = (v[0] << 4) | v[1];
int g = (v[2] << 4) | v[3];
int b = (v[4] << 4) | v[5];
return IM_COL32(r, g, b, 255);
}
// Hash-derivado: FNV-1a 32-bit -> H en [0,360), S=0.58, V=0.78 -> ImU32 ABGR.
// Estable por nombre, distribuye razonablemente entre N apps.
static ImU32 derive_color_from_name(const char* name) {
if (!name || !*name) name = "fn_registry";
unsigned h = 2166136261u;
for (const char* p = name; *p; ++p) {
h ^= (unsigned char)*p;
h *= 16777619u;
}
float hue = (float)(h % 360u);
float s = 0.58f, v = 0.78f;
float c = v * s;
float hp = hue / 60.0f;
float x = c * (1.0f - std::fabs(std::fmod(hp, 2.0f) - 1.0f));
float r=0, g=0, b=0;
if (hp < 1) { r=c; g=x; }
else if (hp < 2) { r=x; g=c; }
else if (hp < 3) { g=c; b=x; }
else if (hp < 4) { g=x; b=c; }
else if (hp < 5) { r=x; b=c; }
else { r=c; b=x; }
float m = v - c;
int R = (int)((r + m) * 255.0f + 0.5f);
int G = (int)((g + m) * 255.0f + 0.5f);
int B = (int)((b + m) * 255.0f + 0.5f);
return IM_COL32(R, G, B, 255);
}
// Decide string a renderizar como glyph. Devuelve puntero a buffer estatico
// thread-local cuando hace falta normalizar la primera letra del nombre.
static const char* resolve_badge_glyph(const AppConfig& cfg) {
const char* g = cfg.header_badge.glyph;
if (g && *g) return g;
static thread_local char letter[8] = {0};
const char* nm = (cfg.about.name && *cfg.about.name) ? cfg.about.name : cfg.title;
char first = (nm && *nm) ? nm[0] : '?';
if (first >= 'a' && first <= 'z') first = (char)(first - 'a' + 'A');
letter[0] = first;
letter[1] = '\0';
return letter;
}
// Color final con precedencia:
// 1) Override explicito en cfg.header_badge.accent_hex (main.cpp)
// 2) Codegen extern fn::app_header_accent_hex (icon.accent del app.md)
// 3) Hash-derived desde about.name (siempre estable, da identidad gratis)
static ImU32 resolve_badge_color(const AppConfig& cfg) {
ImU32 c = parse_hex_color_abgr(cfg.header_badge.accent_hex);
if (c != 0) return c;
c = parse_hex_color_abgr(app_header_accent_hex);
if (c != 0) return c;
const char* nm = (cfg.about.name && *cfg.about.name) ? cfg.about.name : cfg.title;
return derive_color_from_name(nm);
}
// Textura GL del icono de la app — extraida del HICON embebido en el .exe
// (resource ID 101 generado por add_imgui_app desde appicon.ico). Cargada
// perezosamente al primer frame y reutilizada en cada draw. 0 = no disponible.
static GLuint g_app_icon_texture = 0;
static int g_app_icon_size = 0;
#ifdef _WIN32
// Carga el icono embebido a 32x32 y sube como textura GL RGBA8. Linear
// filtering para que escalar 32->18 sea suave. Devuelve 0 si falla.
static GLuint upload_hicon_to_gl_texture() {
const int sz = 32;
HICON hicon = (HICON)LoadImageW(GetModuleHandleW(nullptr),
MAKEINTRESOURCEW(FN_APP_ICON_RES_ID),
IMAGE_ICON, sz, sz,
LR_DEFAULTCOLOR);
if (!hicon) return 0;
ICONINFO ii{};
if (!GetIconInfo(hicon, &ii)) { DestroyIcon(hicon); return 0; }
HDC hdc = CreateCompatibleDC(nullptr);
BITMAPINFO bmi{};
bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
bmi.bmiHeader.biWidth = sz;
bmi.bmiHeader.biHeight = -sz; // top-down
bmi.bmiHeader.biPlanes = 1;
bmi.bmiHeader.biBitCount = 32;
bmi.bmiHeader.biCompression = BI_RGB;
std::vector<unsigned char> pixels(sz * sz * 4, 0);
int ok = GetDIBits(hdc, ii.hbmColor, 0, sz, pixels.data(), &bmi, DIB_RGB_COLORS);
DeleteDC(hdc);
if (ii.hbmColor) DeleteObject(ii.hbmColor);
if (ii.hbmMask) DeleteObject(ii.hbmMask);
DestroyIcon(hicon);
if (ok == 0) return 0;
// BGRA -> RGBA (Windows DIB es BGRA).
for (int i = 0; i < sz * sz; ++i) {
unsigned char b = pixels[i*4 + 0];
unsigned char r = pixels[i*4 + 2];
pixels[i*4 + 0] = r;
pixels[i*4 + 2] = b;
}
GLuint tex = 0;
glGenTextures(1, &tex);
glBindTexture(GL_TEXTURE_2D, tex);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, sz, sz, 0, GL_RGBA, GL_UNSIGNED_BYTE,
pixels.data());
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glBindTexture(GL_TEXTURE_2D, 0);
return tex;
}
#endif
// Itera todas las ventanas ImGui y pinta el badge en la title bar de las que
// viven en un viewport secundario (panel arrastrado fuera del main window).
// Usa imgui_internal.h para acceder a g.Windows y a TitleBarRect(), y dibuja
// directamente en la DrawList propia de cada ventana — asi el badge va en
// el mismo paso de render que el resto del panel, sin depender de
// ForegroundDrawList del viewport (que no se renderiza en algunas combos de
// backend + multi-viewport).
//
// Si tenemos icono GL cargado (Windows con appicon.ico embebido), se dibuja
// el icono real (mismo bitmap que el taskbar). Sin icono, fallback a
// cuadrado redondeado del color accent con la inicial blanca del app name.
//
// Filtro de ventanas:
// - Activa, no Hidden, no Collapsed.
// - No es child window.
// - No es popup/tooltip/menu (NoTitleBar => skip).
// - No esta dockeada en un nodo (DockIsActive => su titlebar es tabbar del
// host; el host window aparece en g.Windows por separado y SI recibe badge).
// - Su viewport != main viewport.
static void draw_header_badge_on_floating_panels(const AppConfig& cfg) {
if (!cfg.header_badge.enabled) return;
ImGuiContext& g = *ImGui::GetCurrentContext();
ImGuiViewport* main_vp = ImGui::GetMainViewport();
const ImU32 bg = resolve_badge_color(cfg);
const char* glyph = resolve_badge_glyph(cfg);
const float sz = cfg.header_badge.size_px > 4.0f ? cfg.header_badge.size_px : 18.0f;
const float mg = cfg.header_badge.margin_px >= 0.0f ? cfg.header_badge.margin_px : 6.0f;
const float round = sz * 0.22f;
const bool has_texture = (g_app_icon_texture != 0);
for (int i = 0; i < g.Windows.Size; ++i) {
ImGuiWindow* w = g.Windows[i];
if (!w || !w->WasActive || w->Hidden) continue;
if (w->Flags & ImGuiWindowFlags_ChildWindow) continue;
if (w->Flags & ImGuiWindowFlags_NoTitleBar) continue;
if (w->DockIsActive) continue; // titlebar reemplazado por tab bar del host
if (w->Viewport == nullptr || w->Viewport == main_vp) continue;
if (w->Collapsed) continue;
ImRect tb = w->TitleBarRect();
ImVec2 p0(tb.Min.x + mg, tb.Min.y + (tb.GetHeight() - sz) * 0.5f);
ImVec2 p1(p0.x + sz, p0.y + sz);
ImDrawList* dl = w->DrawList;
if (has_texture) {
dl->AddImageRounded((ImTextureID)(intptr_t)g_app_icon_texture,
p0, p1, ImVec2(0,0), ImVec2(1,1),
IM_COL32_WHITE, round);
} else {
dl->AddRectFilled(p0, p1, bg, round);
ImVec2 ts = ImGui::CalcTextSize(glyph);
ImVec2 tp(p0.x + (sz - ts.x) * 0.5f,
p0.y + (sz - ts.y) * 0.5f);
dl->AddText(tp, IM_COL32(255, 255, 255, 255), glyph);
}
}
}
int run_app(AppConfig config, std::function<void()> render_fn) { int run_app(AppConfig config, std::function<void()> render_fn) {
// Logger primero para capturar fallos del propio init (GLFW, ventana, GL). // Logger primero para capturar fallos del propio init (GLFW, ventana, GL).
if (config.log.file_path != nullptr) { if (config.log.file_path != nullptr) {
@@ -460,6 +716,14 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
// barra de tareas, Alt+Tab y title bar (GLFW no propaga el icono de // barra de tareas, Alt+Tab y title bar (GLFW no propaga el icono de
// recursos del .exe a su WNDCLASS por defecto). // recursos del .exe a su WNDCLASS por defecto).
attach_app_icon_to_hwnd(glfwGetWin32Window(window)); attach_app_icon_to_hwnd(glfwGetWin32Window(window));
// Title bar oscuro (DWM) si el tema lo es. Sin esto el header del SO
// queda blanco aunque el cliente sea dark.
{
const bool dark = (config.theme == ThemeMode::FnDark ||
config.theme == ThemeMode::ImGuiDark);
attach_dark_titlebar_to_hwnd(glfwGetWin32Window(window), dark);
}
#endif #endif
// Carga punteros a funciones GL >= 2.0 si la app lo pide. En Linux es // Carga punteros a funciones GL >= 2.0 si la app lo pide. En Linux es
@@ -484,6 +748,14 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
ImGuiIO& io = ImGui::GetIO(); ImGuiIO& io = ImGui::GetIO();
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;
// Multi-viewport docking: payload viewport y target viewport swappean
// buffers independientes, asi que los dock preview overlays (los rects
// azul/gris que indican zonas droppeables) parecen vibrar 1px contra el
// payload arrastrado. Con TransparentPayload el payload se vuelve
// invisible al arrastrar y los rects solo se pintan en el target ->
// ningun desync visible. Recomendado por upstream cuando "rendering of
// multiple viewport cannot be synced".
io.ConfigDockingTransparentPayload = true;
// Title-bar-only move for ImGui windows. Critical for secondary viewports // Title-bar-only move for ImGui windows. Critical for secondary viewports
// (floating panels) whose entire OS window is a single borderless ImGui // (floating panels) whose entire OS window is a single borderless ImGui
// window: without this flag, ImGui moves the window when the user drags // window: without this flag, ImGui moves the window when the user drags
@@ -625,6 +897,9 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
if (io.ConfigFlags & ImGuiConfigFlags_ViewportsEnable) { if (io.ConfigFlags & ImGuiConfigFlags_ViewportsEnable) {
prune_dead_subclassed(); prune_dead_subclassed();
prune_dead_icon_attached(); prune_dead_icon_attached();
prune_dead_dark_titlebar();
const bool dark_tb = (config.theme == ThemeMode::FnDark ||
config.theme == ThemeMode::ImGuiDark);
ImGuiPlatformIO& pio_sub = ImGui::GetPlatformIO(); ImGuiPlatformIO& pio_sub = ImGui::GetPlatformIO();
for (int i = 0; i < pio_sub.Viewports.Size; ++i) { for (int i = 0; i < pio_sub.Viewports.Size; ++i) {
ImGuiViewport* vp = pio_sub.Viewports[i]; ImGuiViewport* vp = pio_sub.Viewports[i];
@@ -636,6 +911,9 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
// SetClassLongPtrW. WM_SETICON per-HWND es la unica forma de // SetClassLongPtrW. WM_SETICON per-HWND es la unica forma de
// que el taskbar/titlebar muestren el icono. // que el taskbar/titlebar muestren el icono.
attach_app_icon_to_hwnd(glfwGetWin32Window(gw)); attach_app_icon_to_hwnd(glfwGetWin32Window(gw));
// Misma logica para el title bar oscuro — cada viewport
// secundario tiene su propio HWND con caption pintado por DWM.
attach_dark_titlebar_to_hwnd(glfwGetWin32Window(gw), dark_tb);
} }
} }
#endif #endif
@@ -750,6 +1028,17 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
fps_overlay(); fps_overlay();
} }
// Identidad por app en viewports secundarios — badge en el title bar
// de cada panel arrastrado fuera del main window. Si Windows + tiene
// appicon.ico embebido, dibuja el mismo icono que el taskbar (PNG
// RGBA escalado). Si no, fallback a cuadrado accent + inicial.
#ifdef _WIN32
if (g_app_icon_texture == 0) {
g_app_icon_texture = upload_hicon_to_gl_texture();
}
#endif
draw_header_badge_on_floating_panels(config);
ImGui::Render(); ImGui::Render();
int display_w, display_h; int display_w, display_h;
glfwGetFramebufferSize(window, &display_w, &display_h); glfwGetFramebufferSize(window, &display_w, &display_h);
@@ -800,6 +1089,10 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
} }
// Cleanup // Cleanup
if (g_app_icon_texture != 0) {
glDeleteTextures(1, &g_app_icon_texture);
g_app_icon_texture = 0;
}
ImGui_ImplOpenGL3_Shutdown(); ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplGlfw_Shutdown(); ImGui_ImplGlfw_Shutdown();
ImPlot3D::DestroyContext(); ImPlot3D::DestroyContext();
+36
View File
@@ -171,6 +171,42 @@ struct AppConfig {
// render_fn) las ventanas docked aparecen flotantes hasta el siguiente // render_fn) las ventanas docked aparecen flotantes hasta el siguiente
// ciclo. Default null = no-op. // ciclo. Default null = no-op.
std::function<void()> pre_frame{}; std::function<void()> pre_frame{};
// ------------------------------------------------------------------------
// Header badge — identidad visual en viewports secundarios (panels
// arrastrados fuera del main window). Cuando un panel se separa, el
// framework dibuja un pequeño cuadrado redondeado con la inicial de la
// app (o un glyph custom) en la esquina top-left de la title bar de su
// viewport, asi distingues de un vistazo de que app viene cada panel
// flotante cuando tienes varias apps abiertas a la vez.
//
// Si todos los campos quedan por defecto, el framework auto-deriva color
// estable desde about.name (hash -> HSV) y glyph desde la primera letra.
// No requiere accion en la app; con solo declarar about.name ya hay
// identidad. Apps que ya tengan icon.accent en su app.md deberian setear
// header_badge.accent_hex con el mismo hex para coherencia visual con el
// App Hub.
// ------------------------------------------------------------------------
struct AppHeaderBadge {
// Color de fondo del badge en formato "#RRGGBB" o "RRGGBB" sRGB.
// "" -> auto-derive desde about.name (hash estable).
const char* accent_hex = "";
// Glyph dibujado en blanco encima del fondo. nullptr/"" -> primera
// letra de about.name (uppercase). Soporta cualquier UTF-8 corto
// (1-2 chars o un TI_* macro de cpp/functions/core/icons_tabler.h).
const char* glyph = nullptr;
// Tamaño cuadrado del badge en pixels.
float size_px = 18.0f;
// Margen desde top-left del viewport.
float margin_px = 6.0f;
// false -> deshabilita el badge para esta app (capture mode, headless).
bool enabled = true;
};
AppHeaderBadge header_badge{};
}; };
// Run an ImGui application. The render_fn is called every frame // Run an ImGui application. The render_fn is called every frame
+15
View File
@@ -29,4 +29,19 @@ struct ModuleInfo {
extern const ModuleInfo app_modules_array[]; extern const ModuleInfo app_modules_array[];
extern const unsigned long app_modules_count; extern const unsigned long app_modules_count;
// App identity para el header badge en viewports secundarios (panels arrastrados
// fuera del main window). Auto-generados desde el bloque `icon:` del app.md
// por codegen_app_modules.py. Permiten que el framework dibuje el cuadrado
// accent con la identidad visual de la app sin que main.cpp deba pasar el hex
// manualmente.
//
// app_header_accent_hex: "#RRGGBB" desde icon.accent (default "" si no se
// declara — framework cae a hash-derived).
// app_header_glyph_name: nombre del glyph Phosphor desde icon.phosphor.
// Hoy informativo: el framework C++ no tiene fuente
// Phosphor cargada, asi que cae a primera letra de
// about.name. Reservado para futuro mapping Tabler.
extern const char* const app_header_accent_hex;
extern const char* const app_header_glyph_name;
} // namespace fn } // namespace fn
+3 -9
View File
@@ -60,16 +60,10 @@ void about_window_render() {
ImGui::TextWrapped("%s", g_description.c_str()); ImGui::TextWrapped("%s", g_description.c_str());
} }
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
// --- Framework version (issue 0097) ---
ImGui::Text("Framework");
ImGui::SameLine();
ImGui::TextDisabled("v%s", fn::framework_version());
// --- Modules consumidos por la app (issue 0097) --- // --- Modules consumidos por la app (issue 0097) ---
// codegen_app_modules.py auto-prepende `framework_cpp` a uses_modules de
// toda app C++, asi que la tabla Modules SIEMPRE lista al framework con
// su version + cada modulo declarado en app.md::uses_modules.
if (fn::app_modules_count > 0) { if (fn::app_modules_count > 0) {
ImGui::Spacing(); ImGui::Spacing();
ImGui::Separator(); ImGui::Separator();
@@ -2,6 +2,7 @@
// Promovido del playground tables (issue 0081-F). Pura — sin I/O ni estado. // Promovido del playground tables (issue 0081-F). Pura — sin I/O ni estado.
#include "core/compute_column_stats.h" #include "core/compute_column_stats.h"
#include "core/auto_detect_type.h" // parse_number
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
+4 -1
View File
@@ -2,7 +2,10 @@
// Promovido del playground tables (issue 0081-F). Pura — sin I/O ni estado. // Promovido del playground tables (issue 0081-F). Pura — sin I/O ni estado.
#pragma once #pragma once
#include "core/auto_detect_type.h" // parse_number reutilizado en la impl // NOTE: auto_detect_type.h (parse_number) is required by the .cpp impl only.
// It is NOT included here because data_table_types.h includes this header,
// and auto_detect_type.h itself references ColumnType (defined in
// data_table_types.h) — that would create a circular include.
#include <string> #include <string>
#include <utility> #include <utility>
+210
View File
@@ -0,0 +1,210 @@
#include "core/compute_ring_layout.h"
#include <algorithm>
#include <cmath>
#include <functional> // std::hash
#include <unordered_map>
namespace fn_ring {
// ---------------------------------------------------------------------------
// Constantes internas
// ---------------------------------------------------------------------------
static constexpr float kPi = 3.14159265358979323846f;
static constexpr float kTwoPi = 2.0f * kPi;
// Radio minimo para ring 0 cuando ring_radii[0]==0 (evita colocar nodos en el
// origen exacto, reservado para HUD).
static constexpr float kRing0InnerMin = 30.0f;
// Umbral: si el bin tiene mas nodos de los que caben radialmente con separacion
// minima MIN_RADIAL_SPACING, activamos jitter angular.
static constexpr float kMinRadialSpacing = 18.0f;
// Amplitud maxima del jitter angular: ±0.4 del half-sector (se mantiene dentro
// del sector para no solapar el vecino).
static constexpr float kJitterMaxFraction = 0.4f;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Hash deterministico FNV-1a 32 bits (estable cross-platform, sin depender de
/// std::hash<string> que puede variar entre ABI/compiladores).
static uint32_t fnv1a_32(const std::string& s) {
uint32_t h = 2166136261u;
for (unsigned char c : s) {
h ^= c;
h *= 16777619u;
}
return h;
}
/// Devuelve valor deterministico en [-1.0, +1.0] usando el hash del id.
static float id_jitter(const std::string& id) {
uint32_t h = fnv1a_32(id);
// Normalizar a [-1, +1]
float v = static_cast<float>(h) / static_cast<float>(0xFFFFFFFFu); // [0,1]
return v * 2.0f - 1.0f; // [-1,+1]
}
/// Devuelve el mapa canonico de status → ring.
static StatusRingMap default_status_map() {
return {
{"completado", 0},
{"completed", 0},
{"in-progress", 1},
{"pendiente_unlocked", 2},
{"unlocked", 2},
{"pending", 2},
{"pendiente", 3},
{"locked", 3},
{"deferred", 4},
{"bloqueado", 4},
};
}
/// Busca status en el mapa. Retorna -1 si no encontrado.
static int lookup_ring(const std::string& status, const StatusRingMap& smap) {
for (auto& [k, v] : smap) {
if (k == status) return v;
}
return -1;
}
/// Busca domain en el orden. Retorna n_sectors-1 si no encontrado.
static int lookup_sector(const std::string& domain,
const DomainOrder& order,
int n_sectors) {
for (int i = 0; i < static_cast<int>(order.size()); ++i) {
if (order[i] == domain) return i;
}
return n_sectors - 1;
}
// ---------------------------------------------------------------------------
// Implementacion principal
// ---------------------------------------------------------------------------
std::vector<LayoutOutput>
compute_ring_layout(const std::vector<LayoutInput>& nodes,
const LayoutConfig& cfg,
const StatusRingMap& status_map_in,
const DomainOrder& domain_order) {
if (nodes.empty()) return {};
const StatusRingMap& smap = status_map_in.empty()
? default_status_map()
: status_map_in;
const int n_rings = static_cast<int>(cfg.ring_radii.size()) - 1;
const int n_sectors = cfg.n_sectors > 0 ? cfg.n_sectors : 18;
const float sector_angle = kTwoPi / static_cast<float>(n_sectors);
const float half_sector = sector_angle * 0.5f;
// Preasignar outputs con ring=-1 (descartados por defecto)
std::vector<LayoutOutput> out(nodes.size());
for (size_t i = 0; i < nodes.size(); ++i) {
out[i].id = nodes[i].id;
}
// --- Paso 1: mapear cada nodo a (ring, sector) ---
// indices_in_bin[ring][sector] = lista de indices en nodes[]
using BinKey = std::pair<int,int>;
struct BinKeyHash {
size_t operator()(const BinKey& k) const noexcept {
return std::hash<int>()(k.first) ^ (std::hash<int>()(k.second) << 16);
}
};
std::unordered_map<BinKey, std::vector<size_t>, BinKeyHash> bins;
for (size_t i = 0; i < nodes.size(); ++i) {
int ring = lookup_ring(nodes[i].status, smap);
if (ring < 0 || ring >= n_rings) {
// Nodo descartado: ring=-1, x/y=0 (ya inicializado)
continue;
}
int sector = lookup_sector(nodes[i].domain, domain_order, n_sectors);
out[i].ring = ring;
out[i].sector = sector;
bins[{ring, sector}].push_back(i);
}
// --- Paso 2: posicionar nodos dentro de cada bin ---
for (auto& [key, indices] : bins) {
const int ring = key.first;
const int sector = key.second;
// Ordenar bin: recency desc, id asc (deterministico)
std::sort(indices.begin(), indices.end(),
[&](size_t a, size_t b) {
float ra = nodes[a].recency;
float rb = nodes[b].recency;
if (ra != rb) return ra > rb;
return nodes[a].id < nodes[b].id;
});
// Asignar rank_in_bin
for (int rank = 0; rank < static_cast<int>(indices.size()); ++rank) {
out[indices[rank]].rank_in_bin = rank;
}
// Radio interior y exterior del ring
float r_inner = cfg.ring_radii[ring];
float r_outer = cfg.ring_radii[ring + 1];
// Caso especial: ring 0 con radio interno == 0
if (r_inner == 0.0f) r_inner = kRing0InnerMin;
// Aplicar padding
float r_lo = r_inner + cfg.bin_padding;
float r_hi = r_outer - cfg.bin_padding;
if (r_lo > r_hi) {
// Bin demasiado estrecho: usar el punto medio
r_lo = r_hi = (r_inner + r_outer) * 0.5f;
}
const int N = static_cast<int>(indices.size());
// Angulo central del sector (sin jitter)
float theta_center = cfg.start_angle
+ (static_cast<float>(sector) + 0.5f) * sector_angle;
// Determinar si activamos jitter angular (bin sobrecargado radialmente)
float band_height = r_hi - r_lo;
int radial_capacity = (band_height < 1.0f)
? 1
: std::max(1, static_cast<int>(band_height / kMinRadialSpacing));
bool use_jitter = (N > radial_capacity);
// Posicionar cada nodo
for (int rank = 0; rank < N; ++rank) {
size_t idx = indices[rank];
// Radio: distribucion uniforme en la banda
float r;
if (N == 1) {
r = (r_lo + r_hi) * 0.5f;
} else {
r = r_lo + (static_cast<float>(rank) + 0.5f) * (r_hi - r_lo)
/ static_cast<float>(N);
}
// Sub-jitter angular deterministico si bin sobrecargado
float jitter = 0.0f;
if (use_jitter) {
float raw = id_jitter(nodes[idx].id); // en [-1,+1]
jitter = raw * half_sector * kJitterMaxFraction;
}
float theta = theta_center + jitter;
out[idx].x = cfg.center_x + r * std::cos(theta);
out[idx].y = cfg.center_y + r * std::sin(theta);
}
}
return out;
}
} // namespace fn_ring
+73
View File
@@ -0,0 +1,73 @@
#pragma once
#include <string>
#include <vector>
/// fn_ring — geometria pura para layout en anillos concentricos + sectores radiales.
/// Pure: sin I/O, sin estado global, sin RNG. Misma entrada → mismo output siempre.
/// C++17 STL only.
namespace fn_ring {
struct LayoutInput {
std::string id; // identidad unica para mapeo deterministico
std::string status; // categoria libre — mapeada al ring via status_map
std::string domain; // categoria libre — mapeada al sector via domain_order
float recency = 0.0f; // 0..1, ordena DENTRO de un (ring,sector) bin (desc)
};
struct LayoutOutput {
std::string id;
float x = 0.0f;
float y = 0.0f;
int ring = -1; // -1 = nodo descartado (status no mapeado)
int sector = 0;
int rank_in_bin = 0; // 0..N-1 dentro del bin (ring,sector)
};
struct LayoutConfig {
int n_sectors = 18;
float center_x = 0.0f;
float center_y = 0.0f;
// ring_radii[i] = radio interno del ring i
// ring_radii[i+1] = radio externo del ring i
// Default: 5 rings (0..4)
std::vector<float> ring_radii { 0.0f, 150.0f, 280.0f, 450.0f, 650.0f, 850.0f };
float bin_padding = 14.0f; // padding interior del bin en pixels
float start_angle = 0.0f; // rotacion global del sector 0 en radianes
};
/// Mapea strings de status a indice de ring.
/// Status no listado → ring -1 (nodo descartado).
///
/// Default canonico (usado cuando se pasa vector vacio):
/// {"completado",0}, {"completed",0},
/// {"in-progress",1},
/// {"pendiente_unlocked",2}, {"unlocked",2}, {"pending",2},
/// {"pendiente",3}, {"locked",3},
/// {"deferred",4}, {"bloqueado",4}
using StatusRingMap = std::vector<std::pair<std::string, int>>;
/// Mapea strings de domain a indice de sector [0..n_sectors-1].
/// Domains no listados → sector n_sectors-1 (sector "otros").
using DomainOrder = std::vector<std::string>;
/// Compute ring+sector layout deterministico.
///
/// Para cada nodo:
/// 1. ring = status_map[node.status]; descartar si -1.
/// 2. sector = index(domain_order, node.domain); fallback n_sectors-1.
/// 3. Bin (ring, sector): ordenar por (recency desc, id asc), distribuir
/// uniformemente en la banda radial con padding. Sub-jitter angular
/// deterministico via hash del id cuando el bin esta sobrecargado.
/// 4. theta = start_angle + (sector + 0.5) * (2*PI / n_sectors) + jitter
/// 5. x = center.x + r * cos(theta); y = center.y + r * sin(theta)
///
/// Ring 0 con radio interno 0: usa r_inner = 30.0f para no colocar nodos
/// en el origen exacto (reservado para HUD overlay).
std::vector<LayoutOutput>
compute_ring_layout(const std::vector<LayoutInput>& nodes,
const LayoutConfig& cfg,
const StatusRingMap& status_map = {},
const DomainOrder& domain_order = {});
} // namespace fn_ring
+90
View File
@@ -0,0 +1,90 @@
---
name: compute_ring_layout
kind: function
lang: cpp
domain: core
purity: pure
version: "1.0.0"
signature: "std::vector<LayoutOutput> compute_ring_layout(const std::vector<LayoutInput>& nodes, const LayoutConfig& cfg, const StatusRingMap& status_map, const DomainOrder& domain_order)"
description: "Calcula posiciones (x,y) deterministicas para layout en anillos concentricos por status + sectores radiales por domain. Pure, sin fisicas, output reproducible. Util para skill_tree, dashboards de roadmap, mapas de capability."
tags: [layout, rings, sectors, polar, dashboard, registry, skill-tree]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["core/compute_ring_layout.h"]
params:
- name: nodes
desc: "Vector de LayoutInput. Cada nodo tiene id (unico), status (bucket de ring), domain (sector), recency (0..1, ordena dentro del bin desc)."
- name: cfg
desc: "LayoutConfig: n_sectors, center_x/y, ring_radii (bordes de los anillos), bin_padding, start_angle. Defaults validos para 5 rings / 18 sectores / canvas 850px de radio."
- name: status_map
desc: "Mapa status→ring. Vector vacio usa el mapa canonico (completado→0, in-progress→1, unlocked→2, pendiente→3, deferred→4). Status no encontrado → ring=-1 (descartado)."
- name: domain_order
desc: "Lista ordenada de domains para asignar sector. Domain fuera de la lista → sector n_sectors-1 (sector 'otros'). Vector vacio → todos los domains caen en sector n_sectors-1."
output: "Vector de LayoutOutput en el mismo orden que nodes[]. Cada salida: id, x, y (posicion en world units), ring (-1=descartado), sector, rank_in_bin (orden dentro del (ring,sector))."
tested: true
tests:
- "empty_input_empty_output"
- "single_node_centered_in_bin"
- "two_nodes_same_bin_radial_distribution"
- "default_status_map"
- "unmapped_status_returns_ring_minus_one"
- "domain_not_in_order_falls_back_to_last_sector"
- "deterministic_repeated_call"
- "ring_zero_avoids_origin"
- "sector_wrap_around_last_sector"
- "golden_snapshot_30_nodes"
test_file_path: "cpp/tests/test_compute_ring_layout.cpp"
file_path: "cpp/functions/core/compute_ring_layout.cpp"
---
## Ejemplo
```cpp
#include "core/compute_ring_layout.h"
fn_ring::LayoutConfig cfg;
// cfg usa defaults: 5 rings, 18 sectors, ring_radii={0,150,280,450,650,850}
fn_ring::DomainOrder order = {"core", "infra", "finance", "datascience"};
std::vector<fn_ring::LayoutInput> nodes = {
{"0001", "completado", "core", 1.0f},
{"0002", "pendiente", "infra", 0.5f},
{"0003", "in-progress", "finance", 0.8f},
{"0004", "deferred", "core", 0.1f},
};
auto out = fn_ring::compute_ring_layout(nodes, cfg, {}, order);
// out[0]: ring=0, sector=0 (completado → ring0, core → sector0)
// out[1]: ring=3, sector=1 (pendiente → ring3, infra → sector1)
// out[2]: ring=1, sector=2 (in-progress→ ring1, finance → sector2)
// out[3]: ring=4, sector=0 (deferred → ring4, core → sector0)
// Integrar en main.cpp del skill_tree:
for (auto& o : out) {
if (o.ring < 0) continue; // nodo descartado
// ImGui::SetCursorScreenPos({base.x + o.x, base.y + o.y});
// draw_node(o.id);
}
```
## Cuando usarla
Cuando necesitas colocar N entidades en un mapa polar de progreso donde el anillo codifica el estado de avance (completado → centro, pendiente → exterior) y el sector codifica el dominio o categoria. Casos canonicos: skill_tree de issues/capabilities, roadmap visual de un proyecto, mapa de cobertura del registry por dominio.
La funcion calcula posiciones una vez; la animacion (interpolacion entre dos snapshots) la hace el caller en main.cpp, no esta funcion.
## Gotchas
- **Hash deterministico dentro de la build**: el sub-jitter angular usa FNV-1a 32-bit sobre el id (no `std::hash<string>`), lo que garantiza estabilidad cross-platform dentro del mismo proceso. El output es reproducible entre compiladores distintos.
- **Domain fuera de DomainOrder**: siempre cae en sector `n_sectors-1`. Si el caller quiere que todos los domains inesperados esten dispersos en lugar de apilados en el ultimo sector, debe pasar un `DomainOrder` exhaustivo o usar un sector dedicado `"otros"`.
- **Ring 0 con `ring_radii[0]==0`**: la funcion usa `r_inner=30.0f` automaticamente para no colocar nodos en el origen. Si el caller quiere usar el origen, debe pasar `ring_radii[0] > 0`.
- **Bins muy densamente cargados**: cuando el bin tiene mas nodos que la capacidad radial (`band_height / 18px`), se activa jitter angular `±0.4 * half_sector`. Los nodos siguen dentro del sector pero el angulo no es exactamente el centro del sector. Para bins con N > ~20 nodos el solapamiento visual es inevitable sin escalado del canvas.
- **`status_map` vacio**: se usa el mapa canonico con 5 rings. Si el caller usa status propios, DEBE pasar su propio `StatusRingMap`; de lo contrario todos cairan en ring=-1 (descartados).
## Capability growth log
(sin bumps aun — v1.0.0)
+97 -24
View File
@@ -5,6 +5,8 @@
// v1.4.0: ChipRule / ColorStop / CategoricalChip / ColorScale renderers. // v1.4.0: ChipRule / ColorStop / CategoricalChip / ColorScale renderers.
#pragma once #pragma once
#include "compute_column_stats.h"
#include <string> #include <string>
#include <utility> #include <utility>
#include <vector> #include <vector>
@@ -49,13 +51,68 @@ struct Filter {
std::string value; std::string value;
}; };
// ColorStop: one stop in an N-color gradient. Used by ColorRule (NumericRange)
// y por ColumnSpec (ColorScale renderer). Definido aqui (no abajo) para que
// ColorRule pueda contenerlo sin forward decl.
struct ColorStop {
float position; // 0.0 (leftmost/min) to 1.0 (rightmost/max)
std::string color; // "#rrggbb" hex color at this stop
};
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Drill extendido (fase 10). Ver issue 0079.
// Definidos aqui (no al final) para que State pueda contener vector<DrillStep>.
// ----------------------------------------------------------------------------
enum class DateGranularity { None, Year, Month, Week, Day, Hour };
enum class FilterPreset { Last7d, Last30d, Last90d, ExcludeNulls, NonZero };
// Step de drill grabado para history undo/redo (fase 10).
struct DrillStep {
int target_stage = -1; // stage donde se anadio el filter
int filter_pos = -1; // index en target_stage.filters
int prev_active_stage = 0; // active_stage antes del drill
Filter added; // filter para redo
};
// ----------------------------------------------------------------------------
// ColorRule kind (v1.5.0): pintado condicional con tres modos.
// CellBg — bg de celda cuando valor == `equals` (back-compat).
// CategoricalDot — dot a la izquierda del texto, colores autoasignados por
// valor distinto (palette deterministica). Util para
// categoricas con muchos valores donde no quieres definir
// cada color a mano.
// NumericRange — gradiente continuo de N colores (range_stops) sobre el
// bg de la celda. Para columnas numericas.
// ----------------------------------------------------------------------------
enum class ColorRuleKind : uint8_t {
CellBg = 0, // legacy (equals + color)
CategoricalDot = 1, // dots autoasignados por valor distinto
NumericRange = 2, // gradiente N-color sobre rango numerico
};
// ColorRule: pintado condicional de celdas (UI helper). // ColorRule: pintado condicional de celdas (UI helper).
// ---------------------------------------------------------------------------- // v1.5.0: anade `kind` + campos para CategoricalDot / NumericRange.
struct ColorRule { struct ColorRule {
int col; int col;
std::string equals; std::string equals; // CellBg: match value; otros: ignorado
unsigned int color; unsigned int color; // CellBg: bg color; otros: ignorado
// v1.5.0 fields (defaults preservan back-compat — kind=CellBg).
ColorRuleKind kind = ColorRuleKind::CellBg;
// CategoricalDot: alpha del relleno del dot (0..1). Tamaño en px (default 6).
float dot_alpha = 1.0f;
float dot_radius_px = 4.0f;
// Si vacio -> autoasigna desde palette interna. Si no, mapping fijo:
// pares (valor, "#rrggbb") consultados en orden.
std::vector<std::pair<std::string, std::string>> dot_map;
// NumericRange: gradiente continuo. range_min..range_max + N>=2 stops.
double range_min = 0.0;
double range_max = 1.0;
float range_alpha = 0.25f; // [0..1]; bg tint opacity
std::vector<ColorStop> range_stops; // declarado mas abajo; vacio -> default green→amber→red
}; };
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -189,11 +246,7 @@ struct ChipRule {
std::string color; // "#rrggbb" hex color for the filled circle std::string color; // "#rrggbb" hex color for the filled circle
}; };
// ColorStop: one stop in an N-color gradient for ColorScale renderer (v1.4.0). // (ColorStop defined earlier, near Filter, so ColorRule can reference it.)
struct ColorStop {
float position; // 0.0 (leftmost/min) to 1.0 (rightmost/max)
std::string color; // "#rrggbb" hex color at this stop
};
// ColumnSpec: rendering spec for one column. Indexed by column position. // ColumnSpec: rendering spec for one column. Indexed by column position.
struct ColumnSpec { struct ColumnSpec {
@@ -331,6 +384,41 @@ struct State {
bool chrome_user_set = true; bool chrome_user_set = true;
bool chrome_user_visible = false; bool chrome_user_visible = false;
// ------------------------------------------------------------------------
// v1.5.0 — per-table UI state. Antes vivia en UiState singleton, lo cual
// hacia que toggle "Show stats" / seleccion de celdas / drill-history /
// row inspector se aplicasen a TODAS las tablas a la vez. Ahora cada
// State los lleva. Modal/popup state (ask AI, edit chips, etc.) sigue en
// UiState porque solo uno esta abierto en toda la app a la vez.
// ------------------------------------------------------------------------
// Stats panel (cabecera de col muestra min/max/percentiles/hist).
bool stats_mode = false;
std::vector<ColStats> stats_cache;
// Invalidacion del cache (data-identity y filter-set).
const char* const* stats_last_cells = nullptr;
int stats_last_rows = -1;
int stats_last_eff_cols = -1;
size_t stats_last_filter_h = (size_t)-1;
int stats_last_visible = -1;
// Cell selection (drag-select rectangular Ctrl+C-able).
int sel_anchor_row = -1;
int sel_anchor_col = -1;
int sel_end_row = -1;
int sel_end_col = -1;
bool sel_active = false;
bool sel_dragging = false;
// Row inspector modal target (-1 = closed).
int inspect_row = -1;
bool inspect_open = false;
// Drill history (fase 10) — per-table porque cada tabla tiene su propio
// pipeline de filters.
std::vector<DrillStep> drill_back;
std::vector<DrillStep> drill_forward;
// Helpers (definidos en compute_stage.cpp). // Helpers (definidos en compute_stage.cpp).
Stage& raw(); Stage& raw();
const Stage& raw() const; const Stage& raw() const;
@@ -339,19 +427,4 @@ struct State {
void ensure_stage0(); void ensure_stage0();
}; };
// ----------------------------------------------------------------------------
// Drill extendido (fase 10). Ver issue 0079.
// ----------------------------------------------------------------------------
enum class DateGranularity { None, Year, Month, Week, Day, Hour };
enum class FilterPreset { Last7d, Last30d, Last90d, ExcludeNulls, NonZero };
// Step de drill grabado para history undo/redo (fase 10).
struct DrillStep {
int target_stage = -1; // stage donde se anadio el filter
int filter_pos = -1; // index en target_stage.filters
int prev_active_stage = 0; // active_stage antes del drill
Filter added; // filter para redo
};
} // namespace data_table } // namespace data_table
+251
View File
@@ -0,0 +1,251 @@
#include "core/parse_md_frontmatter.h"
#include <algorithm>
#include <sstream>
namespace fn_md {
namespace {
// ---------------------------------------------------------------------------
// String helpers
// ---------------------------------------------------------------------------
static std::string trim(const std::string& s) {
const auto b = s.find_first_not_of(" \t\r");
if (b == std::string::npos) return {};
const auto e = s.find_last_not_of(" \t\r");
return s.substr(b, e - b + 1);
}
// Strip a trailing ` # comment` from a value string.
// Only strips if the `#` is preceded by at least one space and is outside
// any quote context. We take a simple approach: after stripping outer quotes
// we look for ` #` outside of them.
static std::string strip_comment(const std::string& s) {
// If the whole string is quoted, skip comment stripping; the comment would
// be inside the quotes and should be preserved as literal text.
if (s.size() >= 2 &&
((s.front() == '"' && s.back() == '"') ||
(s.front() == '\'' && s.back() == '\''))) {
return s;
}
const auto pos = s.find(" #");
if (pos == std::string::npos) return s;
return trim(s.substr(0, pos));
}
// Remove surrounding " or ' quotes from a value, if present.
static std::string unquote(const std::string& s) {
if (s.size() >= 2 &&
((s.front() == '"' && s.back() == '"') ||
(s.front() == '\'' && s.back() == '\''))) {
return s.substr(1, s.size() - 2);
}
return s;
}
// Parse an inline YAML list: [a, b, c] or []
static std::vector<std::string> parse_inline_list(const std::string& s) {
std::vector<std::string> result;
// strip brackets
const auto lb = s.find('[');
const auto rb = s.rfind(']');
if (lb == std::string::npos || rb == std::string::npos || lb >= rb)
return result;
const std::string inner = s.substr(lb + 1, rb - lb - 1);
if (trim(inner).empty()) return result;
// split on commas respecting quotes
std::string token;
bool in_quote = false;
char quote_ch = 0;
for (char c : inner) {
if (!in_quote && (c == '"' || c == '\'')) {
in_quote = true;
quote_ch = c;
token += c;
} else if (in_quote && c == quote_ch) {
in_quote = false;
token += c;
} else if (!in_quote && c == ',') {
result.push_back(unquote(trim(token)));
token.clear();
} else {
token += c;
}
}
const auto t = unquote(trim(token));
if (!t.empty()) result.push_back(t);
return result;
}
// Split a frontmatter line into (key, raw_value).
// Returns false if the line has no `:`.
static bool split_kv(const std::string& line, std::string& key, std::string& raw_val) {
const auto colon = line.find(':');
if (colon == std::string::npos) return false;
key = trim(line.substr(0, colon));
raw_val = trim(line.substr(colon + 1));
return true;
}
// ---------------------------------------------------------------------------
// Line iterator helpers
// ---------------------------------------------------------------------------
static std::vector<std::string> split_lines(const std::string& s) {
std::vector<std::string> lines;
std::istringstream ss(s);
std::string l;
while (std::getline(ss, l)) lines.push_back(l);
return lines;
}
// Returns true if a line looks like a block-list item: optional leading
// whitespace, then `- `.
static bool is_list_item(const std::string& line, std::string& item_val) {
const auto b = line.find_first_not_of(" \t");
if (b == std::string::npos) return false;
if (line[b] != '-') return false;
// Must have space (or be end) after `-`
if (b + 1 < line.size() && line[b + 1] != ' ') return false;
item_val = trim(line.substr(b + 1));
item_val = unquote(item_val);
return true;
}
} // anonymous namespace
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
Frontmatter parse_md_frontmatter(const std::string& content) {
Frontmatter fm;
const auto lines = split_lines(content);
if (lines.empty()) return fm;
// Check opening `---`
if (trim(lines[0]) != "---") {
fm.body = content;
return fm;
}
fm.has_frontmatter = true;
// Find closing `---`
std::size_t close_idx = std::string::npos;
for (std::size_t i = 1; i < lines.size(); ++i) {
if (trim(lines[i]) == "---") {
close_idx = i;
break;
}
}
// Determine where the body starts (after the closing `---\n`)
if (close_idx == std::string::npos) {
// No closing delimiter: treat everything after line 0 as frontmatter,
// body is empty.
close_idx = lines.size();
} else {
// Reconstruct body from lines after close_idx
std::string body;
for (std::size_t i = close_idx + 1; i < lines.size(); ++i) {
body += lines[i] + '\n';
}
fm.body = body;
}
// Parse frontmatter lines [1 .. close_idx)
std::size_t i = 1;
while (i < close_idx) {
const std::string& line = lines[i];
// Skip blank lines and top-level comment lines
const std::string tl = trim(line);
if (tl.empty() || tl[0] == '#') {
++i;
continue;
}
std::string key, raw_val;
if (!split_kv(line, key, raw_val)) {
++i;
continue;
}
if (key.empty()) { ++i; continue; }
// --- Determine the kind of value ---
// 1. Inline list: raw_val starts with `[`
if (!raw_val.empty() && raw_val[0] == '[') {
fm.fields[key] = parse_inline_list(raw_val);
++i;
continue;
}
// 2. Block scalar `|` or `>` — unsupported, store empty string
if (raw_val == "|" || raw_val == ">") {
fm.fields[key] = std::string{};
// Skip continuation lines (more indented than current)
++i;
while (i < close_idx) {
const std::string& next = lines[i];
if (next.empty()) { ++i; continue; }
if (next[0] == ' ' || next[0] == '\t') { ++i; continue; }
break;
}
continue;
}
// 3. Block list: raw_val is empty and next line(s) are list items
if (raw_val.empty()) {
// Peek ahead
std::string dummy;
std::size_t j = i + 1;
bool found_items = false;
while (j < close_idx) {
const std::string& next = lines[j];
if (trim(next).empty()) { ++j; continue; }
std::string item_val;
if (is_list_item(next, item_val)) {
found_items = true;
break;
}
break;
}
if (found_items) {
std::vector<std::string> items;
++i; // move past the key line
while (i < close_idx) {
const std::string& next = lines[i];
if (trim(next).empty()) { ++i; continue; }
std::string item_val;
if (is_list_item(next, item_val)) {
items.push_back(unquote(item_val));
++i;
} else {
break; // end of block list
}
}
fm.fields[key] = std::move(items);
continue;
}
// Empty value (e.g. `key:` with no list following)
fm.fields[key] = std::string{};
++i;
continue;
}
// 4. Plain scalar (strip trailing comment, then unquote)
const std::string stripped = strip_comment(raw_val);
fm.fields[key] = unquote(trim(stripped));
++i;
}
return fm;
}
} // namespace fn_md
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <string>
#include <unordered_map>
#include <variant>
#include <vector>
namespace fn_md {
/// A YAML value: absent, scalar string, or list of strings.
using YamlValue = std::variant<std::monostate, std::string, std::vector<std::string>>;
/// Result of parsing a Markdown file that may contain a YAML frontmatter block.
struct Frontmatter {
std::unordered_map<std::string, YamlValue> fields;
std::string body; ///< content after the closing `---` line
bool has_frontmatter = false;
};
/// Parse a Markdown file that may (or may not) begin with a YAML frontmatter
/// block delimited by `---` lines.
///
/// Supported YAML subset (flat only, no nested maps):
/// key: value -> string (bare, or "quoted", or 'quoted')
/// key: [a, b, c] -> inline list -> vector<string>
/// key: -> block list when the next indented lines are
/// - item ` - item` -> vector<string>
/// key: | -> unsupported block scalar; stored as ""
/// # comment -> ignored (also inline trailing comments)
///
/// Notes:
/// - Keys and values are trimmed of leading/trailing whitespace.
/// - A value like `description: "foo: bar"` splits on the FIRST colon only.
/// - Pure function: no I/O, no logging, no side-effects.
Frontmatter parse_md_frontmatter(const std::string& content);
} // namespace fn_md
@@ -0,0 +1,82 @@
---
name: parse_md_frontmatter
kind: function
lang: cpp
domain: core
version: "1.0.0"
purity: pure
signature: "Frontmatter parse_md_frontmatter(const std::string& content)"
description: "Parsea frontmatter YAML simple (subset: key:value, listas inline [a,b], listas multilinea con - item) de un .md y devuelve struct con fields map + body. Pure, sin dependencias externas."
tags: [markdown, frontmatter, yaml, parser, issues, meta, registry]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["core/parse_md_frontmatter.h"]
example: |
#include "core/parse_md_frontmatter.h"
#include <fstream>
#include <sstream>
std::ifstream f("/home/lucas/fn_registry/dev/issues/0109-skill-tree-app-roadmap.md");
std::stringstream ss; ss << f.rdbuf();
auto fm = fn_md::parse_md_frontmatter(ss.str());
auto title = std::get<std::string>(fm.fields["title"]);
auto domain = std::get<std::vector<std::string>>(fm.fields["domain"]);
tested: true
tests:
- "parses_no_frontmatter"
- "parses_simple_key_value"
- "parses_quoted_strings"
- "parses_inline_list"
- "parses_multiline_list"
- "parses_body_after_frontmatter"
- "parses_empty_inline_list"
- "parses_strips_trailing_comment"
- "parses_real_issue_0109"
- "parses_real_issues_golden"
test_file_path: "cpp/tests/test_parse_md_frontmatter.cpp"
file_path: "cpp/functions/core/parse_md_frontmatter.cpp"
params:
- name: content
desc: "Contenido completo del archivo .md como string. Puede o no comenzar con bloque frontmatter `---...---`."
output: "Struct Frontmatter con: fields (unordered_map<string, YamlValue>) donde YamlValue = monostate|string|vector<string>; body (string con contenido despues del segundo ---); has_frontmatter (bool)."
notes: |
Subset YAML deliberado — los frontmatters de issues y flows del registry son planos. Si en el futuro hace falta soportar mapas anidados, considerar embeber yaml-cpp en cpp/vendor/ antes que extender este parser.
---
## Cuando usarla
Cuando una app C++ necesita leer metadata de archivos Markdown del registry (issues, flows, app.md, analysis.md, type.md). No requiere libreria externa ni yaml-cpp.
## Gotchas
- Subset YAML: NO soporta mapas anidados, NO soporta block scalars `|` ni `>` (devuelve string vacio en esos casos).
- Si un value contiene `: ` literal debe ir entre comillas: `description: "foo: bar"`.
- Comentarios solo se eliminan al final de linea en valores no citados. Un `# comentario` dentro de comillas se preserva como literal.
- Block list: las lineas de items deben estar indentadas con al menos un espacio antes de `- `. Un `- item` sin indentacion al nivel raiz se ignora (no es list item de la clave anterior).
- `YamlValue` es `std::variant` — usar `std::get<std::string>` o `std::get<std::vector<std::string>>` segun el campo. `std::get_if` es mas seguro si el tipo no es conocido a priori.
## Ejemplo
```cpp
#include "core/parse_md_frontmatter.h"
#include <fstream>
#include <sstream>
std::ifstream f("/home/lucas/fn_registry/dev/issues/0109-skill-tree-app-roadmap.md");
std::stringstream ss; ss << f.rdbuf();
auto fm = fn_md::parse_md_frontmatter(ss.str());
// Scalar string
auto id = std::get<std::string>(fm.fields["id"]); // "0109"
auto status = std::get<std::string>(fm.fields["status"]); // "in-progress"
// List
auto domain = std::get<std::vector<std::string>>(fm.fields["domain"]);
// domain[0] == "meta", domain[1] == "cpp-stack"
// Body (content after closing ---)
// fm.body starts with "\n# 0109 — skill_tree app..."
```
+188
View File
@@ -0,0 +1,188 @@
// data_table_ai_panel — modal "Ask AI" de la tabla TQL.
// Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c).
//
// Rangos de lineas del fuente original (antes de la extraccion):
// - Stub llm_anthropic (no-op) : lineas 82-112
// - Modal "Ask AI" completo : lineas 4209-4328
// - Boton trigger en viz selector: linea 1588
//
// NOTA DEUDA TECNICA: llm_anthropic no esta en el registry todavia (Wave 4 TODO).
// Pendiente promover a cpp/functions/infra/llm_anthropic (.h + .cpp + .md).
#include "viz/data_table_ai_panel.h"
#include "core/data_table_types.h"
#include "core/tql_apply.h"
#include "imgui.h"
// llm_anthropic: usar header real si disponible, stub si no.
#ifdef FN_LLM_ANTHROPIC
# include "core/llm_anthropic.h"
#else
// Stub no-op (inline en este TU hasta que llm_anthropic se promueva al registry).
// TODO: Wave 4 — promover a cpp/functions/infra/llm_anthropic.cpp + .h + .md
namespace llm_anthropic {
enum class OutputMode { TQL, SQL };
struct AskInput {
std::string question;
std::string tql_current;
std::vector<std::string> col_names;
std::vector<data_table::ColumnType> col_types;
std::vector<std::string> joinable_names;
OutputMode mode = OutputMode::TQL;
std::string model;
int max_tokens = 8192;
};
struct AskResult {
std::string code;
std::string raw;
std::string error;
int tokens_in = 0;
int tokens_out = 0;
};
inline AskResult ask(const AskInput&, const std::string& = "") {
AskResult r;
r.error = "llm_anthropic not available (stub). Build with FN_LLM_ANTHROPIC=1.";
return r;
}
} // namespace llm_anthropic
#endif
#include <string>
#include <vector>
#include <cstdio>
namespace data_table {
// ---------------------------------------------------------------------------
// draw_ask_ai_modal
// Dibuja el modal "Ask AI". Debe llamarse cada frame (patron ImGui).
// Abre el popup si ask_ai.open == true.
// ---------------------------------------------------------------------------
void draw_ask_ai_modal(AskAiState& ask_ai,
State& st,
const std::vector<std::string>& active_headers,
const std::vector<ColumnType>& active_types,
int orig_cols)
{
if (ask_ai.open) ImGui::OpenPopup("Ask AI");
ImGui::SetNextWindowSize(ImVec2(820, 560), ImGuiCond_Appearing);
if (ImGui::BeginPopupModal("Ask AI", &ask_ai.open,
ImGuiWindowFlags_NoSavedSettings)) {
ImGui::TextDisabled("Ask en lenguaje natural. Default TQL. SQL solo si DuckDB linkado.");
const char* modes[] = {"TQL", "SQL (DuckDB)"};
#ifndef FN_TQL_DUCKDB
// SQL mode disabled visually pero el toggle existe (informativo)
if (ask_ai.mode == 1) ask_ai.mode = 0;
#endif
ImGui::Combo("Output##askmode", &ask_ai.mode, modes, IM_ARRAYSIZE(modes));
#ifndef FN_TQL_DUCKDB
if (ask_ai.mode == 1) {
ImGui::TextColored(ImVec4(1, 0.5f, 0.3f, 1),
"SQL mode requires FN_TQL_DUCKDB=1 build flag.");
}
#endif
ImGui::InputTextMultiline("##ask_q", ask_ai.question, sizeof(ask_ai.question),
ImVec2(-1, 80));
ImGui::BeginDisabled(ask_ai.busy);
if (ImGui::Button("Send")) {
ask_ai.busy = true;
ask_ai.status = "Sending...";
ask_ai.error.clear();
ask_ai.response_code.clear();
ask_ai.response_raw.clear();
// Build AskInput desde el state actual.
llm_anthropic::AskInput in;
in.question = ask_ai.question;
in.tql_current = ask_ai.current_tql;
in.col_names = active_headers;
in.col_types = active_types;
in.mode = (ask_ai.mode == 1)
? llm_anthropic::OutputMode::SQL
: llm_anthropic::OutputMode::TQL;
// Llamada blocking (UI freeze breve durante red).
auto r = llm_anthropic::ask(in);
ask_ai.busy = false;
if (!r.error.empty()) {
ask_ai.error = r.error;
ask_ai.status = "Error";
} else {
ask_ai.response_raw = r.raw;
ask_ai.response_code = r.code;
ask_ai.status = "Got response.";
// Llenar edit buffer
std::snprintf(ask_ai.edit_buf, sizeof(ask_ai.edit_buf),
"%s", r.code.c_str());
}
}
ImGui::EndDisabled();
ImGui::SameLine();
if (!ask_ai.status.empty()) {
ImGui::TextDisabled("%s", ask_ai.status.c_str());
}
if (!ask_ai.error.empty()) {
ImGui::TextColored(ImVec4(1, 0.4f, 0.4f, 1), "%s", ask_ai.error.c_str());
}
ImGui::Separator();
ImGui::Columns(2, "ask_cols", true);
ImGui::TextUnformatted("Current");
ImGui::InputTextMultiline("##ask_cur",
const_cast<char*>(ask_ai.current_tql.c_str()),
ask_ai.current_tql.size() + 1,
ImVec2(-1, 240),
ImGuiInputTextFlags_ReadOnly);
ImGui::NextColumn();
ImGui::TextUnformatted("Proposed (editable before apply)");
ImGui::InputTextMultiline("##ask_new", ask_ai.edit_buf, sizeof(ask_ai.edit_buf),
ImVec2(-1, 240));
ImGui::Columns(1);
bool can_apply = !ask_ai.busy && ask_ai.edit_buf[0] != '\0';
ImGui::BeginDisabled(!can_apply);
if (ImGui::Button("Apply")) {
std::string err;
if (ask_ai.mode == 0) {
// TQL apply
bool ok = tql::apply(ask_ai.edit_buf, st,
active_headers,
active_types,
nullptr, 0,
orig_cols,
&err);
if (ok) {
ask_ai.status = "Applied OK.";
ask_ai.open = false;
} else {
ask_ai.error = "tql::apply error: " + err;
ask_ai.status = "Apply failed.";
}
} else {
#ifdef FN_TQL_DUCKDB
// SQL apply: ejecutar via tql_duckdb sobre TableInputs activas.
// Para tablas en memoria construimos un TableInput basico desde
// active_headers/types. v1 no recupera cells originales aqui;
// reportamos solo error si fallo. Caller real deberia pasar
// tables() del render scope. Sin esto, marcamos status info.
ask_ai.status = "SQL execute disponible (FN_TQL_DUCKDB ON). "
"Integracion full pendiente: usar tql_duckdb::execute desde caller.";
#else
ask_ai.status = "SQL execute requires FN_TQL_DUCKDB build flag.";
#endif
}
}
ImGui::EndDisabled();
ImGui::SameLine();
if (ImGui::Button("Reject")) {
ask_ai.response_code.clear();
ask_ai.edit_buf[0] = '\0';
}
ImGui::SameLine();
if (ImGui::Button("Close")) {
ask_ai.open = false;
}
ImGui::EndPopup();
}
}
} // namespace data_table
+61
View File
@@ -0,0 +1,61 @@
#pragma once
// data_table_ai_panel — modal "Ask AI" de la tabla TQL.
// Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c).
//
// Responsabilidad:
// - draw_ask_ai_modal: modal ImGui con prompt de lenguaje natural, llamada
// a llm_anthropic::ask, render de la respuesta (codigo TQL/SQL editable),
// botones Apply/Reject/Close, export CSV de la respuesta.
//
// El modal usa UiState.ask_* que se mueve aqui (AskAiState) para
// encapsulacion. Vive en UiState del data_table principal — no en State.
//
// Dependencia con llm_anthropic:
// - Si FN_LLM_ANTHROPIC esta definido: usa core/llm_anthropic.h (real).
// - Si no: el stub del data_table.cpp provee tipos/funciones no-op.
// - Tras la extraccion este modulo incluye el stub o el header real.
//
// Rangos del fuente original:
// - Modal "Ask AI" : lineas 4636-4755
// - Boton trigger : linea 1869 (dentro de draw_viz_selector)
//
// Dependencias: data_table_types.h, llm_anthropic.h (o stub), imgui.h.
#include "core/data_table_types.h"
#include "imgui.h"
#include <string>
#include <vector>
namespace data_table {
// AskAiState: estado del modal Ask AI. Debe persistir entre frames
// (es parte de UiState). Extraido aqui para encapsulacion.
struct AskAiState {
bool open = false;
bool busy = false;
int mode = 0; // 0 = TQL, 1 = SQL (DuckDB)
char question[2048] = {};
std::string current_tql; // TQL emitido del state al abrir modal
std::string response_raw; // texto raw del modelo
std::string response_code; // bloque extraido (Lua o SQL)
std::string error;
std::string status; // "Sending..." / "Got response." / error
char edit_buf[8192] = {}; // buffer editable de la propuesta
};
// draw_ask_ai_modal — dibuja el modal "Ask AI".
// Debe llamarse cada frame (pattern ImGui). Abre el popup si ask_ai.open==true.
//
// Parametros:
// ask_ai — estado mutable del modal.
// st — State principal (para tql::apply en Apply + active_headers/types).
// active_headers / active_types — snapshot del output activo (para la llamada llm).
// orig_cols — numero de cols originales (para tql::apply signature).
// ---------------------------------------------------------------------------
void draw_ask_ai_modal(AskAiState& ask_ai,
State& st,
const std::vector<std::string>& active_headers,
const std::vector<ColumnType>& active_types,
int orig_cols);
} // namespace data_table
+80
View File
@@ -0,0 +1,80 @@
---
name: data_table_ai_panel
kind: function
lang: cpp
domain: viz
version: "1.0.0"
purity: impure
signature: "void data_table::draw_ask_ai_modal(AskAiState& ask_ai, State& st, const std::vector<std::string>& active_headers, const std::vector<ColumnType>& active_types, int orig_cols)"
description: "Modal 'Ask AI' de la tabla TQL: UI de prompt en lenguaje natural, llamada bloqueante a llm_anthropic::ask, render de la respuesta (codigo TQL o SQL editable), botones Apply/Reject/Close. El Apply ejecuta tql::apply sobre el estado actual de la tabla. Soporta modo TQL (default) y SQL (requiere FN_TQL_DUCKDB). Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c, fase 11 / issue 0080)."
tags: [viz, table, imgui, ui, ai, llm, tql, cpp-tables, ask-ai]
uses_functions:
- data_table_cpp_viz
uses_types:
- data_table_types_cpp_core
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [imgui]
tested: false
tests: []
test_file_path: ""
file_path: "cpp/functions/viz/data_table_ai_panel.cpp"
framework: imgui
params:
- name: ask_ai
desc: "Estado mutable del modal (AskAiState): prompt, modo TQL/SQL, buffers de respuesta, flags open/busy. Debe persistir entre frames (vive en UiState del data_table)."
- name: st
desc: "State principal de la tabla. Mutado por Apply: tql::apply modifica st.stages, filtros, sorts."
- name: active_headers / active_types
desc: "Snapshot del output del stage activo en el momento de abrir el modal. Se pasan a llm_anthropic::AskInput para que el modelo conozca el schema."
- name: orig_cols
desc: "Numero de columnas originales. Necesario para la firma de tql::apply."
output: "Void. Efectos: mutates ask_ai (flags, buffers), mutates st (via tql::apply en Apply)."
---
## Documentacion
Sub-funcion que encapsula el modal "Ask AI" de la tabla TQL (issue 0080, fase 11). Permite al usuario escribir una pregunta en lenguaje natural y recibir codigo TQL o SQL generado por un modelo de lenguaje.
### Flujo del modal
1. `ask_ai.open = true` (seteado por boton "Ask AI" en `draw_viz_selector`).
2. Primer frame con `open=true`: `ImGui::OpenPopup("Ask AI")`.
3. Modal se abre: prompt input, combo modo TQL/SQL.
4. Usuario escribe pregunta y hace click en "Send":
- Construye `llm_anthropic::AskInput` con `question`, `col_names`, `col_types`, `tql_current`.
- Llamada **bloqueante** a `llm_anthropic::ask(in)``AskResult`.
- Si error: `ask_ai.error` se muestra en rojo.
- Si ok: `ask_ai.response_code` se copia a `ask_ai.edit_buf` (editable).
5. Panel dividido en dos columnas: TQL actual (read-only) | propuesta (editable).
6. Botones:
- **Apply**: ejecuta `tql::apply(ask_ai.edit_buf, st, ...)`. Si ok: cierra modal.
- **Reject**: limpia `edit_buf` y `response_code`.
- **Close**: `ask_ai.open = false`.
### Dependencia llm_anthropic
Si `FN_LLM_ANTHROPIC` esta definido en el build, se incluye `core/llm_anthropic.h` (real). Si no, se usa el stub no-op que retorna error "not available". Pendiente promover a `cpp/functions/infra/llm_anthropic` (Wave 4, issue 0107c deuda tecnica).
## Ejemplo
```cpp
// En el render principal, llamar una vez por frame tras el grid:
data_table::draw_ask_ai_modal(U.ask_ai, st,
U.active_headers, U.active_types, orig_cols);
// El boton de apertura vive en draw_viz_selector:
// if (ImGui::SmallButton("Ask AI##ask_open")) U.ask_ai.open = true;
```
## Cuando usarla
Llamar una vez por frame desde el entrypoint thin `data_table::render()` en el bloque de modales (junto a TQL show/apply, Custom column, etc.), SIEMPRE que el contexto ImGui tenga la ventana activa. No llamar desde apps directamente.
## Gotchas
- La llamada a `llm_anthropic::ask` es **bloqueante** (red HTTP). La UI se congela brevemente. Consideracion futura: mover a hilo background + flag de estado asincrono.
- El modal se destruye (EndPopup) si el usuario hace click fuera. `ask_ai.open` se pone a false por el `&ask_ai.open` del `BeginPopupModal`. Esto borra el estado del prompt en curso — consideracion UX conocida.
- En modo SQL (`ask_ai.mode == 1`), Apply solo funciona si `FN_TQL_DUCKDB` esta definido; de lo contrario muestra mensaje de error informativo.
- `llm_anthropic` no esta en el registry (deuda Wave 4). Hasta que se promueva, el stub vive inlined en este .cpp. No duplicar el stub en otros archivos.
File diff suppressed because it is too large Load Diff
+163
View File
@@ -0,0 +1,163 @@
#pragma once
// data_table_chips — barra de chips superior de la tabla TQL.
// Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c).
//
// Responsabilidad:
// Toda la UI de chips que aparece en el area de chrome de la tabla:
// - draw_joins_chips : chips de join con tablas secundarias.
// - draw_filter_chips : chips de filtros activos (con boton de borrar).
// - draw_breakout_chips : chips de breakout (group-by) activos.
// - draw_aggregation_chips: chips de agregaciones activas.
// - draw_sort_chips : chips de ordenamiento activos.
// - draw_header_menu : popup menu al hacer right-click en cabecera de col.
// - apply_header_sort_click: procesa click en cabecera (sort / multi-sort con Shift).
//
// Popups de anadir/editar chips (activados desde los chips o botones "+"):
// - draw_add_filter_popup / draw_edit_filter_popup
// - draw_add_breakout_popup / draw_edit_breakout_popup
// - draw_add_aggregation_popup / draw_edit_agg_popup
// - draw_add_sort_popup / draw_edit_sort_popup
//
// TQL preview / save / load (area de chrome stage 0):
// - draw_tql_bar (NEW helper que engloba Show TQL, Apply TQL, Save/Load .tql)
//
// Helpers de tipo/op para los popups:
// - draw_typed_ops: dibuja radio buttons de Op segun ColumnType.
// - type_supports_range: retorna true si el tipo soporta Op::Range.
//
// Rangos del fuente original:
// - draw_joins_chips : lineas 1897-2003
// - draw_filter_chips : lineas 2009-2093
// - draw_breakout_chips : lineas 2095-2188
// - draw_aggregation_chips : lineas 2189-2238
// - draw_sort_chips : lineas 2240-2309
// - apply_header_sort_click : lineas 2311-2327
// - draw_edit_filter_popup : lineas 2329-2370
// - draw_edit_breakout_popup : lineas 2372-2395
// - draw_edit_agg_popup : lineas 2397-2445
// - draw_edit_sort_popup : lineas 2447-2472
// - draw_typed_ops : lineas 2503-2510
// - type_supports_range : lineas 2512-2514
// - draw_add_filter_popup : lineas 2516-2569
// - draw_add_breakout_popup : lineas 2571-2604
// - draw_add_aggregation_popup: lineas 2606-2655
// - draw_add_sort_popup : lineas 2657-2682
// - draw_header_menu : lineas 2684-2892
// - TQL preview/save/load : lineas 3272-3366 (dentro de render() stage 0)
//
// Dependencias: data_table_types.h, tql_emit.h, tql_apply.h, imgui.h.
#include "core/data_table_types.h"
#include "imgui.h"
#include <vector>
#include <string>
namespace data_table {
// ---------------------------------------------------------------------------
// Helpers de tipo/operacion
// ---------------------------------------------------------------------------
// draw_typed_ops: dibuja radio buttons para los operadores compatibles con el
// tipo `t`. Rellena `out` con el op seleccionado. Retorna true si se selecciono.
bool draw_typed_ops(ColumnType t, Op& out);
// type_supports_range: true si el tipo admite Op::Range (Int, Float, Date).
bool type_supports_range(ColumnType t);
// ---------------------------------------------------------------------------
// Sort
// ---------------------------------------------------------------------------
// apply_header_sort_click: registra un click en el header de col_name.
// Sin Shift: sort primario (reemplaza todos los sorts).
// Con Shift: sort secundario (agrega al sort primario existente).
// Ciclo: Asc -> Desc -> none.
void apply_header_sort_click(Stage& stg, const std::string& col_name, bool shift);
void draw_sort_chips(Stage& stg);
void draw_add_sort_popup(Stage& stg, const char* const* headers, int n_cols,
const std::vector<ColumnType>& types);
void draw_edit_sort_popup(Stage& stg, const char* const* headers, int n_cols);
// ---------------------------------------------------------------------------
// Filtros
// ---------------------------------------------------------------------------
void draw_filter_chips(Stage& stg, const char* const* eff_headers, int eff_cols,
const std::vector<ColumnType>& eff_types);
void draw_add_filter_popup(Stage& stg, const char* const* eff_headers, int eff_cols,
const std::vector<ColumnType>& eff_types);
void draw_edit_filter_popup(Stage& stg, const char* const* headers, int n_cols,
const std::vector<ColumnType>& types);
// ---------------------------------------------------------------------------
// Breakouts (group-by)
// ---------------------------------------------------------------------------
void draw_breakout_chips(Stage& stg, const char* const* in_headers, int in_cols,
const std::vector<ColumnType>& in_types);
void draw_add_breakout_popup(Stage& stg, const char* const* in_headers, int in_cols,
const std::vector<ColumnType>& in_types,
const char* const* cur_cells, int cur_rows);
void draw_edit_breakout_popup(Stage& stg, const char* const* headers, int n_cols);
// ---------------------------------------------------------------------------
// Agregaciones
// ---------------------------------------------------------------------------
void draw_aggregation_chips(Stage& stg, const char* const* in_headers, int in_cols);
void draw_add_aggregation_popup(Stage& stg, const char* const* in_headers, int in_cols,
const std::vector<ColumnType>& in_types);
void draw_edit_agg_popup(Stage& stg, const char* const* headers, int n_cols);
// ---------------------------------------------------------------------------
// Joins
// ---------------------------------------------------------------------------
// draw_joins_chips: chips de join con las tablas joinables. Solo visible si
// joinables no esta vacio. Mutates st.stages y la configuracion de join.
void draw_joins_chips(State& st, const std::vector<TableInput>& joinables,
const char* const* eff_headers, int eff_cols,
const std::vector<ColumnType>& eff_types);
// ---------------------------------------------------------------------------
// Header menu (right-click en cabecera de columna)
// ---------------------------------------------------------------------------
// draw_header_menu: popup que aparece al right-click en el header de columna
// `col`. Incluye sort, filter, conditional color, type change, etc.
// is_raw_stage: true si estamos en stage 0 (permite "Change type" / "Derived").
void draw_header_menu(State& st, Stage& stg, int col,
const char* const* eff_headers_arr, int eff_cols,
const std::vector<ColumnType>& eff_types,
int orig_cols, bool is_raw_stage);
// ---------------------------------------------------------------------------
// TQL bar (Show TQL / Apply TQL / Save .tql / Load .tql)
// Area de chrome adicional en stage 0. Nuevo helper que extrae el bloque
// de lineas 3272-3366 del render() original.
// ---------------------------------------------------------------------------
// TqlBarState: estado del area TQL (vive en UiState).
struct TqlBarState {
bool show_open = false;
std::string show_text;
bool apply_open = false;
std::string apply_text;
std::string apply_error;
char file_path[256] = "table.tql";
std::string io_status; // "saved: ..." / "loaded: ..." / "load FAILED: ..."
};
// draw_tql_bar: dibuja los botones Show TQL, Apply TQL, Save .tql, Load .tql
// y los modales correspondientes. Mutates st via tql::apply en Apply.
// active_headers/types/cells/row_count/orig_cols: necesarios para tql::emit + apply.
void draw_tql_bar(TqlBarState& tql_bar,
State& st,
const std::vector<std::string>& orig_headers,
const std::vector<ColumnType>& orig_types,
const char* const* cells,
int row_count, int orig_cols);
} // namespace data_table
+116
View File
@@ -0,0 +1,116 @@
---
name: data_table_chips
kind: function
lang: cpp
domain: viz
version: "1.0.0"
purity: impure
signature: "void data_table::draw_filter_chips(Stage& stg, const char* const* eff_headers, int eff_cols, const std::vector<ColumnType>& eff_types)"
description: "Barra de chips superior de la tabla TQL: render y edicion de filtros activos, breakouts (group-by), agregaciones, sorts, joins con tablas secundarias, header-menu de columna (sort/filter/conditional-color/type-change), y area TQL (Show TQL / Apply TQL / Save .tql / Load .tql). Es la sub-funcion mas grande del refactor 0107c (~1000 LOC, 17 funciones). Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c)."
tags: [viz, table, imgui, ui, chips, filters, sort, aggregation, tql, cpp-tables, joins]
uses_functions:
- data_table_color_rules_cpp_viz
- data_table_cpp_viz
- tql_emit_cpp_core
- tql_apply_cpp_core
uses_types:
- data_table_types_cpp_core
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [imgui]
tested: false
tests: []
test_file_path: ""
file_path: "cpp/functions/viz/data_table_chips.cpp"
framework: imgui
params:
- name: stg
desc: "Stage activo (st.stages[active]): contiene filters, breakouts, aggregations, sorts, derived. Mutado por todos los chips y popups de edicion."
- name: eff_headers / eff_cols
desc: "Headers y numero de columnas efectivas del stage activo (orig + derived). Usados en labels de chips y en los popups de anadir/editar."
- name: eff_types
desc: "Tipos de columna efectivos. Usados para filtrar los operadores disponibles (draw_typed_ops) y para formatear los popups."
- name: st (para joins y header-menu)
desc: "State completo: necesario para draw_joins_chips (accede a st.stages) y draw_header_menu (accede a st.color_rules, st.col_visible)."
- name: joinables (para joins)
desc: "Vector de TableInput secundarias disponibles para join. Si vacio, draw_joins_chips no muestra nada."
output: "Void. Todos los efectos son mutaciones de stg (Stage) o st (State) en respuesta a la interaccion del usuario."
---
## Documentacion
Sub-funcion mas grande del refactor 0107c. Encapsula toda la UI de la barra de chips de la tabla TQL.
### Mapa de funciones
| Funcion | LOC aprox | Responsabilidad |
|---|---|---|
| `draw_filter_chips` | ~85 | Chips de filtros activos con X para borrar |
| `draw_add_filter_popup` | ~55 | Popup para anadir filtro nuevo |
| `draw_edit_filter_popup` | ~42 | Popup edicion de filtro existente (right-click en chip) |
| `draw_breakout_chips` | ~93 | Chips de breakout activos con X |
| `draw_add_breakout_popup` | ~34 | Popup anadir breakout (col de agrupacion) |
| `draw_edit_breakout_popup` | ~24 | Popup edicion breakout |
| `draw_aggregation_chips` | ~50 | Chips de agregaciones activas con X |
| `draw_add_aggregation_popup` | ~50 | Popup anadir agregacion (Count, Sum, Mean, ...) |
| `draw_edit_agg_popup` | ~48 | Popup edicion agregacion |
| `draw_sort_chips` | ~70 | Chips de sort activos con X |
| `draw_add_sort_popup` | ~26 | Popup anadir sort |
| `draw_edit_sort_popup` | ~26 | Popup edicion sort |
| `apply_header_sort_click` | ~17 | Sort al click en cabecera (Asc/Desc/none ciclo) |
| `draw_joins_chips` | ~107 | Chips de joins con tablas secundarias |
| `draw_header_menu` | ~209 | Menu contextual de columna (sort/filter/color/type) |
| `draw_typed_ops` | ~8 | Radio buttons de Op segun tipo de columna |
| `type_supports_range` | ~3 | Bool tipo soporta rango |
| `draw_tql_bar` | ~95 | Botones + modales TQL show/apply/save/load |
### Orden de llamada en el chrome
```cpp
// Stage 0 (chrome_visible):
draw_joins_chips(st, joinables, hdrs, cols, types);
draw_filter_chips(act, hdrs, cols, types);
draw_add_filter_popup(act, hdrs, cols, types);
draw_edit_filter_popup(act, hdrs, cols, types);
draw_breakout_chips(act, hdrs, cols, types);
// ... etc.
draw_sort_chips(act);
draw_tql_bar(tql_bar, st, orig_headers, orig_types, cells, rows, orig_cols);
```
## Ejemplo
```cpp
// Bloque chrome stage 0:
if (chrome_visible) {
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8, 2));
data_table::draw_filter_chips(act, eff_headers, eff_cols, eff_types);
data_table::draw_add_filter_popup(act, eff_headers, eff_cols, eff_types);
data_table::draw_edit_filter_popup(act, eff_headers, eff_cols, eff_types);
data_table::draw_breakout_chips(act, eff_headers, eff_cols, eff_types);
// ...
data_table::draw_sort_chips(act);
data_table::draw_add_sort_popup(act, eff_headers, eff_cols, eff_types);
data_table::draw_edit_sort_popup(act, eff_headers, eff_cols);
data_table::draw_tql_bar(U.tql_bar, st, orig_h, orig_t, cells, rows, orig_cols);
ImGui::PopStyleVar();
}
```
## Cuando usarla
Llamar desde el entrypoint thin `data_table::render()` en el bloque `chrome_visible`. El orden importa: joins primero, luego filtros, breakouts, agregaciones, sorts, TQL bar. No llamar fuera del contexto de un frame ImGui activo.
## Gotchas
- `draw_header_menu` DEBE llamarse desde dentro del popup de cabecera (`ImGui::BeginPopupContextItem`) — no se puede llamar en el loop normal de render.
- Los popups de add/edit usan IDs de ImGui fijos (`"##addf"`, `"##editf"`, etc.). Si se tienen multiples instancias de data_table en la misma ventana, usar `ImGui::PushID(table_id)` antes de llamar a los chips.
- `draw_tql_bar` incluye los modales "Show TQL" y "Apply TQL" via `ImGui::BeginPopupModal`. Deben llamarse fuera del bloque `PushStyleVar` si los estilos interfieren con el modal.
- `apply_header_sort_click` sin Shift reemplaza TODOS los sorts existentes por el nuevo. Es el comportamiento esperado para click simple. Shift agrega sort secundario.
- `draw_joins_chips` asume que `joinables` es la slice de `tables` excluyendo la tabla principal. Si se pasa la tabla principal como joinable, se creara un self-join.
@@ -0,0 +1,353 @@
// data_table_color_rules — editor de reglas de color por columna + aplicacion.
// Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c).
//
// Responsabilidad:
// - Helpers de color: auto_categorical_color, resolve_categorical_dot_color,
// apply_color_rules_for_cell.
// - UI del submenu "Conditional color" dentro del header-menu de columnas:
// draw_color_rule_menu.
//
// hex_to_imcolor, parse_hex_color, lerp_color_along_stops son helpers de color
// puros. Se definen como static aqui (no se exportan) porque data_table.cpp
// tiene sus propias versiones static tambien — ambos TU son independientes.
#include "viz/data_table_color_rules.h"
#include "data_table/data_table_internal.h"
#include "imgui.h"
#include "core/data_table_types.h"
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <cmath>
#include <limits>
#include <vector>
namespace {
// hex_to_imcolor: "#rrggbb" -> ImVec4. Returns {-1,-1,-1,-1} on failure.
static ImVec4 hex_to_imcolor(const std::string& hex) {
const char* p = hex.c_str();
if (*p == '#') ++p;
unsigned int r = 0, g = 0, b = 0;
if (std::sscanf(p, "%02x%02x%02x", &r, &g, &b) != 3)
return ImVec4(-1.f, -1.f, -1.f, -1.f);
return ImVec4(r / 255.f, g / 255.f, b / 255.f, 1.f);
}
// lerp_color_along_stops: LERP entre N color stops en [0,1].
static ImU32 lerp_color_along_stops(
const std::vector<data_table::ColorStop>& stops, float t, float alpha)
{
static const std::vector<data_table::ColorStop> kDefault = {
{0.0f, "#22c55e"},
{0.5f, "#f59e0b"},
{1.0f, "#ef4444"},
};
const auto& sv = stops.empty() ? kDefault : stops;
std::vector<data_table::ColorStop> sorted_sv = sv;
std::sort(sorted_sv.begin(), sorted_sv.end(),
[](const data_table::ColorStop& a, const data_table::ColorStop& b){
return a.position < b.position;
});
t = t < 0.f ? 0.f : (t > 1.f ? 1.f : t);
if (t <= sorted_sv.front().position)
return data_table::parse_hex_color(sorted_sv.front().color, alpha);
if (t >= sorted_sv.back().position)
return data_table::parse_hex_color(sorted_sv.back().color, alpha);
for (size_t i = 0; i + 1 < sorted_sv.size(); ++i) {
const auto& lo = sorted_sv[i];
const auto& hi = sorted_sv[i + 1];
if (t >= lo.position && t <= hi.position) {
float span = hi.position - lo.position;
float f = (span > 1e-6f) ? (t - lo.position) / span : 0.f;
ImVec4 ca = hex_to_imcolor(lo.color);
ImVec4 cb = hex_to_imcolor(hi.color);
if (ca.x < 0.f) ca = ImVec4(0.5f, 0.5f, 0.5f, 1.f);
if (cb.x < 0.f) cb = ImVec4(0.5f, 0.5f, 0.5f, 1.f);
float r = ca.x + f * (cb.x - ca.x);
float g = ca.y + f * (cb.y - ca.y);
float b = ca.z + f * (cb.z - ca.z);
unsigned int ri = (unsigned int)(r * 255.f + 0.5f);
unsigned int gi = (unsigned int)(g * 255.f + 0.5f);
unsigned int bi = (unsigned int)(b * 255.f + 0.5f);
unsigned int ai = (unsigned int)(alpha * 255.f + 0.5f);
return IM_COL32(ri, gi, bi, ai);
}
}
return data_table::parse_hex_color(sorted_sv.back().color, alpha);
}
} // anon namespace
namespace data_table {
// ---------------------------------------------------------------------------
// parse_hex_color: "#rrggbb" / "#rrggbbaa" -> ImU32 with explicit alpha.
// Declared in data_table_color_rules.h (public API — used by data_table.cpp
// cell renderers and by apply_color_rules_for_cell).
// ---------------------------------------------------------------------------
ImU32 parse_hex_color(const std::string& hex, float alpha)
{
const char* p = hex.c_str();
if (*p == '#') ++p;
unsigned int r = 0, g = 0, b = 0, a = 255;
int parsed = std::sscanf(p, "%02x%02x%02x%02x", &r, &g, &b, &a);
if (parsed < 3) return IM_COL32(128, 128, 128, 255);
if (parsed == 3) {
a = (unsigned int)(alpha * 255.f + 0.5f);
}
return IM_COL32(r, g, b, a);
}
// ---------------------------------------------------------------------------
// auto_categorical_color: deterministic palette for a string value.
// Hashes the value to an index in a fixed 12-color palette (Tailwind-ish).
// Same value always maps to the same color. FNV-1a 32-bit.
// ---------------------------------------------------------------------------
ImU32 auto_categorical_color(const char* value, float alpha)
{
static const unsigned int kPalette[] = {
0xFFef4444u, // red-500
0xFFf97316u, // orange-500
0xFFf59e0bu, // amber-500
0xFF84cc16u, // lime-500
0xFF22c55eu, // green-500
0xFF14b8a6u, // teal-500
0xFF06b6d4u, // cyan-500
0xFF3b82f6u, // blue-500
0xFF8b5cf6u, // violet-500
0xFFa855f7u, // purple-500
0xFFec4899u, // pink-500
0xFFd946efu, // fuchsia-500
};
constexpr size_t N = sizeof(kPalette) / sizeof(kPalette[0]);
uint32_t h = 2166136261u;
for (const char* p = value ? value : ""; *p; ++p) {
h ^= (uint8_t)(*p);
h *= 16777619u;
}
unsigned int packed = kPalette[h % N];
unsigned int a = (unsigned int)(alpha * 255.f + 0.5f);
unsigned int rgb = packed & 0x00FFFFFFu;
return rgb | (a << 24);
}
// ---------------------------------------------------------------------------
// resolve_categorical_dot_color: look up dot color from rule's dot_map; fall
// back to auto_categorical_color if no match.
// ---------------------------------------------------------------------------
ImU32 resolve_categorical_dot_color(const ColorRule& cr, const char* value)
{
if (!cr.dot_map.empty()) {
for (const auto& kv : cr.dot_map) {
if (kv.first == (value ? value : ""))
return parse_hex_color(kv.second, cr.dot_alpha);
}
}
return auto_categorical_color(value, cr.dot_alpha);
}
// parse_cell_number: parse a cell string as double. Returns NaN on failure.
// Only needed here (not shared).
static double parse_cell_number(const char* s) {
if (!s || !*s) return std::numeric_limits<double>::quiet_NaN();
char* end = nullptr;
double v = std::strtod(s, &end);
if (end == s) return std::numeric_limits<double>::quiet_NaN();
return v;
}
// ---------------------------------------------------------------------------
// apply_color_rules_for_cell: applies all matching ColorRules for column `c`.
// Must be called AFTER ImGui::TableSetColumnIndex and BEFORE cell renderer.
// ---------------------------------------------------------------------------
void apply_color_rules_for_cell(const State& st,
int c, const char* cell,
ImVec2 cell_min, float cell_w, float cell_h,
float& dot_advance_px)
{
dot_advance_px = 0.f;
ImDrawList* draw = ImGui::GetWindowDrawList();
for (const auto& cr : st.color_rules) {
if (cr.col != c) continue;
switch (cr.kind) {
case ColorRuleKind::CellBg: {
if (cell && cr.equals == cell) {
ImGui::TableSetBgColor(ImGuiTableBgTarget_CellBg, (ImU32)cr.color);
}
break;
}
case ColorRuleKind::NumericRange: {
double v = parse_cell_number(cell);
if (std::isnan(v)) break;
double span = cr.range_max - cr.range_min;
float t = (span > 1e-12) ? (float)((v - cr.range_min) / span) : 0.5f;
ImU32 tint = lerp_color_along_stops(cr.range_stops, t, cr.range_alpha);
ImGui::TableSetBgColor(ImGuiTableBgTarget_CellBg, tint);
break;
}
case ColorRuleKind::CategoricalDot: {
ImU32 dot = resolve_categorical_dot_color(cr, cell);
float radius = cr.dot_radius_px > 0.f ? cr.dot_radius_px : 4.0f;
ImVec2 c1(cell_min.x + radius + 2.f,
cell_min.y + cell_h * 0.5f);
draw->AddCircleFilled(c1, radius, dot, 12);
dot_advance_px = radius * 2.f + 6.f;
(void)cell_w;
break;
}
}
}
}
// ---------------------------------------------------------------------------
// draw_color_rule_menu: dibuja el submenu "Conditional color" para la columna
// `col`. Retorna true si el usuario hizo click en "Apply" (la regla ya fue
// anadida a st.color_rules).
//
// editor_st: ColorRuleEditorState de UiState (draft state per columna).
// col_type: tipo efectivo de la columna (para auto-seleccion de modo).
// ---------------------------------------------------------------------------
bool draw_color_rule_menu(State& st, int col, ColumnType col_type,
ColorRuleEditorState& editor_st)
{
bool applied = false;
// Auto-pick sensible default on first open for this column.
if (editor_st.color_rule_kind.find(col) == editor_st.color_rule_kind.end()) {
editor_st.color_rule_kind[col] =
(col_type == ColumnType::Int || col_type == ColumnType::Float) ? 2 : 0;
}
int& kind_i = editor_st.color_rule_kind[col]; // 0=CellBg, 1=CatDot, 2=NumRange
ImGui::TextDisabled("Mode");
ImGui::RadioButton("Cell bg", &kind_i, 0); ImGui::SameLine();
ImGui::RadioButton("Categorical dot", &kind_i, 1); ImGui::SameLine();
ImGui::RadioButton("Numeric range", &kind_i, 2);
ImGui::Separator();
bool apply_clicked = false;
ColorRule draft;
draft.col = col;
if (kind_i == 0) {
// ---- Cell bg ----
auto& vbuf = editor_st.color_value_inputs[col];
vbuf.resize(256, '\0');
if (editor_st.color_picker_vals.find(col) == editor_st.color_picker_vals.end())
editor_st.color_picker_vals[col] = ImVec4(0.85f, 0.40f, 0.30f, 0.60f);
ImVec4& cv = editor_st.color_picker_vals[col];
ImGui::SetNextItemWidth(180);
ImGui::InputText("equals", vbuf.data(), vbuf.size());
ImGui::ColorEdit4("color", &cv.x, ImGuiColorEditFlags_NoInputs);
if (ImGui::Button("Apply##cellbg")) {
draft.kind = ColorRuleKind::CellBg;
draft.equals = std::string(vbuf.c_str());
draft.color = (unsigned int)ImGui::ColorConvertFloat4ToU32(cv);
apply_clicked = true;
}
}
else if (kind_i == 1) {
// ---- Categorical dot ----
auto& d = editor_st.cat_dot_drafts[col];
ImGui::TextWrapped("Dibuja un punto coloreado al inicio de cada celda.");
ImGui::TextWrapped("Color asignado automaticamente por valor (palette de 12).");
ImGui::SetNextItemWidth(120);
ImGui::SliderFloat("alpha", &d.alpha, 0.2f, 1.0f);
ImGui::SetNextItemWidth(120);
ImGui::SliderFloat("radius px", &d.radius_px, 2.0f, 9.0f);
// Preview swatches (8 sample values).
ImGui::TextDisabled("preview:");
ImGui::SameLine();
const char* samples[] = {"A","B","C","D","E","F","G","H"};
for (auto* s : samples) {
ImU32 col_u = auto_categorical_color(s, d.alpha);
ImVec2 p = ImGui::GetCursorScreenPos();
ImGui::GetWindowDrawList()->AddCircleFilled(
ImVec2(p.x + d.radius_px, p.y + ImGui::GetTextLineHeight() * 0.5f),
d.radius_px, col_u, 12);
ImGui::Dummy(ImVec2(d.radius_px * 2.f + 2.f, ImGui::GetTextLineHeight()));
ImGui::SameLine();
}
ImGui::NewLine();
if (ImGui::Button("Apply##catdot")) {
draft.kind = ColorRuleKind::CategoricalDot;
draft.dot_alpha = d.alpha;
draft.dot_radius_px = d.radius_px;
apply_clicked = true;
}
}
else if (kind_i == 2) {
// ---- Numeric range (3-color gradient) ----
auto& d = editor_st.num_range_drafts[col];
ImGui::SetNextItemWidth(110);
ImGui::InputDouble("min", &d.min);
ImGui::SameLine();
ImGui::SetNextItemWidth(110);
ImGui::InputDouble("max", &d.max);
ImGui::SetNextItemWidth(140);
ImGui::SliderFloat("alpha", &d.alpha, 0.05f, 0.9f);
ImGui::SetNextItemWidth(100);
ImGui::InputText("lo color (hex)", d.c_lo, sizeof(d.c_lo));
ImGui::SetNextItemWidth(100);
ImGui::InputText("mid color (hex)", d.c_md, sizeof(d.c_md));
ImGui::SetNextItemWidth(100);
ImGui::InputText("hi color (hex)", d.c_hi, sizeof(d.c_hi));
// Preview gradient bar (32 lerped cells).
std::vector<ColorStop> preview_stops = {
{0.0f, std::string("#") + d.c_lo},
{0.5f, std::string("#") + d.c_md},
{1.0f, std::string("#") + d.c_hi},
};
ImVec2 p0 = ImGui::GetCursorScreenPos();
float W = 256.f, H = 16.f;
for (int i = 0; i < 32; ++i) {
float t = i / 31.f;
ImU32 col_u = lerp_color_along_stops(preview_stops, t, 1.0f);
ImGui::GetWindowDrawList()->AddRectFilled(
ImVec2(p0.x + (W / 32.f) * i, p0.y),
ImVec2(p0.x + (W / 32.f) * (i + 1), p0.y + H),
col_u);
}
ImGui::Dummy(ImVec2(W, H + 4.f));
if (ImGui::Button("Apply##numrange")) {
draft.kind = ColorRuleKind::NumericRange;
draft.range_min = d.min;
draft.range_max = d.max;
draft.range_alpha = d.alpha;
draft.range_stops = preview_stops;
apply_clicked = true;
}
}
if (apply_clicked) {
// Replace any existing rule of same kind on this col (one per kind).
for (size_t i = 0; i < st.color_rules.size();) {
if (st.color_rules[i].col == col &&
st.color_rules[i].kind == draft.kind) {
st.color_rules.erase(st.color_rules.begin() + (int)i);
} else {
++i;
}
}
st.color_rules.push_back(draft);
ImGui::CloseCurrentPopup();
applied = true;
}
ImGui::SameLine();
if (ImGui::Button("Clear col")) {
for (size_t i = 0; i < st.color_rules.size();) {
if (st.color_rules[i].col == col)
st.color_rules.erase(st.color_rules.begin() + (int)i);
else
++i;
}
}
return applied;
}
} // namespace data_table
@@ -0,0 +1,91 @@
#pragma once
// data_table_color_rules — editor de reglas de color por columna + aplicacion.
// Sub-funcion extraida de modules/data_table/data_table.cpp.
// Issue 0107c. Wave de refactor para partir el god-file 4777 LOC.
//
// Responsabilidad: helpers estaticos de color (parse_hex_color, resolve_categorical_dot_color,
// apply_color_rules_for_cell, auto_categorical_color) + UI del submenu "Conditional color"
// dentro del header-menu de columnas.
//
// Dependencias: data_table_types.h (ColorRule, ColorRuleKind, State).
// imgui.h, core/icons_tabler.h.
// NO depende de UiState global — recibe el draft state que necesita por referencia.
#include "core/data_table_types.h"
#include "imgui.h"
#include <string>
#include <unordered_map>
namespace data_table {
// ---------------------------------------------------------------------------
// Helpers de color (pure/static, no ImGui state).
// ---------------------------------------------------------------------------
// auto_categorical_color: palette determinista de 12 colores para un valor
// string. Mismo valor -> mismo color siempre. v1.5.0.
ImU32 auto_categorical_color(const char* value, float alpha = 1.0f);
// resolve_categorical_dot_color: busca en dot_map de la regla; fallback a
// auto_categorical_color si no hay match.
ImU32 resolve_categorical_dot_color(const ColorRule& cr, const char* value);
// parse_hex_color: "#rrggbb" / "#rrggbbaa" -> ImU32 con alpha explicitamente.
// Fallback a gris IM_COL32(128,128,128,255) en error.
ImU32 parse_hex_color(const std::string& hex, float alpha = 1.0f);
// apply_color_rules_for_cell: aplica todas las ColorRules para la columna `c`
// ANTES del renderer de celda. Llama ImGui::TableSetBgColor para CellBg,
// dibuja dots para CategoricalDot, y pinta overlay de rango para NumericRange.
// `dot_advance_px` (out): pixeles a avanzar en x antes de texto (por dot).
// Debe llamarse justo despues de ImGui::TableSetColumnIndex, antes del renderer.
void apply_color_rules_for_cell(const State& st,
int c, const char* cell,
ImVec2 cell_min, float cell_w, float cell_h,
float& dot_advance_px);
// ---------------------------------------------------------------------------
// UI del editor de reglas de color (submenu dentro del header-menu de cols).
// Llamada desde draw_header_menu cuando el usuario abre "Conditional color".
// Mutates `st.color_rules` al hacer click en "Apply".
// ---------------------------------------------------------------------------
// ColorRuleEditorState: estado de draft del editor de reglas de color.
// Un ColorRuleEditorState vive en UiState (singleton por instancia de tabla).
// Guarda los drafts per-column en maps indexados por columna.
struct ColorRuleEditorState {
// 0=CellBg, 1=CategoricalDot, 2=NumericRange — per column.
std::unordered_map<int, int> color_rule_kind;
// CellBg draft per col.
std::unordered_map<int, std::string> color_value_inputs;
std::unordered_map<int, ImVec4> color_picker_vals;
// NumericRange draft per col.
struct NumRangeDraft {
double min = 0.0;
double max = 100.0;
float alpha = 0.25f;
char c_lo[8] = "22c55e"; // green-500
char c_md[8] = "f59e0b"; // amber-500
char c_hi[8] = "ef4444"; // red-500
};
std::unordered_map<int, NumRangeDraft> num_range_drafts;
// CategoricalDot draft per col.
struct CatDotDraft {
float alpha = 1.0f;
float radius_px = 4.0f;
};
std::unordered_map<int, CatDotDraft> cat_dot_drafts;
};
// draw_color_rule_menu: dibuja el submenu "Conditional color" para la columna
// `col`. Retorna true si el usuario hizo click en "Apply" (la regla ya fue
// anadida a st.color_rules por la funcion).
// editor_st: draft state per tabla (ColorRuleEditorState de UiState).
// col_type: tipo efectivo de la columna (para auto-seleccion de modo).
bool draw_color_rule_menu(State& st, int col, ColumnType col_type,
ColorRuleEditorState& editor_st);
} // namespace data_table
@@ -0,0 +1,95 @@
---
name: data_table_color_rules
kind: function
lang: cpp
domain: viz
version: "1.0.0"
purity: impure
signature: "void data_table::apply_color_rules_for_cell(const State& st, int col, const char* cell, ImVec2 cell_min, float cell_w, float cell_h, float& dot_advance_px)"
description: "Helpers de color (parse_hex_color, auto_categorical_color, resolve_categorical_dot_color, apply_color_rules_for_cell) + editor UI del submenu Conditional color para columnas de tabla. Soporta tres modos: CellBg (igualdad exacta), CategoricalDot (punto coloreado por valor via palette determinista de 12 colores), NumericRange (degradado lo/mid/hi por rango). Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c)."
tags: [viz, table, imgui, ui, color-rules, tql, cpp-tables]
uses_functions:
- data_table_cpp_viz
uses_types:
- data_table_types_cpp_core
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [imgui]
tested: false
tests: []
test_file_path: ""
file_path: "cpp/functions/viz/data_table_color_rules.cpp"
framework: imgui
params:
- name: st
desc: "State del data_table que contiene st.color_rules (vector de ColorRule). Solo lectura en apply_color_rules_for_cell; mutado por draw_color_rule_menu al anadir reglas."
- name: col
desc: "Indice de columna (0-based en el output visible del stage activo)."
- name: cell
desc: "Valor de la celda como C-string nullable. Null se trata como cadena vacia."
- name: cell_min
desc: "Esquina top-left de la celda en coordenadas de pantalla (ImGui::GetItemRectMin o equivalente). Usado para posicionar dots CategoricalDot."
- name: cell_w / cell_h
desc: "Anchura y altura de la celda en pixeles. Usados para centrar el dot verticalmente y calcular la zona de fondo NumericRange."
- name: dot_advance_px
desc: "Salida: pixeles a avanzar en X antes de renderizar texto (reserva el espacio del dot). 0 si no hay regla CategoricalDot activa para esta columna."
output: "Void para apply_color_rules_for_cell (efectos: TableSetBgColor + draw dot). Bool para draw_color_rule_menu (true = regla anadida a st.color_rules)."
---
## Documentacion
Sub-funcion que encapsula toda la logica de color condicional por celda de la tabla TQL. Se extrae de `modules/data_table/data_table.cpp` como parte del issue 0107c (partir el god-file 4777 LOC).
### Funciones publicas
| Funcion | Uso |
|---|---|
| `parse_hex_color(hex, alpha)` | Convierte `"#rrggbb"` / `"#rrggbbaa"` a `ImU32`. Fallback gris en error. |
| `auto_categorical_color(value, alpha)` | Palette determinista 12 colores via FNV-1a hash. Mismo valor → mismo color siempre. |
| `resolve_categorical_dot_color(cr, value)` | Busca en `cr.dot_map`; fallback a `auto_categorical_color`. |
| `apply_color_rules_for_cell(st, col, cell, ...)` | Aplica todas las reglas activas para `col`. Llamar ANTES del renderer de celda, despues de `TableSetColumnIndex`. |
| `draw_color_rule_menu(st, col, col_type, editor_st)` | UI del submenu "Conditional color" (dentro del header-menu). Mutates `st.color_rules` en Apply. |
### Modo CellBg
Igualdad exacta: si `cell == rule.equals` pinta el fondo con `rule.color`.
### Modo CategoricalDot
Dibuja un circulo coloreado a la izquierda de cada celda. Color determinado por `resolve_categorical_dot_color`. `dot_advance_px` retorna el offset para que el texto no solape el punto.
### Modo NumericRange
Parsea la celda como double. Interpola entre tres colores (lo/mid/hi) segun el valor relativo en `[rule.range_min, rule.range_max]`. Pinta fondo con `TableSetBgColor`.
## Ejemplo
```cpp
// En el render del grid, por cada celda antes del renderer:
float dot_px = 0.f;
data_table::apply_color_rules_for_cell(st, col, cell_value,
ImGui::GetItemRectMin(), col_width, row_height, dot_px);
if (dot_px > 0.f)
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + dot_px);
// ... renderer normal ...
// Para anadir una regla desde el header-menu:
data_table::ColorRuleEditorState editor;
if (ImGui::BeginMenu("Conditional color")) {
data_table::draw_color_rule_menu(st, col, col_type, editor);
ImGui::EndMenu();
}
```
## Cuando usarla
Usarla cuando renderices celdas individuales de la tabla TQL (llamar desde `data_table_grid`) y cuando muestres el menu de cabecera de columna que permita al usuario definir reglas de color. NO llamarla fuera del contexto de un `ImGui::BeginTable / EndTable` activo.
## Gotchas
- `apply_color_rules_for_cell` DEBE llamarse despues de `ImGui::TableSetColumnIndex` y ANTES del renderer de celda. Si se invierte el orden, `TableSetBgColor` no afecta a la celda correcta.
- El dot CategoricalDot requiere que el draw list de la ventana este activo (`ImGui::GetWindowDrawList()`). No llamar desde un contexto de renderizado diferido.
- `parse_hex_color` acepta strings sin `#` pero si el string tiene caracteres no hex devuelve gris fallback — no paniquea.
- `auto_categorical_color` usa FNV-1a 32-bit: con >12 valores distintos habra colisiones (intencional, visualmente aceptable).
- En la extraccion desde data_table.cpp (tarea 3.8) hay que eliminar la declaracion `static` de `parse_hex_color` y `auto_categorical_color` en el .cpp original y hacer forward-include a este header.
+204
View File
@@ -0,0 +1,204 @@
// data_table_drill — drill-down stack + breadcrumb de stages.
// Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c).
//
// Rangos de lineas del fuente original:
// - make_drill_filter : lineas 699-706
// - apply_drill_step : lineas 708-718
// - undo_drill_step : lineas 720-730
// - drill_up : lineas 732-737
// - draw_stage_breadcrumb : lineas 1386-1483
// - drill_into : lineas 2898-2919
#include "viz/data_table_drill.h"
#include "core/data_table_types.h"
#include "imgui.h"
#include <cstdio>
#include <string>
#include <vector>
namespace data_table {
// ---------------------------------------------------------------------------
// make_drill_filter: crea un Filter Op::Eq para col_idx con el valor dado.
// ---------------------------------------------------------------------------
Filter make_drill_filter(int col_idx, const std::string& value) {
Filter f;
f.col = col_idx;
f.op = Op::Eq;
f.value = value;
return f;
}
// ---------------------------------------------------------------------------
// apply_drill_step: inserta step.added en el stage en step.target_stage y
// actualiza st.active_stage. Retorna true si el step se aplico correctamente.
// ---------------------------------------------------------------------------
bool apply_drill_step(State& st, const DrillStep& step) {
if (step.target_stage < 0 || step.target_stage >= (int)st.stages.size()) return false;
Stage& s = st.stages[step.target_stage];
int pos = step.filter_pos;
if (pos < 0 || pos > (int)s.filters.size()) return false;
s.filters.insert(s.filters.begin() + pos, step.added);
st.active_stage = step.target_stage;
return true;
}
// ---------------------------------------------------------------------------
// undo_drill_step: elimina el filter insertado por apply_drill_step y restaura
// st.active_stage a step.prev_active_stage. Retorna true si se deshizo.
// ---------------------------------------------------------------------------
bool undo_drill_step(State& st, const DrillStep& step) {
if (step.target_stage < 0 || step.target_stage >= (int)st.stages.size()) return false;
Stage& s = st.stages[step.target_stage];
int pos = step.filter_pos;
if (pos < 0 || pos >= (int)s.filters.size()) return false;
s.filters.erase(s.filters.begin() + pos);
if (step.prev_active_stage >= 0 && step.prev_active_stage < (int)st.stages.size())
st.active_stage = step.prev_active_stage;
return true;
}
// ---------------------------------------------------------------------------
// drill_up: retrocede st.active_stage en 1 si hay stages previos.
// Retorna true si se pudo retroceder.
// ---------------------------------------------------------------------------
bool drill_up(State& st) {
if (st.stages.empty()) return false;
if (st.active_stage <= 0) return false;
st.active_stage -= 1;
return true;
}
// ---------------------------------------------------------------------------
// draw_stage_breadcrumb: barra de navegacion de drill con botones < > ^ y
// selector de stages. Mutates st.drill_back/forward y st.active_stage.
// ---------------------------------------------------------------------------
void draw_stage_breadcrumb(State& st) {
st.ensure_stage0();
// Drill history back/forward (fase 10). Botones al inicio.
{
bool can_back = !st.drill_back.empty();
ImGui::BeginDisabled(!can_back);
if (ImGui::SmallButton("<##drill_back")) {
DrillStep s = st.drill_back.back();
st.drill_back.pop_back();
if (undo_drill_step(st, s)) {
st.drill_forward.push_back(s);
}
}
ImGui::EndDisabled();
if (can_back && ImGui::IsItemHovered())
ImGui::SetTooltip("Drill back (%zu)", st.drill_back.size());
ImGui::SameLine();
bool can_fwd = !st.drill_forward.empty();
ImGui::BeginDisabled(!can_fwd);
if (ImGui::SmallButton(">##drill_fwd")) {
DrillStep s = st.drill_forward.back();
st.drill_forward.pop_back();
if (apply_drill_step(st, s)) {
st.drill_back.push_back(s);
}
}
ImGui::EndDisabled();
if (can_fwd && ImGui::IsItemHovered())
ImGui::SetTooltip("Drill forward (%zu)", st.drill_forward.size());
ImGui::SameLine();
bool can_up = (st.active_stage > 0);
ImGui::BeginDisabled(!can_up);
if (ImGui::SmallButton("^##drill_up")) drill_up(st);
ImGui::EndDisabled();
if (can_up && ImGui::IsItemHovered())
ImGui::SetTooltip("Drill up (stage previo, sin perder filters)");
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
}
for (int si = 0; si < (int)st.stages.size(); ++si) {
if (si > 0) { ImGui::SameLine(); ImGui::TextDisabled(">"); ImGui::SameLine(); }
bool active = (si == st.active_stage);
if (active) {
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 80, 140, 200, 240));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(100, 160, 220, 240));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 60, 120, 180, 240));
} else {
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 70, 70, 90, 200));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32( 90, 90, 120, 220));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 55, 55, 75, 220));
}
char label[256];
if (si == 0) {
std::snprintf(label, sizeof(label), "Raw##stage%d", si);
} else {
const Stage& s = st.stages[si];
std::string desc;
for (size_t i = 0; i < s.breakouts.size() && i < 2; ++i) {
if (i > 0) desc += ", ";
desc += s.breakouts[i];
}
if (s.breakouts.size() > 2) desc += "...";
if (desc.empty())
std::snprintf(label, sizeof(label), "Stage %d##s%d", si, si);
else
std::snprintf(label, sizeof(label), "Stage %d: by %s##s%d",
si, desc.c_str(), si);
}
if (ImGui::Button(label)) st.active_stage = si;
ImGui::PopStyleColor(3);
if (si > 0) {
ImGui::SameLine();
char xlbl[32];
std::snprintf(xlbl, sizeof(xlbl), "x##rm_s%d", si);
if (ImGui::SmallButton(xlbl)) {
// borra ese stage y sucesores
while ((int)st.stages.size() > si) st.stages.pop_back();
if (st.active_stage >= (int)st.stages.size())
st.active_stage = (int)st.stages.size() - 1;
if (st.active_stage < 0) st.active_stage = 0;
break;
}
}
}
ImGui::SameLine();
ImGui::TextDisabled(">");
ImGui::SameLine();
if (ImGui::SmallButton("+ Stage##add_stage")) {
st.stages.push_back(Stage{});
st.active_stage = (int)st.stages.size() - 1;
}
}
// ---------------------------------------------------------------------------
// drill_into: API publica. Anade un filter Op::Eq sobre col_name=value al
// stage (from_stage - 1) y cambia st.active_stage a ese stage previo.
// Graba el step en st.drill_back y limpia st.drill_forward (rama nueva).
// ---------------------------------------------------------------------------
void drill_into(State& st, int from_stage,
const std::string& col_name, const std::string& value,
const std::vector<std::string>& prev_input_headers)
{
if (from_stage <= 0 || from_stage >= (int)st.stages.size()) return;
int target = from_stage - 1;
int ci = -1;
for (size_t i = 0; i < prev_input_headers.size(); ++i) {
if (prev_input_headers[i] == col_name) { ci = (int)i; break; }
}
if (ci < 0) return;
// Fase 10: graba step en drill_back, limpia forward (rama nueva).
DrillStep step;
step.target_stage = target;
step.filter_pos = (int)st.stages[target].filters.size();
step.prev_active_stage = st.active_stage;
step.added = make_drill_filter(ci, value);
apply_drill_step(st, step);
st.drill_back.push_back(step);
st.drill_forward.clear();
}
} // namespace data_table
+70
View File
@@ -0,0 +1,70 @@
#pragma once
// data_table_drill — drill-down stack + breadcrumb de stages.
// Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c).
//
// Responsabilidad:
// - make_drill_filter: construye un Filter Op::Eq sobre un col_idx.
// - apply_drill_step / undo_drill_step: manipula el stack drill_back/forward.
// - drill_up: retrocede un stage.
// - drill_into: API publica que anade un filter al stage previo y cambia active.
// - draw_stage_breadcrumb: UI de la barra de breadcrumb con botones < > ^.
//
// Rangos del fuente original:
// - make_drill_filter : linea 700-706
// - apply_drill_step : lineas 708-718
// - undo_drill_step : lineas 720-730
// - drill_up : lineas 731-745
// - drill_into : lineas 2898-2919
// - draw_stage_breadcrumb: lineas 1383-1488
//
// Dependencias: data_table_types.h (State, DrillStep, Filter, Stage).
#include "core/data_table_types.h"
#include <string>
#include <vector>
namespace data_table {
// ---------------------------------------------------------------------------
// Helpers internos del drill stack (usados por drill_into y draw_stage_breadcrumb).
// Se exponen en el header para que data_table_grid pueda llamarlos al
// procesar el drill popup de celdas en stage > 0.
// ---------------------------------------------------------------------------
// make_drill_filter: crea un Filter Op::Eq para col_idx con el valor dado.
Filter make_drill_filter(int col_idx, const std::string& value);
// apply_drill_step: inserta step.added en el stage en step.target_stage y
// actualiza st.active_stage. Retorna true si el step se aplico correctamente.
bool apply_drill_step(State& st, const DrillStep& step);
// undo_drill_step: elimina el filter insertado por apply_drill_step y restaura
// st.active_stage a step.prev_active_stage. Retorna true si se deshizo.
bool undo_drill_step(State& st, const DrillStep& step);
// drill_up: retrocede st.active_stage en 1 si hay stages previos.
// Retorna true si se pudo retroceder.
bool drill_up(State& st);
// ---------------------------------------------------------------------------
// API publica: drill_into
// Anade un filter Op::Eq sobre col_name=value al stage (from_stage - 1) y
// cambia st.active_stage a from_stage - 1. Graba el step en st.drill_back
// y limpia st.drill_forward (rama nueva del arbol de drill).
//
// prev_input_headers: headers del INPUT del stage from_stage (para traducir
// col_name a col_idx en el stage previo).
// ---------------------------------------------------------------------------
void drill_into(State& st, int from_stage,
const std::string& col_name, const std::string& value,
const std::vector<std::string>& prev_input_headers);
// ---------------------------------------------------------------------------
// UI: draw_stage_breadcrumb
// Dibuja la barra de navegacion de drill con botones < (back), > (forward),
// ^ (up) y el nombre del stage activo. Mutates st.drill_back/forward y
// st.active_stage en respuesta a clicks.
// ---------------------------------------------------------------------------
void draw_stage_breadcrumb(State& st);
} // namespace data_table
+85
View File
@@ -0,0 +1,85 @@
---
name: data_table_drill
kind: function
lang: cpp
domain: viz
version: "1.0.0"
purity: impure
signature: "void data_table::drill_into(State& st, int from_stage, const std::string& col_name, const std::string& value, const std::vector<std::string>& prev_input_headers)"
description: "Drill-down stack + UI breadcrumb para la tabla TQL. Gestiona el arbol de navegacion de stages: drill_into anade un filtro Op::Eq al stage previo y avanza al nivel de detalle, draw_stage_breadcrumb dibuja los botones de navegacion (< back, > forward, ^ up) y el selector de stages activo. Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c)."
tags: [viz, table, imgui, ui, drill-down, navigation, tql, cpp-tables]
uses_functions:
- data_table_cpp_viz
uses_types:
- data_table_types_cpp_core
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [imgui]
tested: false
tests: []
test_file_path: ""
file_path: "cpp/functions/viz/data_table_drill.cpp"
framework: imgui
params:
- name: st
desc: "State mutable que contiene st.stages, st.active_stage, st.drill_back (undo stack), st.drill_forward (redo stack). Todos son mutados por las funciones de este modulo."
- name: from_stage
desc: "Stage desde el que se origina el drill (stage activo al hacer right-click en una celda de breakout). Debe ser > 0 para que haya stage previo donde anadir el filtro."
- name: col_name
desc: "Nombre de la columna breakout sobre la que se drilla (debe existir en prev_input_headers)."
- name: value
desc: "Valor de la celda seleccionada. Se aplica como Filter Op::Eq en el stage from_stage-1."
- name: prev_input_headers
desc: "Headers del INPUT del stage from_stage (= output del stage from_stage-1). Necesarios para traducir col_name a col_idx."
output: "Void. Mutates st.stages[target].filters, st.active_stage, st.drill_back, st.drill_forward."
---
## Documentacion
Sub-funcion que encapsula la logica de drill-down de la tabla TQL. El drill-down permite al usuario hacer right-click en una celda de una columna breakout (stage > 0) y "entrar" al detalle del grupo seleccionado anadiendo un filtro al stage previo.
### Funciones publicas
| Funcion | Uso |
|---|---|
| `make_drill_filter(col_idx, value)` | Helper: crea `Filter{col_idx, Op::Eq, value}`. |
| `apply_drill_step(st, step)` | Inserta `step.added` en `st.stages[step.target_stage].filters` y actualiza `st.active_stage`. |
| `undo_drill_step(st, step)` | Invierte `apply_drill_step`: elimina el filtro y restaura `st.active_stage`. |
| `drill_up(st)` | Decrementa `st.active_stage` en 1 (sin crear entry en el undo stack). |
| `drill_into(st, from, col, val, hdrs)` | API publica: compone make_drill_filter + apply_drill_step + graba en drill_back + limpia drill_forward. |
| `draw_stage_breadcrumb(st)` | UI: botones < > ^ + combo de stages. |
### Invariante del stack
- `st.drill_back`: historial de drill steps (undo). Cada `drill_into` agrega al final.
- `st.drill_forward`: pasos deshecho (redo). Se limpia en cada `drill_into` nueva.
- `drill_up` NO agrega al stack — es un atajo que no se puede rehacer.
## Ejemplo
```cpp
// En el cell popup de una celda de breakout col (stage > 0):
if (ImGui::MenuItem(lbl)) {
data_table::drill_into(st, active_stage,
cur_headers[col], cell_value,
input_headers_active);
ImGui::CloseCurrentPopup();
}
// Breadcrumb al inicio del area de chrome:
if (chrome_visible) {
data_table::draw_stage_breadcrumb(st);
}
```
## Cuando usarla
`drill_into` se llama desde el popup de celda en `data_table_grid_cpp_viz` cuando el usuario hace right-click en una columna breakout. `draw_stage_breadcrumb` se llama una vez por frame en el area de chrome antes de los chips.
## Gotchas
- `drill_into` requiere `from_stage > 0`. Si `from_stage <= 0` la funcion retorna sin hacer nada (no hay stage previo).
- `col_name` debe existir en `prev_input_headers`; si no se encuentra, la funcion retorna sin efecto (el filter no se anade).
- `apply_drill_step` y `undo_drill_step` modifican `st.stages[step.target_stage].filters` por posicion (`filter_pos`). Hay un riesgo de corrupcion si los filtros del stage cambian entre apply y undo por otra via. El design actual asume que el caller no muta los filtros del stage target fuera de este API.
- `draw_stage_breadcrumb` dibuja los botones en linea horizontal; si el area es muy estrecha (< 120px) los botones solapan. El caller debe asegurar suficiente ancho o usar `ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ...)`.
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
#pragma once
// data_table_grid — render del grid de datos: ImGui::BeginTable, headers con sort,
// freeze cols, drag-drop de columnas, renderers declarativos (Badge/Progress/
// Duration/Icon/Button/Dots/CategoricalChip/ColorScale), color rules per cell,
// selection (Ctrl+C TSV), stats overlay, cell popup.
//
// Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c).
// Rangos del fuente original:
// - draw_cell_custom (declarative renderers): lineas 376-655
// - Grid setup + header row + cell loop (stage 0): lineas 3436-3765
// - Grid setup + cell loop (stage > 0): lineas 4040-4311
// - Ctrl+C TSV copy: lineas 3725-3766
// - Stats overlay en encabezados: lineas 4160-4226
//
// Dependencias: data_table_types.h, data_table_color_rules.h, imgui.h.
#include "core/data_table_types.h"
#include "imgui.h"
#include <vector>
#include <string>
namespace data_table {
// ---------------------------------------------------------------------------
// draw_cell_custom — renderer declarativo por ColumnSpec.renderer.
// Soporta: Text (fallback), Badge, Progress, Duration, Icon, Button, Dots,
// CategoricalChip, ColorScale.
// Emite TableEvent (ButtonClick, RowDoubleClick) en events_out si no-null.
// Llamar dentro del contexto de un ImGui::BeginTable activo.
// ---------------------------------------------------------------------------
void draw_cell_custom(const ColumnSpec& spec, const char* value,
int row, int col,
std::vector<TableEvent>* events_out);
// ---------------------------------------------------------------------------
// render_grid_stage0 — renderiza el grid para el path stage==0 (datos crudos
// + derivados via Lua). Incluye: BeginTable, headers (sort click, drag-drop,
// header menu via draw_header_menu), cell loop con draw_cell_custom,
// apply_color_rules_for_cell, selection rect, Ctrl+C TSV copy.
//
// Parametros:
// id — ID ImGui unico para el BeginTable.
// st — State mutable (sort, selection, color_rules, col_order, ...).
// cells — cells originales row-major [rows * orig_cols].
// row_count — numero de filas originales.
// orig_cols — numero de columnas originales (sin derived).
// eff_cols — numero de columnas efectivas (orig + derived).
// eff_headers — punteros a los nombres de columna efectivos (size eff_cols).
// eff_types — tipos de columna efectivos (size eff_cols).
// src_for_eff — mapeo eff col -> src col en orig (size eff_cols).
// visible_rows — filas visibles tras filtrado (indices en [0, row_count)).
// events_out — si no null, recibe TableEvent de esta frame.
// ---------------------------------------------------------------------------
void render_grid_stage0(const char* id,
State& st,
const char* const* cells,
int row_count, int orig_cols, int eff_cols,
const char* const* eff_headers,
const ColumnType* eff_types,
const int* src_for_eff,
const std::vector<int>& visible_rows,
const TableInput& main_t,
std::vector<TableEvent>* events_out);
// ---------------------------------------------------------------------------
// render_grid_stage_n — renderiza el grid para el path stage > 0 (datos
// materializados del compute_stage chain). Similar a stage0 pero opera sobre
// cur_cells/cur_rows/cur_cols_n ya materializados.
//
// input_headers_active — headers del INPUT del stage activo (para drill popup).
// n_breakouts — numero de breakout cols en el stage activo.
// ---------------------------------------------------------------------------
void render_grid_stage_n(const char* id,
State& st,
const char* const* cur_cells,
int cur_rows, int cur_cols_n,
const std::vector<std::string>& cur_headers,
const std::vector<ColumnType>& cur_types,
const std::vector<std::string>& input_headers_active,
int n_breakouts,
const TableInput& main_t,
std::vector<TableEvent>* events_out);
} // namespace data_table
+103
View File
@@ -0,0 +1,103 @@
---
name: data_table_grid
kind: function
lang: cpp
domain: viz
version: "1.0.0"
purity: impure
signature: "void data_table::render_grid_stage0(const char* id, State& st, const char* const* cells, int row_count, int orig_cols, int eff_cols, const char* const* eff_headers, const ColumnType* eff_types, const int* src_for_eff, const std::vector<int>& visible_rows, const TableInput& main_t, std::vector<TableEvent>* events_out)"
description: "Render del grid de datos de la tabla TQL: ImGui::BeginTable con freeze de cabecera, headers clicables (sort), drag-drop de columnas para reordenar, renderers declarativos por ColumnSpec (Badge/Progress/Duration/Icon/Button/Dots/CategoricalChip/ColorScale), aplicacion de ColorRules por celda, seleccion de rango (Ctrl+C TSV), stats overlay en encabezados. Dos entrypoints: render_grid_stage0 (datos crudos + derived Lua) y render_grid_stage_n (output materializado de compute_stage). Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c)."
tags: [viz, table, imgui, ui, grid, cell-renderer, sorting, tql, cpp-tables]
uses_functions:
- data_table_color_rules_cpp_viz
- data_table_cpp_viz
uses_types:
- data_table_types_cpp_core
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [imgui]
tested: false
tests: []
test_file_path: ""
file_path: "cpp/functions/viz/data_table_grid.cpp"
framework: imgui
params:
- name: id
desc: "ID unico de ImGui para BeginTable (ej. '##my_table'). Debe ser unico por instancia en la ventana."
- name: st
desc: "State mutable. El grid lee st.col_order, st.col_visible, st.color_rules, st.sel_anchor/end, st.stages[active].sorts. Lo muta en respuesta a clicks, drag-drop, seleccion."
- name: cells
desc: "Puntero a cells originales row-major [rows * orig_cols]. Para stage0; stage_n recibe cur_cells ya materializados."
- name: row_count / orig_cols / eff_cols
desc: "Dimensiones de la tabla. orig_cols = columnas del input; eff_cols = orig_cols + derived cols del stage 0."
- name: eff_headers / eff_types
desc: "Headers y tipos efectivos (size eff_cols). Usados para headers del BeginTable y para render de celda."
- name: src_for_eff
desc: "Mapeo eff_col_idx -> src_col_idx en la tabla original. Necesario para leer cells[row * orig_cols + src]. Solo para stage0."
- name: visible_rows
desc: "Indices de filas visibles tras filtrado (output de compute_visible_rows). Solo para stage0."
- name: main_t
desc: "TableInput principal: proporciona column_specs para draw_cell_custom y los column_spec ids para eventos."
- name: events_out
desc: "Si no null, recibe TableEvent (ButtonClick, RowDoubleClick, RowRightClick) generados este frame."
output: "Void. Mutates st (sort, selection, col_order). Appends to events_out si no null."
---
## Documentacion
Sub-funcion que encapsula el render del grid de celdas de la tabla TQL. Es el bloque mas grande del refactor 0107c (~1300 LOC del fuente original).
### Renderers declarativos (draw_cell_custom)
| Renderer | Que muestra |
|---|---|
| `Text` | Selectable de texto, tooltip on-hover si `tooltip_on_hover=true` |
| `Badge` | Pastilla de color (background del spec) con texto |
| `Progress` | Barra de progreso ImGui [0..100] |
| `Duration` | Formato "Xh Ym Zs" desde segundos enteros |
| `Icon` | Glyph Tabler (usa `icons_tabler.h`) |
| `Button` | Boton clickable; emite `TableEventKind::ButtonClick` con `action_id` |
| `Dots` | Circulos coloreados por reglas de chips del ColumnSpec |
| `CategoricalChip` | Pastilla con borde coloreado; color por palette hash |
| `ColorScale` | Degradado de fondo segun valor numerico normalizado |
### Flujo render_grid_stage0
1. Setup `ImGuiTableFlags` (Borders + RowBg + Resizable + ScrollY).
2. `BeginTable``TableSetupColumn` por cada col visible en `st.col_order`.
3. `TableSetupScrollFreeze(0, 1)` para freeze de header.
4. Header row: `Selectable` con sort-click (`apply_header_sort_click`), drag-drop source/target para reordenar columnas.
5. Por cada fila en `visible_rows`: por cada col visible en `st.col_order`: `apply_color_rules_for_cell``draw_cell_custom`.
6. Seleccion de rango: mouse drag con `sel_anchor/sel_end`; Ctrl+C genera TSV al portapapeles.
7. `EndTable` + `PopStyleColor(3)`.
### Flujo render_grid_stage_n
Igual pero opera sobre `cur_cells` ya materializados del chain `compute_stage`. Ademas:
- Stats overlay en headers (histograma/top-categories de `st.stats_cache`).
- Drill popup: right-click en celda de breakout col ofrece "Drill into: col = val".
## Ejemplo
```cpp
// Dentro del render() principal, path stage == 0:
if (st.display == data_table::ViewMode::Table && visible_cols > 0) {
data_table::render_grid_stage0(id, st,
cells_in, row_count_in, orig_cols, eff_cols,
eff_headers.data(), eff_types.data(), src_for_eff.data(),
visible_rows, main_t, events_out);
}
```
## Cuando usarla
Llamar desde el entrypoint thin `data_table::render()` tras calcular `visible_rows` (stage 0) o materializar el chain (stage > 0). No llamar directamente desde apps — la API publica es `data_table::render()`.
## Gotchas
- `render_grid_stage0` y `render_grid_stage_n` deben llamarse dentro de un `ImGui::BeginChild` o contexto de ventana activo. No funcionan fuera de un frame ImGui.
- La funcion hace `PushStyleColor` para Header/HeaderHovered/HeaderActive y debe hacer el correspondiente `PopStyleColor(3)`. No anidar pushes adicionales sin su pop.
- `draw_cell_custom` con renderer `Button` emite eventos; si `events_out` es null se ignoran silenciosamente (back-compat).
- El `thread_local` en el entrypoint original (main_hdr_ptrs, joinables_v) no se mueve a esta funcion — esos son responsabilidad del entrypoint thin.
- Despues del split, `apply_color_rules_for_cell` vive en `data_table_color_rules_cpp_viz` — no re-implementar aqui.
+429
View File
@@ -0,0 +1,429 @@
// data_table_viz_panels — paneles de visualizacion lateral de la tabla TQL.
// Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c).
//
// Funciones implementadas:
// - draw_table_toggle (ex lineas 745-767)
// - draw_extra_panel (ex lineas 771-856)
// - draw_viz_config_popup (ex lineas 858-1021)
// - draw_viz_selector (ex lineas 1034-1110)
// - maybe_recompute_stats (ex lineas 1118-1145)
#include "viz/data_table_viz_panels.h"
#include "data_table/data_table_internal.h"
#include "viz/data_table_grid.h" // draw_cell_custom
#include "core/data_table_types.h"
#include "core/compute_column_stats.h"
#include "core/tql_emit.h"
#include "viz/viz_render.h"
#include "imgui.h"
#include <algorithm>
#include <cstdio>
#include <string>
#include <vector>
namespace data_table {
// ---------------------------------------------------------------------------
// draw_table_toggle
// ---------------------------------------------------------------------------
void draw_table_toggle(ViewMode& display, ViewMode& last_non_table,
const char* id_suffix,
State* st_opt)
{
bool is_table = (display == ViewMode::Table);
char b[64];
std::snprintf(b, sizeof(b), "%s##tbl_%s",
is_table ? "Show chart" : "Show table", id_suffix);
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(80, 140, 200, 240));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(100, 160, 220, 240));
if (ImGui::SmallButton(b)) {
if (is_table) {
ViewMode tgt = (last_non_table == ViewMode::Table)
? ViewMode::Bar : last_non_table;
display = tgt;
if (st_opt && view_mode_needs_aggregation(tgt)) {
auto_promote_aggregated(*st_opt);
}
} else {
last_non_table = display;
display = ViewMode::Table;
}
}
ImGui::PopStyleColor(2);
}
// ---------------------------------------------------------------------------
// draw_extra_panel
// ---------------------------------------------------------------------------
bool draw_extra_panel(State& st, VizPanel& p, int idx,
const StageOutput& so,
const std::vector<ColumnSpec>* col_specs)
{
bool close_req = false;
char child_id[64]; std::snprintf(child_id, sizeof(child_id), "##extra_viz_%d", idx);
ImGui::BeginChild(child_id, ImVec2(0, 320), true);
// Toolbar
int n_modes = 0;
const ViewMode* modes = all_view_modes(&n_modes);
ImGui::TextDisabled("View:");
ImGui::SameLine();
ImGui::SetNextItemWidth(180);
char combo_id[64]; std::snprintf(combo_id, sizeof(combo_id), "##ev_mode_%d", idx);
if (ImGui::BeginCombo(combo_id, view_mode_label(p.display))) {
for (int i = 0; i < n_modes; ++i) {
bool sel = (modes[i] == p.display);
if (ImGui::Selectable(view_mode_label(modes[i]), sel)) {
p.display = modes[i];
p.config.fit_request = true;
}
if (sel) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
ImGui::SameLine();
char fit_id[32]; std::snprintf(fit_id, sizeof(fit_id), "Fit##ev_fit_%d", idx);
if (ImGui::SmallButton(fit_id)) p.config.fit_request = true;
ImGui::SameLine();
char lock_id[32]; std::snprintf(lock_id, sizeof(lock_id), "%s##ev_lock_%d",
p.config.locked ? "Locked" : "Lock", idx);
if (p.config.locked) {
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(180, 60, 60, 230));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(200, 80, 80, 240));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32(150, 40, 40, 240));
}
if (ImGui::SmallButton(lock_id)) p.config.locked = !p.config.locked;
if (p.config.locked) ImGui::PopStyleColor(3);
ImGui::SameLine();
char close_id[32]; std::snprintf(close_id, sizeof(close_id), "X##ev_close_%d", idx);
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(120, 50, 50, 220));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(160, 70, 70, 240));
if (ImGui::SmallButton(close_id)) close_req = true;
ImGui::PopStyleColor(2);
// Toggle Table <-> View per-panel
char ts[32]; std::snprintf(ts, sizeof(ts), "ep%d", idx);
draw_table_toggle(p.display, p.last_non_table, ts);
// Render: si Table -> mini table; else chart.
if (p.display == ViewMode::Table) {
ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY |
ImGuiTableFlags_ScrollX;
char tid[64]; std::snprintf(tid, sizeof(tid), "##ep_table_%d", idx);
if (so.cols > 0 && ImGui::BeginTable(tid, so.cols, flags, ImVec2(0, 0))) {
for (int c = 0; c < so.cols; ++c)
ImGui::TableSetupColumn(so.headers[c].c_str());
ImGui::TableHeadersRow();
for (int r = 0; r < so.rows; ++r) {
ImGui::TableNextRow();
for (int c = 0; c < so.cols; ++c) {
ImGui::TableSetColumnIndex(c);
const char* s = so.cells[(size_t)r * so.cols + c];
// Issue 0081-N: declarative renderer for extra panel mini-table.
// events_out not propagated to mini-table (secondary render).
bool custom_ep = false;
if (col_specs && c < (int)col_specs->size()) {
const ColumnSpec& cs = (*col_specs)[(size_t)c];
if (cs.renderer != CellRenderer::Text) {
draw_cell_custom(cs, s, r, c, nullptr);
custom_ep = true;
}
}
if (!custom_ep) ImGui::TextUnformatted(s ? s : "");
}
}
ImGui::EndTable();
}
} else {
viz::render(so, p.display, p.config, ImVec2(-1, -1));
}
ImGui::EndChild();
(void)st;
return close_req;
}
// ---------------------------------------------------------------------------
// draw_viz_config_popup
// ---------------------------------------------------------------------------
void draw_viz_config_popup(State& st) {
if (!ImGui::BeginPopup("##viz_cfg_popup")) return;
ImGui::Text("Configure: %s", view_mode_label(st.display));
ImGui::Separator();
auto cols = collect_active_col_info(st);
std::vector<const char*> all_names;
std::vector<const char*> num_names;
std::vector<const char*> cat_names;
for (auto& c : cols) {
all_names.push_back(c.name.c_str());
if (c.type == ColumnType::Int || c.type == ColumnType::Float)
num_names.push_back(c.name.c_str());
else
cat_names.push_back(c.name.c_str());
}
auto& vc = st.viz_config;
ViewMode m = st.display;
auto combo_for_col = [&](const char* label, std::string& target,
const std::vector<const char*>& options) {
const char* preview = target.empty() ? "(auto)" : target.c_str();
ImGui::SetNextItemWidth(220);
if (ImGui::BeginCombo(label, preview)) {
if (ImGui::Selectable("(auto)", target.empty())) target.clear();
for (auto& o : options) {
bool sel = (target == o);
if (ImGui::Selectable(o, sel)) target = o;
}
ImGui::EndCombo();
}
};
// X col: scatter, line, area, stairs, hist2d, bubble
bool needs_x = (m == ViewMode::Scatter || m == ViewMode::Line ||
m == ViewMode::Area || m == ViewMode::Stairs ||
m == ViewMode::Histogram2D || m == ViewMode::Bubble);
if (needs_x) combo_for_col("X column", vc.x_col, num_names);
// Y cols: most modes
bool needs_y = (m != ViewMode::Pie && m != ViewMode::Donut && m != ViewMode::Funnel &&
m != ViewMode::Candlestick);
if (needs_y) {
ImGui::Text("Y columns:");
ImGui::SameLine();
ImGui::TextDisabled("(%d selected; empty = auto)", (int)vc.y_cols.size());
ImGui::Indent();
for (auto& nn : num_names) {
std::string ns = nn;
bool checked = std::find(vc.y_cols.begin(), vc.y_cols.end(), ns) != vc.y_cols.end();
if (ImGui::Checkbox(nn, &checked)) {
if (checked) vc.y_cols.push_back(ns);
else {
auto it = std::find(vc.y_cols.begin(), vc.y_cols.end(), ns);
if (it != vc.y_cols.end()) vc.y_cols.erase(it);
}
}
}
ImGui::Unindent();
if (ImGui::SmallButton("Clear Y##clr_y")) vc.y_cols.clear();
}
// Cat col: bar/pie/funnel/box/waterfall
bool needs_cat = (m == ViewMode::Bar || m == ViewMode::Column ||
m == ViewMode::GroupedBar || m == ViewMode::StackedBar ||
m == ViewMode::Pie || m == ViewMode::Donut ||
m == ViewMode::Funnel || m == ViewMode::BoxPlot ||
m == ViewMode::Waterfall);
if (needs_cat) {
// Si el active stage YA esta agrupado (breakouts != empty), la categoria
// del chart la dicta el breakout. Mostrar todas las cols del INPUT del
// stage (= cols pre-agrupacion). Selecionar otra = reemplaza breakouts[0]
// (re-agrupa).
int as = st.active_stage;
bool grouped = (as >= 0 && as < (int)st.stages.size() &&
!st.stages[as].breakouts.empty());
const auto& U = ui();
if (grouped) {
std::vector<const char*> input_cat_names;
for (size_t i = 0; i < U.input_headers_active.size() &&
i < U.input_types_active.size(); ++i) {
ColumnType t = U.input_types_active[i];
if (t == ColumnType::String || t == ColumnType::Date ||
t == ColumnType::Bool || t == ColumnType::Json) {
input_cat_names.push_back(U.input_headers_active[i].c_str());
}
}
std::string cur_break = st.stages[as].breakouts[0];
const char* preview = cur_break.empty() ? "(none)" : cur_break.c_str();
ImGui::SetNextItemWidth(220);
if (ImGui::BeginCombo("Category (breakout)", preview)) {
for (auto& o : input_cat_names) {
bool sel = (cur_break == o);
if (ImGui::Selectable(o, sel)) {
st.stages[as].breakouts[0] = o;
}
}
ImGui::EndCombo();
}
} else {
combo_for_col("Category", vc.cat_col, cat_names);
}
}
// Size col: bubble
if (m == ViewMode::Bubble) combo_for_col("Size column", vc.size_col, num_names);
// Color
ImGui::Separator();
float col_f[4] = {
((vc.primary_color) & 0xFF) / 255.0f,
((vc.primary_color >> 8) & 0xFF) / 255.0f,
((vc.primary_color >> 16) & 0xFF) / 255.0f,
((vc.primary_color >> 24) & 0xFF) / 255.0f,
};
if (vc.primary_color == 0) { col_f[0]=col_f[1]=col_f[2]=1.0f; col_f[3]=1.0f; }
if (ImGui::ColorEdit4("Primary color", col_f, ImGuiColorEditFlags_AlphaBar)) {
unsigned int r2 = (unsigned int)(col_f[0] * 255);
unsigned int g2 = (unsigned int)(col_f[1] * 255);
unsigned int b2 = (unsigned int)(col_f[2] * 255);
unsigned int a2 = (unsigned int)(col_f[3] * 255);
vc.primary_color = (a2 << 24) | (b2 << 16) | (g2 << 8) | r2;
}
ImGui::SameLine();
if (ImGui::SmallButton("Auto##color")) vc.primary_color = 0;
// Hist bins
if (m == ViewMode::Histogram || m == ViewMode::Histogram2D) {
ImGui::SetNextItemWidth(120);
int bins = vc.hist_bins;
if (ImGui::InputInt("Bins (0=auto)", &bins)) {
if (bins < 0) bins = 0;
vc.hist_bins = bins;
}
}
// Pie radius
if (m == ViewMode::Pie || m == ViewMode::Donut) {
ImGui::SetNextItemWidth(120);
float rad = vc.pie_radius;
if (ImGui::SliderFloat("Radius (0=auto)", &rad, 0.0f, 0.5f, "%.2f")) {
vc.pie_radius = rad;
}
}
// Toggles
ImGui::Separator();
ImGui::Checkbox("Show legend", &vc.show_legend);
if (m == ViewMode::Line || m == ViewMode::Area || m == ViewMode::Stairs) {
ImGui::SameLine();
ImGui::Checkbox("Show markers", &vc.show_markers);
}
ImGui::Separator();
if (ImGui::SmallButton("Reset config")) {
vc = ViewConfig{};
}
ImGui::SameLine();
if (ImGui::SmallButton("Close")) ImGui::CloseCurrentPopup();
ImGui::EndPopup();
}
// ---------------------------------------------------------------------------
// draw_viz_selector
// ---------------------------------------------------------------------------
void draw_viz_selector(State& st) {
int n_modes = 0;
const ViewMode* modes = all_view_modes(&n_modes);
// Right-align: reserve "View: [combo] [Fit] [Lock] [Config] [Ask AI] [+ Viz]"
const float combo_w = 200.0f;
const float total_w = combo_w + 50.0f + 280.0f;
float right_edge = ImGui::GetWindowContentRegionMax().x;
float target_x = right_edge - total_w;
float min_x = ImGui::GetCursorPosX() + 20.0f; // do not overlap breadcrumb
if (target_x < min_x) target_x = min_x;
ImGui::SameLine();
ImGui::SetCursorPosX(target_x);
ImGui::TextDisabled("View:");
ImGui::SameLine();
ImGui::SetNextItemWidth(combo_w);
if (ImGui::BeginCombo("##viz_mode", view_mode_label(st.display))) {
for (int i = 0; i < n_modes; ++i) {
bool sel = (modes[i] == st.display);
if (ImGui::Selectable(view_mode_label(modes[i]), sel)) {
ViewMode nm = modes[i];
if (nm != st.display) {
st.display = nm;
if (view_mode_needs_aggregation(nm)) {
auto_promote_aggregated(st);
}
}
}
if (sel) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
ImGui::SameLine();
if (ImGui::SmallButton("Fit##viz_fit")) {
st.viz_config.fit_request = true;
}
ImGui::SameLine();
bool locked = st.viz_config.locked;
if (locked) {
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(180, 60, 60, 230));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(200, 80, 80, 240));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32(150, 40, 40, 240));
}
if (ImGui::SmallButton(locked ? "Locked##viz_lock" : "Lock##viz_lock")) {
st.viz_config.locked = !st.viz_config.locked;
}
if (locked) ImGui::PopStyleColor(3);
ImGui::SameLine();
if (ImGui::SmallButton("Config##viz_cfg")) {
ImGui::OpenPopup("##viz_cfg_popup");
}
ImGui::SameLine();
if (ImGui::SmallButton("Ask AI##ask_open")) {
auto& U2 = ui();
U2.ask_ai.open = true;
U2.ask_ai.busy = false;
U2.ask_ai.error.clear();
U2.ask_ai.status.clear();
U2.ask_ai.response_code.clear();
U2.ask_ai.response_raw.clear();
U2.ask_ai.current_tql = tql::emit(st,
std::vector<std::string>(),
std::vector<ColumnType>());
}
ImGui::SameLine();
if (ImGui::SmallButton("+ Viz##viz_add")) {
VizPanel p;
p.display = ViewMode::Bar;
if (view_mode_needs_aggregation(p.display)) {
auto_promote_aggregated(st);
}
st.extra_panels.push_back(p);
}
draw_viz_config_popup(st);
ImGui::NewLine();
}
// ---------------------------------------------------------------------------
// maybe_recompute_stats
// ---------------------------------------------------------------------------
void maybe_recompute_stats(State& st,
const char* const* cells,
int row_count, int orig_cols, int eff_cols,
const std::vector<Filter>& active_filters,
const std::vector<int>& visible_rows,
const std::vector<int>& src_for_eff)
{
if (!st.stats_mode) return;
size_t fh = filters_hash(active_filters);
bool ds_changed = (cells != st.stats_last_cells || row_count != st.stats_last_rows ||
eff_cols != st.stats_last_eff_cols ||
(int)st.stats_cache.size() != eff_cols);
bool fl_changed = (fh != st.stats_last_filter_h ||
(int)visible_rows.size() != st.stats_last_visible);
if (!ds_changed && !fl_changed) return;
st.stats_cache.resize(eff_cols);
const int* idx = visible_rows.empty() ? nullptr : visible_rows.data();
int n = (int)visible_rows.size();
for (int c = 0; c < eff_cols; ++c) {
int src = src_for_eff[c];
st.stats_cache[c] = compute_column_stats(cells, row_count, orig_cols, src,
100000, idx, n);
}
st.stats_last_cells = cells;
st.stats_last_rows = row_count;
st.stats_last_eff_cols = eff_cols;
st.stats_last_filter_h = fh;
st.stats_last_visible = (int)visible_rows.size();
}
} // namespace data_table
+79
View File
@@ -0,0 +1,79 @@
#pragma once
// data_table_viz_panels — paneles de visualizacion lateral de la tabla TQL.
// Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c).
//
// Responsabilidad:
// - draw_table_toggle: boton de toggle entre modo tabla y ultimo modo viz.
// - draw_extra_panel: render de un panel de viz adicional (VizPanel) con su
// toolbar mini (titulo, pin/close, selector de chart).
// - draw_viz_config_popup: popup de configuracion de visualizacion (columnas
// x/y, modo, config del grafico activo).
// - draw_viz_selector: selector de modo de viz (iconos/grid de ViewMode).
// - maybe_recompute_stats: recalcula las estadisticas de columna si cambiaron
// los datos visibles (para el stats overlay en headers y config popup).
//
// Rangos del fuente original:
// - draw_table_toggle : lineas 1527-1552
// - draw_extra_panel : lineas 1553-1638
// - draw_viz_config_popup : lineas 1640-1815
// - draw_viz_selector : lineas 1816-1892
// - maybe_recompute_stats : lineas 2474-2502
//
// Dependencias: data_table_types.h, viz_render.h (viz::render), imgui.h.
#include "core/data_table_types.h"
#include "imgui.h"
#include <vector>
#include <string>
namespace data_table {
// ---------------------------------------------------------------------------
// draw_table_toggle
// Boton que cambia display entre ViewMode::Table y el ultimo modo viz no-tabla.
// `id_suffix` diferencia multiples toggles en la misma ventana.
// `st_ptr` si no null se usa para actualizar `st.display` directamente;
// si null usa display/last_non_table pasados por ref.
// ---------------------------------------------------------------------------
void draw_table_toggle(ViewMode& display, ViewMode& last_non_table,
const char* id_suffix,
State* st_ptr = nullptr);
// ---------------------------------------------------------------------------
// draw_extra_panel
// Dibuja un VizPanel adicional (child window con toolbar mini + chart).
// Retorna true si el usuario cerro el panel (el caller debe borrar el entry).
// ---------------------------------------------------------------------------
bool draw_extra_panel(State& st, VizPanel& p, int idx,
const StageOutput& so,
const std::vector<ColumnSpec>* col_specs);
// ---------------------------------------------------------------------------
// draw_viz_config_popup
// Popup de configuracion de visualizacion: columnas x/y, modo de chart,
// colores, histogramas bins, pie radius, etc. Mutates st.viz_config y
// st.extra_panels.
// ---------------------------------------------------------------------------
void draw_viz_config_popup(State& st);
// ---------------------------------------------------------------------------
// draw_viz_selector
// Selector de modo de visualizacion: grid de iconos con ViewMode disponibles.
// Se abre via boton "Ask AI" o desde draw_viz_config_popup.
// ---------------------------------------------------------------------------
void draw_viz_selector(State& st);
// ---------------------------------------------------------------------------
// maybe_recompute_stats
// Recalcula st.stats_cache si el snapshot de datos ha cambiado
// (se detecta por hash de visible_rows + filtros). Solo recalcula si
// st.stats_mode == true.
// ---------------------------------------------------------------------------
void maybe_recompute_stats(State& st,
const char* const* cells,
int row_count, int orig_cols, int eff_cols,
const std::vector<Filter>& active_filters,
const std::vector<int>& visible_rows,
const std::vector<int>& src_for_eff);
} // namespace data_table
@@ -0,0 +1,97 @@
---
name: data_table_viz_panels
kind: function
lang: cpp
domain: viz
version: "1.0.0"
purity: impure
signature: "bool data_table::draw_extra_panel(State& st, VizPanel& p, int idx, const StageOutput& so, const std::vector<ColumnSpec>* col_specs)"
description: "Paneles de visualizacion lateral de la tabla TQL: toggle tabla/chart, paneles extra independientes (histogramas, linea, scatter, value-counts), popup de configuracion de viz (cols x/y, modo, color, bins), selector de ViewMode via grid de iconos, y recalculo lazy de estadisticas de columna. Sub-funcion extraida de modules/data_table/data_table.cpp (issue 0107c)."
tags: [viz, table, imgui, ui, charts, visualization, tql, cpp-tables, implot]
uses_functions:
- viz_render_cpp_viz
- compute_column_stats_cpp_core
- data_table_cpp_viz
uses_types:
- data_table_types_cpp_core
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [imgui, implot]
tested: false
tests: []
test_file_path: ""
file_path: "cpp/functions/viz/data_table_viz_panels.cpp"
framework: imgui
params:
- name: st
desc: "State mutable: st.display (modo actual), st.viz_config (config de viz), st.extra_panels (paneles adicionales), st.stats_cache/stats_mode (estadisticas)."
- name: p
desc: "VizPanel a renderizar: contiene su propio ViewConfig, titulo, display mode. Mutado si el usuario cambia la config del panel."
- name: idx
desc: "Indice del panel en st.extra_panels. Usado como ID ImGui para unicidad."
- name: so
desc: "StageOutput del stage activo: headers, types, cells row-major. Input de viz::render."
- name: col_specs
desc: "ColumnSpecs del TableInput principal si no vacias; null si no hay specs declarativas."
output: "Bool para draw_extra_panel: true si el usuario cerro el panel (caller debe hacer erase de st.extra_panels[idx]). Void para el resto."
---
## Documentacion
Sub-funcion que encapsula los paneles de visualizacion lateral de la tabla TQL. Permite al usuario ver graficos (bar, line, scatter, pie, etc.) en paralelo a la tabla de datos, con configuracion interactiva.
### Funciones publicas
| Funcion | Que hace |
|---|---|
| `draw_table_toggle(display, last_non_table, id, st_ptr)` | Boton toggle "Table / Chart". Guarda el ultimo modo viz en `last_non_table`. |
| `draw_extra_panel(st, p, idx, so, col_specs)` | Child window con header/footer mini: titulo, pin, close. Llama `viz::render`. Retorna true si cerrado. |
| `draw_viz_config_popup(st)` | Popup tabbado: config de viz principal + config de cada panel extra. Permite cambiar columnas x/y, modo, color, bins, anadir panel. |
| `draw_viz_selector(st)` | Grid de iconos ViewMode; seleccion cambia `st.display`. Incluye boton "Ask AI" -> abre `data_table_ai_panel`. |
| `maybe_recompute_stats(st, cells, ...)` | Recalcula stats lazy si `visible_rows` cambio. Solo cuando `st.stats_mode == true`. |
### draw_extra_panel: ciclo de vida de un VizPanel
1. El usuario abre un panel desde `draw_viz_selector` (boton "+").
2. Se agrega un `VizPanel` a `st.extra_panels` con config por defecto.
3. Cada frame: `draw_extra_panel(st, p, i, so, specs)`.
4. Si retorna `true`: `st.extra_panels.erase(begin + i)`.
### maybe_recompute_stats: politica de cache
Compara un hash de `visible_rows` (FNV-1a sobre el vector de indices) contra `st.stats_last_visible_hash`. Si difiere, llama `compute_column_stats_cpp_core` por cada columna efectiva y actualiza `st.stats_cache`. Costo O(cols * visible_rows) — llamar solo cuando stats_mode este activo.
## Ejemplo
```cpp
// Llamado desde render() tras el grid (path stage 0):
if (st.display != data_table::ViewMode::Table) {
data_table::draw_table_toggle(st.display, U.last_non_table_main, "main", &st);
}
// Paneles extra cuando NO estamos en modo tabla:
if (st.display != data_table::ViewMode::Table && !st.extra_panels.empty()) {
int close_idx = -1;
for (int i = 0; i < (int)st.extra_panels.size(); ++i) {
if (data_table::draw_extra_panel(st, st.extra_panels[i], i, so, &main_t.column_specs))
close_idx = i;
}
if (close_idx >= 0) st.extra_panels.erase(st.extra_panels.begin() + close_idx);
}
// Config popup (activado por boton en draw_viz_selector):
data_table::draw_viz_config_popup(st);
data_table::draw_viz_selector(st);
```
## Cuando usarla
Llamar desde el entrypoint thin `data_table::render()` despues del grid y antes de los modales. No llamar directamente desde apps — la API publica es siempre `data_table::render()`.
## Gotchas
- `draw_extra_panel` abre un `ImGui::BeginChild` interno — no anidar dentro de otro child que ya recorte el area de pintado.
- `draw_viz_selector` incluye la apertura del modal Ask AI (`st.ask_open = true`). El modal real lo dibuja `data_table_ai_panel_cpp_viz`. El order de calls importa: selector primero, luego el modal.
- `maybe_recompute_stats` es potencialmente caro (O(visible_rows * cols)). Solo activar con `st.stats_mode = true` via boton "Show stats"; el boton vive en el area de chrome del render principal.
- ImPlot context debe estar activo cuando se llama `viz::render` desde `draw_extra_panel`. Garantizado si el caller usa `fn::run_app` con ImPlot inicializado en `cfg`.
+28 -26
View File
@@ -5,32 +5,24 @@
#include <imgui.h> #include <imgui.h>
#include <cstdio> #include <cstdio>
void kpi_card(const char* label, float value, float delta_percent, static void kpi_card_impl(const char* label, float value, float delta_percent,
const float* history, int history_count, const float* history, int history_count,
const char* format, const char* format, const char* icon,
const char* icon) { bool fixed_y, float y_min, float y_max) {
using namespace fn_tokens; using namespace fn_tokens;
// Card container — surface bg, border, rounded, padding.
// Mirrors Mantine <Paper withBorder shadow="xs" radius="md" p="md" /> usado
// en @fn_library/kpi_card.tsx, adaptado a ImGui dark theme via tokens.
ImGui::PushStyleColor(ImGuiCol_ChildBg, colors::surface); ImGui::PushStyleColor(ImGuiCol_ChildBg, colors::surface);
ImGui::PushStyleColor(ImGuiCol_Border, colors::border); ImGui::PushStyleColor(ImGuiCol_Border, colors::border);
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, radius::md); ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, radius::md);
ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, 1.0f); ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, 1.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(spacing::sm, spacing::sm)); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(spacing::sm, spacing::sm));
// Unique child id por label para que multiples cards en la misma ventana
// no colisionen.
char child_id[96]; char child_id[96];
std::snprintf(child_id, sizeof(child_id), "##kpi_%s", label); std::snprintf(child_id, sizeof(child_id), "##kpi_%s", label);
float width = ImGui::GetContentRegionAvail().x; float width = ImGui::GetContentRegionAvail().x;
if (width < 1.0f) width = 1.0f; if (width < 1.0f) width = 1.0f;
// Altura fija (no AutoResizeY) para que:
// (a) todas las cards de un grid queden alineadas visualmente,
// (b) no haya recalculo de layout por card en cada resize.
constexpr float card_height = 86.0f; constexpr float card_height = 86.0f;
ImGui::BeginChild(child_id, ImVec2(width, card_height), ImGui::BeginChild(child_id, ImVec2(width, card_height),
ImGuiChildFlags_Borders, ImGuiChildFlags_Borders,
@@ -39,8 +31,6 @@ void kpi_card(const char* label, float value, float delta_percent,
const bool has_history = history != nullptr && history_count > 0; const bool has_history = history != nullptr && history_count > 0;
const bool has_delta = delta_percent != 0.0f; const bool has_delta = delta_percent != 0.0f;
// Layout de dos columnas: izquierda info (label, value, delta), derecha sparkline.
// El sparkline se sitia verticalmente centrado a la derecha de la card.
constexpr float spark_w = 100.0f; constexpr float spark_w = 100.0f;
constexpr float spark_h = 36.0f; constexpr float spark_h = 36.0f;
@@ -50,7 +40,6 @@ void kpi_card(const char* label, float value, float delta_percent,
ImGui::BeginGroup(); ImGui::BeginGroup();
ImGui::PushItemWidth(info_w); ImGui::PushItemWidth(info_w);
// Top row: optional icon + label, ambos en text_muted.
ImGui::PushStyleColor(ImGuiCol_Text, colors::text_muted); ImGui::PushStyleColor(ImGuiCol_Text, colors::text_muted);
if (icon && *icon) { if (icon && *icon) {
ImGui::TextUnformatted(icon); ImGui::TextUnformatted(icon);
@@ -59,14 +48,12 @@ void kpi_card(const char* label, float value, float delta_percent,
ImGui::TextUnformatted(label); ImGui::TextUnformatted(label);
ImGui::PopStyleColor(); ImGui::PopStyleColor();
// Value — escala compacta 1.4x, proporcional a una card de 86px.
ImGui::SetWindowFontScale(1.4f); ImGui::SetWindowFontScale(1.4f);
char value_buf[64]; char value_buf[64];
std::snprintf(value_buf, sizeof(value_buf), format, value); std::snprintf(value_buf, sizeof(value_buf), format, value);
ImGui::TextUnformatted(value_buf); ImGui::TextUnformatted(value_buf);
ImGui::SetWindowFontScale(1.0f); ImGui::SetWindowFontScale(1.0f);
// Delta / trend — SIEMPRE se reserva la linea aunque no haya tendencia.
if (has_delta) { if (has_delta) {
const bool positive = delta_percent >= 0.0f; const bool positive = delta_percent >= 0.0f;
const ImVec4 delta_color = positive ? colors::success : colors::error; const ImVec4 delta_color = positive ? colors::success : colors::error;
@@ -80,7 +67,6 @@ void kpi_card(const char* label, float value, float delta_percent,
ImGui::TextUnformatted(delta_buf); ImGui::TextUnformatted(delta_buf);
ImGui::PopStyleColor(); ImGui::PopStyleColor();
} else { } else {
// Placeholder para preservar altura.
ImGui::PushStyleColor(ImGuiCol_Text, colors::text_dim); ImGui::PushStyleColor(ImGuiCol_Text, colors::text_dim);
ImGui::TextUnformatted(TI_MINUS); ImGui::TextUnformatted(TI_MINUS);
ImGui::PopStyleColor(); ImGui::PopStyleColor();
@@ -89,24 +75,23 @@ void kpi_card(const char* label, float value, float delta_percent,
ImGui::PopItemWidth(); ImGui::PopItemWidth();
ImGui::EndGroup(); ImGui::EndGroup();
// Sparkline a la derecha, centrado verticalmente respecto a la card.
if (has_history) { if (has_history) {
const ImVec4 spark_color = has_delta const ImVec4 spark_color = has_delta
? (delta_percent >= 0.0f ? colors::success : colors::error) ? (delta_percent >= 0.0f ? colors::success : colors::error)
: colors::primary; : colors::primary;
ImGui::SameLine(); ImGui::SameLine();
// Centrar verticalmente: la card mide card_height, el sparkline spark_h.
// Restamos el padding interno (spacing::sm) ya consumido al inicio.
const float card_inner_h = card_height - 2.0f * spacing::sm; const float card_inner_h = card_height - 2.0f * spacing::sm;
float y_offset = (card_inner_h - spark_h) * 0.5f; float y_offset = (card_inner_h - spark_h) * 0.5f;
if (y_offset < 0.0f) y_offset = 0.0f; if (y_offset < 0.0f) y_offset = 0.0f;
// Anclamos el sparkline al borde derecho.
const float spark_x = inner_w - spark_w; const float spark_x = inner_w - spark_w;
ImGui::SetCursorPos(ImVec2(spacing::sm + spark_x, ImGui::SetCursorPos(ImVec2(spacing::sm + spark_x, spacing::sm + y_offset));
spacing::sm + y_offset)); if (fixed_y) {
sparkline(label, history, history_count, spark_color, spark_w, spark_h); sparkline(label, history, history_count, spark_color,
y_min, y_max, spark_w, spark_h);
} else {
sparkline(label, history, history_count, spark_color, spark_w, spark_h);
}
} }
ImGui::EndChild(); ImGui::EndChild();
@@ -114,3 +99,20 @@ void kpi_card(const char* label, float value, float delta_percent,
ImGui::PopStyleVar(3); ImGui::PopStyleVar(3);
ImGui::PopStyleColor(2); ImGui::PopStyleColor(2);
} }
void kpi_card(const char* label, float value, float delta_percent,
const float* history, int history_count,
const char* format,
const char* icon) {
kpi_card_impl(label, value, delta_percent, history, history_count,
format, icon, /*fixed_y=*/false, 0.0f, 0.0f);
}
void kpi_card(const char* label, float value, float delta_percent,
const float* history, int history_count,
float y_min, float y_max,
const char* format,
const char* icon) {
kpi_card_impl(label, value, delta_percent, history, history_count,
format, icon, /*fixed_y=*/true, y_min, y_max);
}
+10 -1
View File
@@ -10,9 +10,18 @@
// - Optional icon (Tabler glyph) + label (small, muted) on top row // - Optional icon (Tabler glyph) + label (small, muted) on top row
// - Value (large font) // - Value (large font)
// - Delta badge (green TI_TRENDING_UP / red TI_TRENDING_DOWN) // - Delta badge (green TI_TRENDING_UP / red TI_TRENDING_DOWN)
// - Sparkline of history // - Sparkline of history (auto Y by default; fixed Y in overload v1.4)
void kpi_card(const char* label, float value, float delta_percent, void kpi_card(const char* label, float value, float delta_percent,
const float* history = nullptr, int history_count = 0, const float* history = nullptr, int history_count = 0,
const char* format = "%.1f", const char* format = "%.1f",
const char* icon = nullptr); const char* icon = nullptr);
// Fixed Y-scale variant (v1.4): el sparkline interno usa [y_min, y_max]
// absolutos. Util para metricas con dominio conocido (CPU%/RAM% -> 0,100)
// donde la comparacion visual entre cards exige misma escala.
void kpi_card(const char* label, float value, float delta_percent,
const float* history, int history_count,
float y_min, float y_max,
const char* format = "%.1f",
const char* icon = nullptr);
+6 -2
View File
@@ -3,11 +3,11 @@ name: kpi_card
kind: component kind: component
lang: cpp lang: cpp
domain: viz domain: viz
version: "1.3.0" version: "1.4.0"
purity: pure purity: pure
signature: "void kpi_card(const char* label, float value, float delta_percent, const float* history = nullptr, int history_count = 0, const char* format = \"%.1f\", const char* icon = nullptr)" signature: "void kpi_card(const char* label, float value, float delta_percent, const float* history = nullptr, int history_count = 0, const char* format = \"%.1f\", const char* icon = nullptr)"
description: "Card de KPI con icono opcional + label, valor grande, delta porcentual con TI_TRENDING_UP/DOWN y sparkline historico. Contenedor con surface bg, borde y radius via tokens (Mantine Paper equivalente)." description: "Card de KPI con icono opcional + label, valor grande, delta porcentual con TI_TRENDING_UP/DOWN y sparkline historico. Contenedor con surface bg, borde y radius via tokens (Mantine Paper equivalente)."
tags: [imgui, kpi, card, dashboard, metrics, sparkline, tokens, tabler] tags: [imgui, kpi, card, dashboard, metrics, sparkline, tokens, tabler, cpp-dashboard-viz]
uses_functions: ["sparkline_cpp_viz", "tokens_cpp_core"] uses_functions: ["sparkline_cpp_viz", "tokens_cpp_core"]
uses_types: [] uses_types: []
returns: [] returns: []
@@ -79,3 +79,7 @@ ImGui::Columns(1);
- La linea de trend siempre se reserva (delta, sparkline o em dash placeholder en `text_dim`) para que un grid de KPIs quede alineado vertical. - La linea de trend siempre se reserva (delta, sparkline o em dash placeholder en `text_dim`) para que un grid de KPIs quede alineado vertical.
- ~~Los caracteres UTF-8 del triangulo (`▲` U+25B2 y `▼` U+25BC) y del em dash (`—` U+2014) requieren que la fuente ImGui tenga el rango de simbolos geometricos / puntuacion general cargado.~~ → Obsoleto en v1.3: ahora se usan glyphs Tabler que estan en el atlas mergeado por `icon_font_cpp_core`. - ~~Los caracteres UTF-8 del triangulo (`▲` U+25B2 y `▼` U+25BC) y del em dash (`—` U+2014) requieren que la fuente ImGui tenga el rango de simbolos geometricos / puntuacion general cargado.~~ → Obsoleto en v1.3: ahora se usan glyphs Tabler que estan en el atlas mergeado por `icon_font_cpp_core`.
- Colores: delta usa `fn_tokens::colors::{success, error}`, placeholder `TI_MINUS` usa `text_dim`, label + icono usan `text_muted`. - Colores: delta usa `fn_tokens::colors::{success, error}`, placeholder `TI_MINUS` usa `text_dim`, label + icono usan `text_muted`.
## Capability growth log
- v1.4.0 (2026-05-18) — Overload con `y_min, y_max` que se propaga al sparkline interno. Cards de un grid con dominio fijo (CPU%/RAM% -> 0,100) ahora son visualmente comparables — outliers no aplastan la escala. Default sigue siendo auto-scale.
+68 -16
View File
@@ -5,28 +5,48 @@
namespace { namespace {
template <typename T> template <typename T>
void draw_line(const char* title, const T* xs, const T* ys, int count, float height) { void draw_line(const char* title, const T* xs, const T* ys, int count,
float height, bool auto_x, double x_lo_in, double x_hi_in,
bool auto_y, double y_lo_in, double y_hi_in) {
if (count <= 0) return; if (count <= 0) return;
T x_min = xs[0], x_max = xs[0]; double x_lo, x_hi;
T y_min = ys[0], y_max = ys[0]; if (auto_x) {
for (int i = 1; i < count; i++) { T x_min = xs[0], x_max = xs[0];
if (xs[i] < x_min) x_min = xs[i]; for (int i = 1; i < count; i++) {
if (xs[i] > x_max) x_max = xs[i]; if (xs[i] < x_min) x_min = xs[i];
if (ys[i] < y_min) y_min = ys[i]; if (xs[i] > x_max) x_max = xs[i];
if (ys[i] > y_max) y_max = ys[i]; }
x_lo = static_cast<double>(x_min);
x_hi = static_cast<double>(x_max);
} else {
x_lo = x_lo_in;
x_hi = x_hi_in;
}
if (x_hi - x_lo < 1e-9) x_hi = x_lo + 1.0;
double y_lo, y_hi;
if (auto_y) {
T y_min = ys[0], y_max = ys[0];
for (int i = 1; i < count; i++) {
if (ys[i] < y_min) y_min = ys[i];
if (ys[i] > y_max) y_max = ys[i];
}
double dy = static_cast<double>(y_max) - static_cast<double>(y_min);
if (dy < 1e-9) dy = 1.0;
y_lo = static_cast<double>(y_min) - dy * 0.05;
y_hi = static_cast<double>(y_max) + dy * 0.05;
} else {
y_lo = y_lo_in;
y_hi = y_hi_in;
if (y_hi - y_lo < 1e-9) y_hi = y_lo + 1.0;
} }
double dy = static_cast<double>(y_max) - static_cast<double>(y_min);
if (dy < 1e-9) dy = 1.0;
double y_lo = static_cast<double>(y_min) - dy * 0.05;
double y_hi = static_cast<double>(y_max) + dy * 0.05;
const ImVec2 plot_size(-1.0f, height > 0.0f ? height : 200.0f); const ImVec2 plot_size(-1.0f, height > 0.0f ? height : 200.0f);
if (ImPlot::BeginPlot(title, plot_size, plot_static::kPlotFlags)) { if (ImPlot::BeginPlot(title, plot_size, plot_static::kPlotFlags)) {
ImPlot::SetupAxes(nullptr, nullptr, plot_static::kAxisFlags, plot_static::kAxisFlags); ImPlot::SetupAxes(nullptr, nullptr, plot_static::kAxisFlags, plot_static::kAxisFlags);
ImPlot::SetupAxisLimits(ImAxis_X1, static_cast<double>(x_min), ImPlot::SetupAxisLimits(ImAxis_X1, x_lo, x_hi, ImPlotCond_Always);
static_cast<double>(x_max), ImPlotCond_Always);
ImPlot::SetupAxisLimits(ImAxis_Y1, y_lo, y_hi, ImPlotCond_Always); ImPlot::SetupAxisLimits(ImAxis_Y1, y_lo, y_hi, ImPlotCond_Always);
ImPlot::PlotLine("##data", xs, ys, count); ImPlot::PlotLine("##data", xs, ys, count);
ImPlot::EndPlot(); ImPlot::EndPlot();
@@ -36,9 +56,41 @@ void draw_line(const char* title, const T* xs, const T* ys, int count, float hei
} // namespace } // namespace
void line_plot(const char* title, const float* xs, const float* ys, int count, float height) { void line_plot(const char* title, const float* xs, const float* ys, int count, float height) {
draw_line<float>(title, xs, ys, count, height); draw_line<float>(title, xs, ys, count, height,
/*auto_x=*/true, 0.0, 0.0,
/*auto_y=*/true, 0.0, 0.0);
} }
void line_plot(const char* title, const double* xs, const double* ys, int count, float height) { void line_plot(const char* title, const double* xs, const double* ys, int count, float height) {
draw_line<double>(title, xs, ys, count, height); draw_line<double>(title, xs, ys, count, height,
/*auto_x=*/true, 0.0, 0.0,
/*auto_y=*/true, 0.0, 0.0);
}
void line_plot(const char* title, const float* xs, const float* ys, int count,
float y_min, float y_max, float height) {
draw_line<float>(title, xs, ys, count, height,
/*auto_x=*/true, 0.0, 0.0,
/*auto_y=*/false, (double)y_min, (double)y_max);
}
void line_plot(const char* title, const double* xs, const double* ys, int count,
double y_min, double y_max, float height) {
draw_line<double>(title, xs, ys, count, height,
/*auto_x=*/true, 0.0, 0.0,
/*auto_y=*/false, y_min, y_max);
}
void line_plot(const char* title, const float* xs, const float* ys, int count,
float x_min, float x_max, float y_min, float y_max, float height) {
draw_line<float>(title, xs, ys, count, height,
/*auto_x=*/false, (double)x_min, (double)x_max,
/*auto_y=*/false, (double)y_min, (double)y_max);
}
void line_plot(const char* title, const double* xs, const double* ys, int count,
double x_min, double x_max, double y_min, double y_max, float height) {
draw_line<double>(title, xs, ys, count, height,
/*auto_x=*/false, x_min, x_max,
/*auto_y=*/false, y_min, y_max);
} }
+17
View File
@@ -8,3 +8,20 @@ void line_plot(const char* title, const float* xs, const float* ys, int count,
float height = 200.0f); float height = 200.0f);
void line_plot(const char* title, const double* xs, const double* ys, int count, void line_plot(const char* title, const double* xs, const double* ys, int count,
float height = 200.0f); float height = 200.0f);
// Fixed Y-range overloads — pinea Y a [y_min, y_max] para series con dominio
// conocido (ej. 0-100 en CPU%/RAM%). v1.2.
void line_plot(const char* title, const float* xs, const float* ys, int count,
float y_min, float y_max, float height = 200.0f);
void line_plot(const char* title, const double* xs, const double* ys, int count,
double y_min, double y_max, float height = 200.0f);
// Fixed XY-range overloads — pinea BOTH X y Y. Util para ventanas temporales
// fijas (ej. ultimos 5 min de CPU%): xs en [0, WINDOW] y ys en [0, 100]. El
// plot NO se aplasta al variar count. v1.3.
void line_plot(const char* title, const float* xs, const float* ys, int count,
float x_min, float x_max, float y_min, float y_max,
float height = 200.0f);
void line_plot(const char* title, const double* xs, const double* ys, int count,
double x_min, double x_max, double y_min, double y_max,
float height = 200.0f);
+7 -2
View File
@@ -3,11 +3,11 @@ name: line_plot
kind: component kind: component
lang: cpp lang: cpp
domain: viz domain: viz
version: "1.1.0" version: "1.3.0"
purity: pure purity: pure
signature: "void line_plot(const char* title, const float* xs, const float* ys, int count, float height = 200.0f)" signature: "void line_plot(const char* title, const float* xs, const float* ys, int count, float height = 200.0f)"
description: "Line plot 2D con ImPlot, ejes pineados y altura explicita para no vibrar al redimensionar" description: "Line plot 2D con ImPlot, ejes pineados y altura explicita para no vibrar al redimensionar"
tags: [implot, chart, visualization, gpu, line, locked-axes] tags: [implot, chart, visualization, gpu, line, locked-axes, cpp-dashboard-viz]
uses_functions: ["plot_static_cpp_viz"] uses_functions: ["plot_static_cpp_viz"]
uses_types: [] uses_types: []
returns: [] returns: []
@@ -45,3 +45,8 @@ Wrapper atomico sobre `ImPlot::PlotLine` configurado para visualizacion estatica
- **Sin inputs, sin auto-fit** — ver `viz/plot_static.h`. - **Sin inputs, sin auto-fit** — ver `viz/plot_static.h`.
Soporta `float` y `double`. Soporta `float` y `double`.
## Capability growth log
- v1.2.0 (2026-05-18) — Overloads `(..., y_min, y_max, height)` para series con dominio conocido (CPU%/RAM% -> 0,100). Mantiene los overloads de auto-fit historico intactos. Tipos float y double.
- v1.3.0 (2026-05-18) — Overloads `(..., x_min, x_max, y_min, y_max, height)` que pinea AMBOS ejes. Util para ventanas temporales fijas (ej. ultimos 5 min): xs en [0, 300], el grafico NO se aplasta al variar count durante warmup.
+63 -38
View File
@@ -1,8 +1,11 @@
#include "viz/sparkline.h" #include "viz/sparkline.h"
#include "imgui.h" #include "imgui.h"
void sparkline(const char* id, const float* values, int count, ImVec4 color, // Implementacion comun. Si auto_y=true, calcula min/max de values; si no,
float width, float height) { // usa [y_min, y_max] explicitos.
static void sparkline_impl(const char* id, const float* values, int count,
ImVec4 color, float width, float height,
bool auto_y, float y_min, float y_max) {
if (count <= 0) return; if (count <= 0) return;
ImGui::PushID(id); ImGui::PushID(id);
@@ -10,67 +13,89 @@ void sparkline(const char* id, const float* values, int count, ImVec4 color,
ImVec2 pos = ImGui::GetCursorScreenPos(); ImVec2 pos = ImGui::GetCursorScreenPos();
ImDrawList* draw_list = ImGui::GetWindowDrawList(); ImDrawList* draw_list = ImGui::GetWindowDrawList();
// Reserve inline space
ImGui::Dummy(ImVec2(width, height)); ImGui::Dummy(ImVec2(width, height));
// Find min/max for Y auto-scale if (auto_y) {
float min_val = values[0]; y_min = values[0];
float max_val = values[0]; y_max = values[0];
for (int i = 1; i < count; i++) { for (int i = 1; i < count; i++) {
if (values[i] < min_val) min_val = values[i]; if (values[i] < y_min) y_min = values[i];
if (values[i] > max_val) max_val = values[i]; if (values[i] > y_max) y_max = values[i];
}
} }
float range = max_val - min_val; float range = y_max - y_min;
if (range < 1e-6f) range = 1.0f; // avoid division by zero for flat lines if (range < 1e-6f) range = 1.0f;
auto y_at = [&](float v) {
// Clamp visualmente al rango — para fixed Y sirve para que outliers
// no salgan del card; para auto Y es no-op.
if (v < y_min) v = y_min;
if (v > y_max) v = y_max;
return pos.y + height - ((v - y_min) / range) * height;
};
// Fade gradient v1.2: alpha sube de older->newer.
// El segmento [i, i+1] se pinta con alpha proporcional al endpoint derecho
// (mas cercano a "ahora"). Hace efecto de rastro / trail.
const int r_ = (int)(color.x * 255);
const int g_ = (int)(color.y * 255);
const int b_ = (int)(color.z * 255);
auto seg_t = [&](int i) {
return (count > 1) ? (float)(i + 1) / (float)(count - 1) : 1.0f;
};
// Fill area under curve (low alpha)
if (count >= 2) { if (count >= 2) {
ImU32 fill_color = IM_COL32(
(int)(color.x * 255),
(int)(color.y * 255),
(int)(color.z * 255),
40);
// Build fill polygon: baseline bottom-left -> points -> baseline bottom-right
// We use AddConvexPolyFilled workaround: draw as a series of triangles from baseline
float x0 = pos.x; float x0 = pos.x;
float y_base = pos.y + height; float y_base = pos.y + height;
for (int i = 0; i + 1 < count; i++) { for (int i = 0; i + 1 < count; i++) {
float t = seg_t(i);
int fill_a = (int)((0.10f + 0.70f * t) * 40.0f); // 4..28 alpha
ImU32 fill_color = IM_COL32(r_, g_, b_, fill_a);
float xa = x0 + ((float)i / (count - 1)) * width; float xa = x0 + ((float)i / (count - 1)) * width;
float xb = x0 + ((float)(i + 1) / (count - 1)) * width; float xb = x0 + ((float)(i + 1) / (count - 1)) * width;
float ya = pos.y + height - ((values[i] - min_val) / range) * height; float ya = y_at(values[i]);
float yb = pos.y + height - ((values[i + 1] - min_val) / range) * height; float yb = y_at(values[i + 1]);
draw_list->AddQuadFilled( draw_list->AddQuadFilled(
ImVec2(xa, y_base), ImVec2(xa, y_base), ImVec2(xa, ya),
ImVec2(xa, ya), ImVec2(xb, yb), ImVec2(xb, y_base),
ImVec2(xb, yb),
ImVec2(xb, y_base),
fill_color); fill_color);
} }
} }
// Draw polyline
ImU32 line_color = IM_COL32(
(int)(color.x * 255),
(int)(color.y * 255),
(int)(color.z * 255),
(int)(color.w * 255));
for (int i = 0; i + 1 < count; i++) { for (int i = 0; i + 1 < count; i++) {
float t = seg_t(i);
float a = 0.20f + 0.80f * t;
ImU32 line_color = IM_COL32(r_, g_, b_, (int)(color.w * a * 255));
float xa = pos.x + ((float)i / (count - 1)) * width; float xa = pos.x + ((float)i / (count - 1)) * width;
float xb = pos.x + ((float)(i + 1) / (count - 1)) * width; float xb = pos.x + ((float)(i + 1) / (count - 1)) * width;
float ya = pos.y + height - ((values[i] - min_val) / range) * height; float ya = y_at(values[i]);
float yb = pos.y + height - ((values[i + 1] - min_val) / range) * height; float yb = y_at(values[i + 1]);
draw_list->AddLine(ImVec2(xa, ya), ImVec2(xb, yb), line_color, 1.5f); draw_list->AddLine(ImVec2(xa, ya), ImVec2(xb, yb), line_color, 1.5f);
} }
ImGui::PopID(); ImGui::PopID();
} }
void sparkline(const char* id, const float* values, int count, ImVec4 color,
float width, float height) {
sparkline_impl(id, values, count, color, width, height,
/*auto_y=*/true, 0.0f, 0.0f);
}
void sparkline(const char* id, const float* values, int count, void sparkline(const char* id, const float* values, int count,
float width, float height) { float width, float height) {
// Default color: soft green
sparkline(id, values, count, ImVec4(0.35f, 0.85f, 0.45f, 1.0f), width, height); sparkline(id, values, count, ImVec4(0.35f, 0.85f, 0.45f, 1.0f), width, height);
} }
void sparkline(const char* id, const float* values, int count, ImVec4 color,
float y_min, float y_max, float width, float height) {
sparkline_impl(id, values, count, color, width, height,
/*auto_y=*/false, y_min, y_max);
}
void sparkline(const char* id, const float* values, int count,
float y_min, float y_max, float width, float height) {
sparkline(id, values, count, ImVec4(0.35f, 0.85f, 0.45f, 1.0f),
y_min, y_max, width, height);
}
+14 -1
View File
@@ -3,10 +3,23 @@
#include "imgui.h" #include "imgui.h"
// Renders a mini inline line chart for use in tables, headers and KPI cards. // Renders a mini inline line chart for use in tables, headers and KPI cards.
// Auto-scales Y to the min/max of values.
// Uses PushID/PopID with id for uniqueness inside tables. // Uses PushID/PopID with id for uniqueness inside tables.
//
// Auto Y-scale variants:
void sparkline(const char* id, const float* values, int count, void sparkline(const char* id, const float* values, int count,
float width = 100.0f, float height = 20.0f); float width = 100.0f, float height = 20.0f);
void sparkline(const char* id, const float* values, int count, ImVec4 color, void sparkline(const char* id, const float* values, int count, ImVec4 color,
float width = 100.0f, float height = 20.0f); float width = 100.0f, float height = 20.0f);
// Fixed Y-scale variants — clamp the polyline to [y_min, y_max] so cards in a
// grid stay visually comparable (ej. CPU% / RAM%: pasar 0,100). v1.1.
// width y height son explicitos (sin default) para que el compilador no haga
// match contra los overloads sin y_min/y_max.
void sparkline(const char* id, const float* values, int count,
float y_min, float y_max,
float width, float height);
void sparkline(const char* id, const float* values, int count, ImVec4 color,
float y_min, float y_max,
float width, float height);
+7 -2
View File
@@ -3,11 +3,11 @@ name: sparkline
kind: component kind: component
lang: cpp lang: cpp
domain: viz domain: viz
version: "1.0.0" version: "1.2.0"
purity: pure purity: pure
signature: "void sparkline(const char* id, const float* values, int count, float width = 100.0f, float height = 20.0f)" signature: "void sparkline(const char* id, const float* values, int count, float width = 100.0f, float height = 20.0f)"
description: "Renderiza un mini grafico de lineas inline para uso en tablas, headers y KPI cards" description: "Renderiza un mini grafico de lineas inline para uso en tablas, headers y KPI cards"
tags: [imgui, visualization, sparkline, inline, dashboard] tags: [imgui, visualization, sparkline, inline, dashboard, data-table-renderers, cpp-dashboard-viz]
uses_functions: [] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
@@ -69,3 +69,8 @@ sparkline("kpi_rev", data, count);
- Si todos los valores son iguales (rango < 1e-6), la linea se dibuja en el centro verticalmente. - Si todos los valores son iguales (rango < 1e-6), la linea se dibuja en el centro verticalmente.
- El grosor de linea es 1.5px para que sea legible a alturas de 16-24px. - El grosor de linea es 1.5px para que sea legible a alturas de 16-24px.
- `id` no se muestra visualmente; solo se pasa a `PushID` para que ImGui diferencie widgets con los mismos datos en la misma tabla. - `id` no se muestra visualmente; solo se pasa a `PushID` para que ImGui diferencie widgets con los mismos datos en la misma tabla.
## Capability growth log
- v1.1.0 (2026-05-18) — Overloads con `y_min, y_max` explicitos. Cuando la metrica tiene dominio conocido (CPU%/RAM% -> 0,100) varias cards comparten escala y son comparables visualmente. Outliers se clampan al rango. Variant sin bounds preserva auto-scale historico.
- v1.2.0 (2026-05-18) — Alpha fade gradient default: segmento i pinta con alpha lerp(0.2, 1.0, i/(count-1)). Older -> faded, newest -> bright. Efecto "trail" hacia el numero actual. Fill bajo curva tambien fade (alpha 4..28). Cambio visual sin cambio de API.
+10
View File
@@ -256,6 +256,16 @@ add_fn_test(test_tql_to_sql test_tql_to_sql.cpp
add_fn_test(test_llm_anthropic test_llm_anthropic.cpp add_fn_test(test_llm_anthropic test_llm_anthropic.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/llm_anthropic.cpp) ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/llm_anthropic.cpp)
# --- Issue 0109a — parse_md_frontmatter: pure YAML-subset frontmatter parser --
add_fn_test(test_parse_md_frontmatter test_parse_md_frontmatter.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/parse_md_frontmatter.cpp)
target_compile_definitions(test_parse_md_frontmatter PRIVATE
FN_TEST_REPO_ROOT="${CMAKE_CURRENT_SOURCE_DIR}/../..")
# --- Issue 0109b — compute_ring_layout: geometria pura para skill_tree -------
add_fn_test(test_compute_ring_layout test_compute_ring_layout.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/compute_ring_layout.cpp)
# --- Visual golden-image diff (issue 0048) --------------------------------- # --- Visual golden-image diff (issue 0048) ---------------------------------
# El binario primitives_gallery se compila con --capture; el test compara los # El binario primitives_gallery se compila con --capture; el test compara los
# PNGs generados con los goldens en cpp/tests/golden/. Si no hay goldens o el # PNGs generados con los goldens en cpp/tests/golden/. Si no hay goldens o el
+337
View File
@@ -0,0 +1,337 @@
// Tests para fn_ring::compute_ring_layout (cpp/functions/core/compute_ring_layout).
// Pure: sin ImGui context, sin I/O.
// Issue 0109b — skill_tree ring layout.
#define CATCH_CONFIG_MAIN
#include "catch_amalgamated.hpp"
#include "core/compute_ring_layout.h"
#include <algorithm>
#include <cmath>
#include <string>
#include <vector>
using namespace fn_ring;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
static const LayoutConfig kDefault; // default 5 rings, 18 sectors
static LayoutOutput find_by_id(const std::vector<LayoutOutput>& out,
const std::string& id) {
for (auto& o : out) {
if (o.id == id) return o;
}
// not found — return sentinel with ring=-99
LayoutOutput bad;
bad.id = id;
bad.ring = -99;
return bad;
}
static float dist2(float x, float y) {
return std::sqrt(x * x + y * y);
}
// ---------------------------------------------------------------------------
// Test 1: empty input → empty output
// ---------------------------------------------------------------------------
TEST_CASE("empty_input_empty_output") {
auto out = compute_ring_layout({}, kDefault);
REQUIRE(out.empty());
}
// ---------------------------------------------------------------------------
// Test 2: un solo nodo cae en centro de su banda radial + centro de su sector
// ---------------------------------------------------------------------------
TEST_CASE("single_node_centered_in_bin") {
std::vector<LayoutInput> nodes = {{"n1", "completado", "core", 1.0f}};
// "completado" → ring 0, ring_radii default: [0, 150)
// r_inner efectivo = 30 (ring0 avoidance), r_outer = 150
// r_lo = 30 + 14 = 44, r_hi = 150 - 14 = 136, center = (44+136)/2 = 90
auto out = compute_ring_layout(nodes, kDefault);
REQUIRE(out.size() == 1);
auto o = out[0];
REQUIRE(o.ring == 0);
REQUIRE(o.sector == 17); // "core" no esta en DomainOrder vacia → sector n_sectors-1 = 17
float r = dist2(o.x, o.y);
// Centro de banda: [44, 136] -> 90.0
REQUIRE(std::abs(r - 90.0f) < 0.01f);
}
// ---------------------------------------------------------------------------
// Test 3: dos nodos en mismo bin → radios distintos, uniformemente distribuidos
// ---------------------------------------------------------------------------
TEST_CASE("two_nodes_same_bin_radial_distribution") {
std::vector<LayoutInput> nodes = {
{"a", "completado", "alpha", 0.5f},
{"b", "completado", "alpha", 0.5f},
};
DomainOrder order = {"alpha"};
auto out = compute_ring_layout(nodes, kDefault, {}, order);
REQUIRE(out.size() == 2);
auto oa = find_by_id(out, "a");
auto ob = find_by_id(out, "b");
REQUIRE(oa.ring == 0);
REQUIRE(ob.ring == 0);
REQUIRE(oa.sector == 0);
REQUIRE(ob.sector == 0);
float ra = dist2(oa.x, oa.y);
float rb = dist2(ob.x, ob.y);
// Radios deben ser distintos
REQUIRE(std::abs(ra - rb) > 0.01f);
// Ambos deben estar en la banda [44, 136]
float r_lo = 44.0f; // 30 + 14
float r_hi = 136.0f; // 150 - 14
REQUIRE(ra >= r_lo - 0.01f);
REQUIRE(ra <= r_hi + 0.01f);
REQUIRE(rb >= r_lo - 0.01f);
REQUIRE(rb <= r_hi + 0.01f);
// N=2: r_0 = 44 + 0.5*(136-44)/2 = 44+23 = 67; r_1 = 44+1.5*46 = 113
// (aprox, sin jitter porque bin tiene 2 nodos y capacidad radial = band/18 ~ 5)
// No verificamos valores exactos porque el jitter angular puede activarse,
// pero la diferencia de radios debe ser aprox (r_hi-r_lo)/N = 46
float expected_step = (r_hi - r_lo) / 2.0f; // 46
REQUIRE(std::abs(std::abs(ra - rb) - expected_step) < 0.5f);
}
// ---------------------------------------------------------------------------
// Test 4: default status map mapea correctamente
// ---------------------------------------------------------------------------
TEST_CASE("default_status_map") {
std::vector<LayoutInput> nodes = {
{"c1", "completado", "d", 0.0f},
{"c2", "completed", "d", 0.0f},
{"c3", "in-progress", "d", 0.0f},
{"c4", "pendiente", "d", 0.0f},
{"c5", "deferred", "d", 0.0f},
{"c6", "locked", "d", 0.0f},
{"c7", "unlocked", "d", 0.0f},
{"c8", "bloqueado", "d", 0.0f},
};
auto out = compute_ring_layout(nodes, kDefault);
REQUIRE(find_by_id(out, "c1").ring == 0);
REQUIRE(find_by_id(out, "c2").ring == 0);
REQUIRE(find_by_id(out, "c3").ring == 1);
REQUIRE(find_by_id(out, "c4").ring == 3);
REQUIRE(find_by_id(out, "c5").ring == 4);
REQUIRE(find_by_id(out, "c6").ring == 3);
REQUIRE(find_by_id(out, "c7").ring == 2);
REQUIRE(find_by_id(out, "c8").ring == 4);
}
// ---------------------------------------------------------------------------
// Test 5: status no mapeado → ring == -1, x/y == 0
// ---------------------------------------------------------------------------
TEST_CASE("unmapped_status_returns_ring_minus_one") {
std::vector<LayoutInput> nodes = {{"x1", "unknown_status", "core", 0.5f}};
auto out = compute_ring_layout(nodes, kDefault);
REQUIRE(out.size() == 1);
auto o = out[0];
REQUIRE(o.ring == -1);
REQUIRE(o.x == 0.0f);
REQUIRE(o.y == 0.0f);
}
// ---------------------------------------------------------------------------
// Test 6: domain fuera del orden → sector n_sectors-1
// ---------------------------------------------------------------------------
TEST_CASE("domain_not_in_order_falls_back_to_last_sector") {
DomainOrder order = {"alpha", "beta", "gamma"};
std::vector<LayoutInput> nodes = {{"n1", "completado", "unknown_domain", 0.5f}};
LayoutConfig cfg = kDefault;
cfg.n_sectors = 5;
auto out = compute_ring_layout(nodes, cfg, {}, order);
REQUIRE(out.size() == 1);
REQUIRE(out[0].sector == 4); // n_sectors-1 = 4
}
// ---------------------------------------------------------------------------
// Test 7: determinismo — dos llamadas identicas producen el mismo output
// ---------------------------------------------------------------------------
TEST_CASE("deterministic_repeated_call") {
std::vector<LayoutInput> nodes = {
{"0001", "completado", "core", 1.0f},
{"0002", "pendiente", "infra", 0.5f},
{"0003", "in-progress", "finance", 0.8f},
{"0004", "deferred", "core", 0.1f},
{"0005", "completado", "infra", 0.9f},
};
auto out1 = compute_ring_layout(nodes, kDefault);
auto out2 = compute_ring_layout(nodes, kDefault);
REQUIRE(out1.size() == out2.size());
for (size_t i = 0; i < out1.size(); ++i) {
REQUIRE(out1[i].id == out2[i].id);
REQUIRE(out1[i].x == out2[i].x);
REQUIRE(out1[i].y == out2[i].y);
REQUIRE(out1[i].ring == out2[i].ring);
REQUIRE(out1[i].sector == out2[i].sector);
REQUIRE(out1[i].rank_in_bin == out2[i].rank_in_bin);
}
}
// ---------------------------------------------------------------------------
// Test 8: ring 0 con radio interno 0 → nodos colocados con r >= 30
// ---------------------------------------------------------------------------
TEST_CASE("ring_zero_avoids_origin") {
// ring_radii[0] == 0 por defecto
std::vector<LayoutInput> nodes = {
{"n1", "completado", "d1", 1.0f},
{"n2", "completed", "d2", 0.9f},
};
auto out = compute_ring_layout(nodes, kDefault);
for (auto& o : out) {
if (o.ring == 0) {
float r = dist2(o.x, o.y);
REQUIRE(r >= 30.0f - 0.01f); // kRing0InnerMin = 30
REQUIRE(r > 0.5f); // definitivamente no en el origen
}
}
}
// ---------------------------------------------------------------------------
// Test 9: sector wrap-around — sector 17 (ultimo) con 18 sectores
// theta ≈ 2*PI*(17+0.5)/18 = 2*PI*17.5/18 ≈ 6.109 rad
// ---------------------------------------------------------------------------
TEST_CASE("sector_wrap_around_last_sector") {
// Forzamos que el nodo caiga en sector 17 pasando domain_order sin "misc"
std::vector<LayoutInput> nodes = {{"n1", "completado", "misc_domain", 1.0f}};
LayoutConfig cfg;
cfg.n_sectors = 18;
cfg.ring_radii = {0.0f, 150.0f, 280.0f, 450.0f, 650.0f, 850.0f};
cfg.start_angle = 0.0f;
cfg.bin_padding = 14.0f;
DomainOrder order; // vacia → "misc_domain" cae en sector n_sectors-1 = 17
auto out = compute_ring_layout(nodes, cfg, {}, order);
REQUIRE(out.size() == 1);
REQUIRE(out[0].sector == 17);
// theta = start_angle + (17 + 0.5) * (2*PI / 18) = 17.5 / 18 * 2*PI
const float pi = 3.14159265358979323846f;
const float theta = (17.5f / 18.0f) * 2.0f * pi; // ≈ 6.1087 rad
// Radio del nodo: ring 0, r_inner=30, r_outer=150, padding=14 → [44,136] → 90
const float r = 90.0f;
float expected_x = r * std::cos(theta);
float expected_y = r * std::sin(theta);
REQUIRE(std::abs(out[0].x - expected_x) < 0.05f);
REQUIRE(std::abs(out[0].y - expected_y) < 0.05f);
}
// ---------------------------------------------------------------------------
// Test 10: golden snapshot — 30 nodos, mezcla de status/domain
// Verifica primer y ultimo nodo con tolerancia 0.001
// ---------------------------------------------------------------------------
TEST_CASE("golden_snapshot_30_nodes") {
// Input fijo: 30 nodos con mix de status/domain/recency
const std::vector<LayoutInput> nodes = {
{"0001", "completado", "core", 1.00f},
{"0002", "completado", "infra", 0.95f},
{"0003", "in-progress", "finance", 0.90f},
{"0004", "pendiente", "core", 0.85f},
{"0005", "deferred", "datascience", 0.80f},
{"0006", "completado", "core", 0.75f},
{"0007", "locked", "infra", 0.70f},
{"0008", "unlocked", "finance", 0.65f},
{"0009", "pendiente", "core", 0.60f},
{"0010", "in-progress", "datascience", 0.55f},
{"0011", "completado", "infra", 0.50f},
{"0012", "bloqueado", "core", 0.45f},
{"0013", "deferred", "finance", 0.40f},
{"0014", "pendiente", "infra", 0.35f},
{"0015", "completado", "datascience", 0.30f},
{"0016", "unknown_status", "core", 0.25f}, // descartado
{"0017", "in-progress", "infra", 0.20f},
{"0018", "pendiente_unlocked", "finance", 0.15f},
{"0019", "completed", "datascience", 0.10f},
{"0020", "locked", "core", 0.05f},
{"0021", "completado", "core", 1.00f},
{"0022", "completado", "infra", 0.92f},
{"0023", "pendiente", "finance", 0.88f},
{"0024", "in-progress", "core", 0.84f},
{"0025", "deferred", "infra", 0.80f},
{"0026", "unlocked", "datascience", 0.76f},
{"0027", "completado", "finance", 0.72f},
{"0028", "locked", "datascience", 0.68f},
{"0029", "bloqueado", "core", 0.64f},
{"0030", "pendiente", "finance", 0.60f},
};
DomainOrder order = {"core", "infra", "finance", "datascience"};
LayoutConfig cfg;
cfg.n_sectors = 18;
cfg.ring_radii = {0.0f, 150.0f, 280.0f, 450.0f, 650.0f, 850.0f};
cfg.start_angle = 0.0f;
cfg.bin_padding = 14.0f;
cfg.center_x = 0.0f;
cfg.center_y = 0.0f;
auto out = compute_ring_layout(nodes, cfg, {}, order);
REQUIRE(out.size() == 30);
// Nodo "0016" tiene status desconocido → debe ser descartado
auto o16 = find_by_id(out, "0016");
REQUIRE(o16.ring == -1);
REQUIRE(o16.x == 0.0f);
REQUIRE(o16.y == 0.0f);
// Verificar "0001": completado→ring0, core→sector0
auto o1 = find_by_id(out, "0001");
REQUIRE(o1.ring == 0);
REQUIRE(o1.sector == 0);
// theta = 0 + (0 + 0.5) * (2*PI / 18) = PI/18 ≈ 0.17453 rad
// En el bin (ring=0, sector=0) hay "0001", "0006", "0021", "0024"
// ordenados por (recency desc): recency 1.00, 0.75, 1.00 (0021), 0.84
// -> "0001"(1.0), "0021"(1.0, id mayor), "0024"(0.84), "0006"(0.75)
// rank "0001" = 0 si su id < "0021": "0001" < "0021" → rank 0
REQUIRE(o1.rank_in_bin == 0);
// El radio y angulo exactos dependen del jitter deterministico.
// Verificamos que esta dentro de la banda del ring 0: [44, 136]
float r1 = dist2(o1.x, o1.y);
REQUIRE(r1 >= 44.0f - 0.01f);
REQUIRE(r1 <= 136.0f + 0.01f);
// Verificar "0030": pendiente→ring3, finance→sector2
auto o30 = find_by_id(out, "0030");
REQUIRE(o30.ring == 3);
REQUIRE(o30.sector == 2);
// ring 3: [450, 650), r_lo = 450+14 = 464, r_hi = 650-14 = 636
float r30 = dist2(o30.x, o30.y);
REQUIRE(r30 >= 464.0f - 0.01f);
REQUIRE(r30 <= 636.0f + 0.01f);
// El output es deterministico: dos llamadas dan resultado identico
auto out2 = compute_ring_layout(nodes, cfg, {}, order);
for (size_t i = 0; i < out.size(); ++i) {
REQUIRE(out[i].x == out2[i].x);
REQUIRE(out[i].y == out2[i].y);
}
}
+270
View File
@@ -0,0 +1,270 @@
// Tests for parse_md_frontmatter (cpp/functions/core/parse_md_frontmatter).
// Pure function — no ImGui context, no I/O (except the golden-run test which
// reads dev/issues/ using FN_TEST_REPO_ROOT defined at compile time).
#define CATCH_CONFIG_MAIN
#include "catch_amalgamated.hpp"
#include "core/parse_md_frontmatter.h"
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
using namespace fn_md;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
static std::string str(const YamlValue& v) {
if (auto* s = std::get_if<std::string>(&v)) return *s;
return "";
}
static std::vector<std::string> lst(const YamlValue& v) {
if (auto* l = std::get_if<std::vector<std::string>>(&v)) return *l;
return {};
}
static bool has(const Frontmatter& fm, const std::string& key) {
return fm.fields.count(key) > 0;
}
// ---------------------------------------------------------------------------
// Case 1 — No frontmatter
// ---------------------------------------------------------------------------
TEST_CASE("parses_no_frontmatter") {
const std::string content = "# Just a markdown file\n\nSome body text.\n";
auto fm = parse_md_frontmatter(content);
REQUIRE_FALSE(fm.has_frontmatter);
REQUIRE(fm.body == content);
REQUIRE(fm.fields.empty());
}
// ---------------------------------------------------------------------------
// Case 2 — Simple key:value
// ---------------------------------------------------------------------------
TEST_CASE("parses_simple_key_value") {
const std::string content =
"---\n"
"title: Hello\n"
"status: pending\n"
"---\n"
"Body here.\n";
auto fm = parse_md_frontmatter(content);
REQUIRE(fm.has_frontmatter);
REQUIRE(str(fm.fields.at("title")) == "Hello");
REQUIRE(str(fm.fields.at("status")) == "pending");
REQUIRE(fm.body == "Body here.\n");
}
// ---------------------------------------------------------------------------
// Case 3 — Quoted string (double and single quotes)
// ---------------------------------------------------------------------------
TEST_CASE("parses_quoted_strings") {
const std::string content =
"---\n"
"description: \"foo bar\"\n"
"note: 'baz qux'\n"
"id: \"0109\"\n"
"---\n";
auto fm = parse_md_frontmatter(content);
REQUIRE(fm.has_frontmatter);
REQUIRE(str(fm.fields.at("description")) == "foo bar");
REQUIRE(str(fm.fields.at("note")) == "baz qux");
REQUIRE(str(fm.fields.at("id")) == "0109");
}
// ---------------------------------------------------------------------------
// Case 4 — Inline list
// ---------------------------------------------------------------------------
TEST_CASE("parses_inline_list") {
const std::string content =
"---\n"
"tags: [meta, cpp, imgui]\n"
"---\n";
auto fm = parse_md_frontmatter(content);
REQUIRE(fm.has_frontmatter);
const auto v = lst(fm.fields.at("tags"));
REQUIRE(v.size() == 3);
REQUIRE(v[0] == "meta");
REQUIRE(v[1] == "cpp");
REQUIRE(v[2] == "imgui");
}
// ---------------------------------------------------------------------------
// Case 5 — Multiline list
// ---------------------------------------------------------------------------
TEST_CASE("parses_multiline_list") {
const std::string content =
"---\n"
"domain:\n"
" - meta\n"
" - cpp-stack\n"
"---\n";
auto fm = parse_md_frontmatter(content);
REQUIRE(fm.has_frontmatter);
const auto v = lst(fm.fields.at("domain"));
REQUIRE(v.size() == 2);
REQUIRE(v[0] == "meta");
REQUIRE(v[1] == "cpp-stack");
}
// ---------------------------------------------------------------------------
// Case 6 — Body after frontmatter
// ---------------------------------------------------------------------------
TEST_CASE("parses_body_after_frontmatter") {
const std::string content =
"---\n"
"key: v\n"
"---\n"
"body text\n";
auto fm = parse_md_frontmatter(content);
REQUIRE(fm.has_frontmatter);
REQUIRE(str(fm.fields.at("key")) == "v");
REQUIRE(fm.body == "body text\n");
}
// ---------------------------------------------------------------------------
// Case 7 — Empty list
// ---------------------------------------------------------------------------
TEST_CASE("parses_empty_inline_list") {
const std::string content =
"---\n"
"blocks: []\n"
"depends: []\n"
"---\n";
auto fm = parse_md_frontmatter(content);
REQUIRE(fm.has_frontmatter);
REQUIRE(lst(fm.fields.at("blocks")).empty());
REQUIRE(lst(fm.fields.at("depends")).empty());
}
// ---------------------------------------------------------------------------
// Case 8 — Trailing comments stripped
// ---------------------------------------------------------------------------
TEST_CASE("parses_strips_trailing_comment") {
const std::string content =
"---\n"
"key: value # this is a comment\n"
"other: hello # another\n"
"---\n";
auto fm = parse_md_frontmatter(content);
REQUIRE(fm.has_frontmatter);
REQUIRE(str(fm.fields.at("key")) == "value");
REQUIRE(str(fm.fields.at("other")) == "hello");
}
// ---------------------------------------------------------------------------
// Case 9 — Real issue sample (issue 0109)
// ---------------------------------------------------------------------------
TEST_CASE("parses_real_issue_0109") {
const std::string content =
"---\n"
"id: \"0109\"\n"
"title: \"App skill_tree: mapa interactivo de issues+flows en anillos concentricos por estado (roadmap)\"\n"
"status: in-progress\n"
"type: epic\n"
"domain:\n"
" - meta\n"
" - cpp-stack\n"
"scope: cross-stack\n"
"priority: media\n"
"depends: []\n"
"blocks: []\n"
"related:\n"
" - \"0069\"\n"
" - \"0085\"\n"
"created: 2026-05-17\n"
"updated: 2026-05-17\n"
"tags:\n"
" - skill-tree\n"
" - roadmap\n"
" - meta\n"
" - cpp\n"
" - imgui\n"
" - gamification\n"
"---\n"
"\n"
"# Body\n";
auto fm = parse_md_frontmatter(content);
REQUIRE(fm.has_frontmatter);
REQUIRE_FALSE(fm.fields.empty());
REQUIRE(str(fm.fields.at("id")) == "0109");
REQUIRE(str(fm.fields.at("status")) == "in-progress");
const auto domain = lst(fm.fields.at("domain"));
REQUIRE(domain.size() == 2);
REQUIRE(domain[0] == "meta");
REQUIRE(domain[1] == "cpp-stack");
const auto tags = lst(fm.fields.at("tags"));
REQUIRE(tags.size() == 6);
REQUIRE(tags[0] == "skill-tree");
}
// ---------------------------------------------------------------------------
// Case 10 — Golden run: parse ALL dev/issues/*.md and dev/flows/*.md
//
// Requires the compile-time definition FN_TEST_REPO_ROOT pointing to the
// root of the fn_registry repo (set by CMakeLists.txt via
// target_compile_definitions(... PRIVATE FN_TEST_REPO_ROOT="...") ).
//
// The test reads every .md file it finds, parses it, and verifies:
// 1. No crash / no exception.
// 2. If the file starts with `---`, has_frontmatter == true.
// 3. fields is not empty.
// 4. Cumulative parse_errors == 0.
// ---------------------------------------------------------------------------
#ifndef FN_TEST_REPO_ROOT
#define FN_TEST_REPO_ROOT ""
#endif
#include <filesystem>
TEST_CASE("parses_real_issues_golden") {
const std::string repo_root = FN_TEST_REPO_ROOT;
if (repo_root.empty()) {
WARN("FN_TEST_REPO_ROOT not set — skipping golden run");
return;
}
std::vector<std::filesystem::path> md_files;
for (const auto& dir : {"dev/issues", "dev/flows"}) {
const auto p = std::filesystem::path(repo_root) / dir;
if (!std::filesystem::is_directory(p)) continue;
for (const auto& entry : std::filesystem::directory_iterator(p)) {
if (entry.path().extension() == ".md")
md_files.push_back(entry.path());
}
}
REQUIRE_FALSE(md_files.empty());
int parse_errors = 0;
for (const auto& path : md_files) {
std::ifstream f(path);
REQUIRE(f.is_open());
std::ostringstream ss;
ss << f.rdbuf();
const std::string content = ss.str();
Frontmatter fm;
// Must not throw
REQUIRE_NOTHROW(fm = parse_md_frontmatter(content));
if (content.rfind("---", 0) == 0) {
// File starts with `---`: expect frontmatter was found
INFO("File: " << path.string());
REQUIRE(fm.has_frontmatter);
REQUIRE_FALSE(fm.fields.empty());
}
}
REQUIRE(parse_errors == 0);
}
+563
View File
@@ -0,0 +1,563 @@
# data_table integration audit — 8 apps
Canonical pattern (from modules/data_table/data_table.md):
```cpp
static data_table::State g_table_state; // PERSISTENT entre frames
data_table::TableInput tbl;
tbl.name = "my_table";
tbl.headers = {"col1","col2"};
tbl.types = {data_table::ColumnType::String, data_table::ColumnType::Int};
tbl.cells = cells_ptr; // row-major flat array
tbl.rows = N;
tbl.cols = 2;
std::vector<data_table::TableEvent> events;
ImGui::BeginChild("##tbl_host");
data_table::render("##my_tbl_id", { tbl }, g_table_state, &events);
ImGui::EndChild();
for (const auto& ev : events) {
if (ev.kind == data_table::TableEventKind::RowDoubleClick) { ... }
}
```
Anti-patterns the audit flags:
- inline_begintable [warn]: app calls ImGui::BeginTable directly instead of data_table::render. Migrar la tabla a data_table::render con un TableInput.
- state_not_persistent [error]: data_table::State declarado dentro de funcion sin `static`. Mover a `static` local o miembro global; sino se pierde drill/sort/filtros cada frame.
- no_child_host [warn]: data_table::render se llama sin BeginChild/Begin en las ~30 lineas previas. Envolver con `ImGui::BeginChild("##host");``ImGui::EndChild();`.
- no_event_sink [info]: app declara uses_modules data_table_cpp pero no captura events_out. Pasar `&events` para reaccionar a double-click / right-click / ButtonClick.
- cmake_missing_link [error]: app.md declara `uses_modules: [data_table_cpp]` pero CMakeLists.txt no enlaza `fn_module_data_table`.
---
## app_gestion [warn] (apps/app_gestion)
### inline_begintable [warn] apps/app_gestion/main.cpp:722
snippet: if (ImGui::BeginTable("##linked_tbl", 4,
```
717 ? "?" : a->linked_build.c_str());
718 if (a->linked_modules.empty()) {
719 ImGui::TextDisabled("(sin info — la app no se ha buildeado todavia,"
720 " o no genera _modules_generated.cpp)");
721 } else {
>> 722 if (ImGui::BeginTable("##linked_tbl", 4,
723 ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) {
724 ImGui::TableSetupColumn("module");
725 ImGui::TableSetupColumn("linked");
726 ImGui::TableSetupColumn("registry");
727 ImGui::TableSetupColumn("status");
```
## dag_engine_ui [warn] (apps/dag_engine_ui)
### inline_begintable [warn] apps/dag_engine_ui/tabs.cpp:382
snippet: if (ImGui::BeginTable("##dt_run_steps", 6, steps_flags)) {
```
377 ImGui::BeginChild("##run_steps_wrap", ImVec2(-1, ImGui::GetContentRegionAvail().y * 0.5f));
378 const ImGuiTableFlags steps_flags =
379 ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
380 ImGuiTableFlags_Resizable | ImGuiTableFlags_SizingStretchProp |
381 ImGuiTableFlags_ScrollY;
>> 382 if (ImGui::BeginTable("##dt_run_steps", 6, steps_flags)) {
383 ImGui::TableSetupScrollFreeze(0, 1);
384 ImGui::TableSetupColumn("Step", ImGuiTableColumnFlags_WidthStretch, 1.6f);
385 ImGui::TableSetupColumn("Function", ImGuiTableColumnFlags_WidthStretch, 2.2f);
386 ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthStretch, 0.8f);
387 ImGui::TableSetupColumn("Exit", ImGuiTableColumnFlags_WidthStretch, 0.4f);
```
### inline_begintable [warn] apps/dag_engine_ui/tabs.cpp:731
snippet: if (ImGui::BeginTable("##health_kpis", 4,
```
726 "Trigger a DAG to populate health metrics.");
727 ImGui::End();
728 return;
729 }
730
>> 731 if (ImGui::BeginTable("##health_kpis", 4,
732 ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchSame))
733 {
734 ImGui::TableNextRow();
735
736 ImGui::TableNextColumn();
```
## data_factory [warn] (apps/data_factory)
### no_child_host [warn] apps/data_factory/tabs.cpp:291
snippet: data_table::render(dt_id, {tbl}, *st, &events);
```
286 }
287 cells_to_ptrs(*backing, *ptrs);
288 tbl.cells = ptrs->data();
289
290 std::vector<data_table::TableEvent> events;
>> 291 data_table::render(dt_id, {tbl}, *st, &events);
292
293 for (auto& ev : events) {
294 if (ev.kind == data_table::TableEventKind::RowDoubleClick &&
295 ev.row >= 0 && ev.row < static_cast<int>(filtered.size()))
296 {
```
### no_child_host [warn] apps/data_factory/tabs.cpp:454
snippet: data_table::render("##dt_tables", {tbl}, g_st_tables, &tbl_events);
```
449 }
450 cells_to_ptrs(g_back_tables, g_ptrs_tables);
451 tbl.cells = g_ptrs_tables.data();
452
453 std::vector<data_table::TableEvent> tbl_events;
>> 454 data_table::render("##dt_tables", {tbl}, g_st_tables, &tbl_events);
455 for (auto& ev : tbl_events) {
456 if (ev.kind == data_table::TableEventKind::RowDoubleClick &&
457 ev.row >= 0 && ev.row < static_cast<int>(tables.size()))
458 {
459 const auto& t = tables[ev.row];
```
### no_child_host [warn] apps/data_factory/tabs.cpp:531
snippet: data_table::render("##dt_databases", {tbl}, g_st_databases);
```
526 g_back_databases.push_back(d.last_seen_at.empty() ? "-" : d.last_seen_at);
527 }
528 cells_to_ptrs(g_back_databases, g_ptrs_databases);
529 tbl.cells = g_ptrs_databases.data();
530
>> 531 data_table::render("##dt_databases", {tbl}, g_st_databases);
532 }
533 ImGui::End();
534 }
535
536 // ---------------------------------------------------------------------------
```
### no_child_host [warn] apps/data_factory/tabs.cpp:619
snippet: data_table::render("##dt_kpis", {tbl}, g_st_kpis);
```
614 std::snprintf(buf, sizeof(buf), "%lld KB", kb_24h); g_back_kpis.push_back(buf);
615
616 cells_to_ptrs(g_back_kpis, g_ptrs_kpis);
617 tbl.cells = g_ptrs_kpis.data();
618
>> 619 data_table::render("##dt_kpis", {tbl}, g_st_kpis);
620 }
621
622 ImGui::Separator();
623 ImGui::TextDisabled("Computed client-side from %zu runs in cache.", runs_all.size());
624 ImGui::End();
```
### no_child_host [warn] apps/data_factory/tabs.cpp:883
snippet: data_table::render("##dt_node_runs", {tbl}, g_st_node_runs, &events);
```
878 }
879 cells_to_ptrs(g_back_node_runs, g_ptrs_node_runs);
880 tbl.cells = g_ptrs_node_runs.data();
881
882 std::vector<data_table::TableEvent> events;
>> 883 data_table::render("##dt_node_runs", {tbl}, g_st_node_runs, &events);
884
885 for (auto& ev : events) {
886 if (ev.kind == data_table::TableEventKind::RowDoubleClick &&
887 ev.row >= 0 && ev.row < static_cast<int>(shown_runs.size()))
888 {
```
### no_child_host [warn] apps/data_factory/tabs.cpp:992
snippet: data_table::render("##dt_preview", {tbl}, g_st_preview, nullptr, true);
```
987 }
988 }
989 cells_to_ptrs(g_back_preview, g_ptrs_preview);
990 tbl.cells = g_ptrs_preview.data();
991
>> 992 data_table::render("##dt_preview", {tbl}, g_st_preview, nullptr, true);
993 }
994
995 // Pagination controls.
996 ImGui::Separator();
997 {
```
## graph_explorer [warn] (projects/osint_graph/apps/graph_explorer)
### inline_begintable [warn] projects/osint_graph/apps/graph_explorer/extract_panel.cpp:981
snippet: if (ImGui::BeginTable("##ents", 5,
```
976 }
977
978 // Tabla de entidades.
979 if (!res->entities.empty() &&
980 ImGui::CollapsingHeader("Entities", ImGuiTreeNodeFlags_DefaultOpen)) {
>> 981 if (ImGui::BeginTable("##ents", 5,
982 ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
983 ImGuiTableFlags_ScrollY,
984 ImVec2(0.0f, 200.0f))) {
985 ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 28.0f);
986 ImGui::TableSetupColumn("Type", ImGuiTableColumnFlags_WidthFixed, 120.0f);
```
### inline_begintable [warn] projects/osint_graph/apps/graph_explorer/extract_panel.cpp:1027
snippet: if (ImGui::BeginTable("##rels", 5,
```
1022 }
1023
1024 // Tabla de relaciones.
1025 if (!res->relations.empty() &&
1026 ImGui::CollapsingHeader("Relations", ImGuiTreeNodeFlags_DefaultOpen)) {
>> 1027 if (ImGui::BeginTable("##rels", 5,
1028 ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
1029 ImGuiTableFlags_ScrollY,
1030 ImVec2(0.0f, 160.0f))) {
1031 ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 28.0f);
1032 ImGui::TableSetupColumn("From", ImGuiTableColumnFlags_WidthFixed, 100.0f);
```
### inline_begintable [warn] projects/osint_graph/apps/graph_explorer/main.cpp:1127
snippet: if (ImGui::BeginTable("##enr_params", 2,
```
1122 // con mas params de los que llenamos al abrir la ventana.
1123 if (g_app.enr_modal_param_bufs.size() < spec->params.size()) {
1124 g_app.enr_modal_param_bufs.resize(spec->params.size());
1125 }
1126
>> 1127 if (ImGui::BeginTable("##enr_params", 2,
1128 ImGuiTableFlags_SizingStretchProp |
1129 ImGuiTableFlags_NoBordersInBody)) {
1130 ImGui::TableSetupColumn("name", ImGuiTableColumnFlags_WidthFixed, 110.0f);
1131 ImGui::TableSetupColumn("value", ImGuiTableColumnFlags_WidthStretch);
1132
```
### inline_begintable [warn] projects/osint_graph/apps/graph_explorer/views.cpp:885
snippet: if (ImGui::BeginTable("##insp_id", 2,
```
880 // Layout label-izquierda / input-derecha via 2-col table. El label
881 // alineado al frame del input y el input estirado al ancho restante.
882 ImGui::TextUnformatted("Identity");
883 ImGui::Separator();
884
>> 885 if (ImGui::BeginTable("##insp_id", 2,
886 ImGuiTableFlags_SizingStretchProp |
887 ImGuiTableFlags_NoBordersInBody)) {
888 ImGui::TableSetupColumn("k", ImGuiTableColumnFlags_WidthFixed, 90.0f);
889 ImGui::TableSetupColumn("v", ImGuiTableColumnFlags_WidthStretch);
890
```
### inline_begintable [warn] projects/osint_graph/apps/graph_explorer/views.cpp:958
snippet: if (ImGui::BeginTable("##insp_fields", 2,
```
953 ImGui::TextUnformatted("Fields");
954 ImGui::Separator();
955 const EntitySpec* spec = find_entity_spec(app.parsed_types,
956 app.insp_type_buf);
957
>> 958 if (ImGui::BeginTable("##insp_fields", 2,
959 ImGuiTableFlags_SizingStretchProp |
960 ImGuiTableFlags_NoBordersInBody)) {
961 ImGui::TableSetupColumn("k", ImGuiTableColumnFlags_WidthFixed, 90.0f);
962 ImGui::TableSetupColumn("v", ImGuiTableColumnFlags_WidthStretch);
963
```
### inline_begintable [warn] projects/osint_graph/apps/graph_explorer/views.cpp:1546
snippet: // OLD: ImGui::BeginTable("##tablev", 6, ...) with manual sort/filter/clipper.
```
1541 }
1542
1543 // ----------------------------------------------------------------------------
1544 // Table view (issue 0004) — migrated to data_table::render (issue 0081-J).
1545 //
>> 1546 // OLD: ImGui::BeginTable("##tablev", 6, ...) with manual sort/filter/clipper.
1547 // Per-column filter popups + chips toolbar + TabBar per type.
1548 // Click on row selected node in graph viewport.
1549 //
1550 // NEW: data_table::render() provides sort + filter + viz + stages.
1551 // AppState::table_dt_state persists the UI state between frames.
```
### inline_begintable [warn] projects/osint_graph/apps/graph_explorer/views.cpp:1854
snippet: if (col_count > 0 && ImGui::BeginTable("##te_rows", col_count, tflags,
```
1849 : (int)m.columns.size() + 2;
1850 ImGuiTableFlags tflags =
1851 ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
1852 ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable |
1853 ImGuiTableFlags_SizingStretchProp;
>> 1854 if (col_count > 0 && ImGui::BeginTable("##te_rows", col_count, tflags,
1855 ImVec2(0, -ImGui::GetFrameHeightWithSpacing()))) {
1856 ImGui::TableSetupScrollFreeze(0, 1);
1857 if (is_group) {
1858 for (size_t i = 0; i < m.columns.size(); ++i) {
1859 bool is_id = (i == 0);
```
### inline_begintable [warn] projects/osint_graph/apps/graph_explorer/views.cpp:2292
snippet: if (ImGui::BeginTable("##te_fields", 5,
```
2287 // Fields table
2288 ImGui::Spacing();
2289 ImGui::TextUnformatted("Fields");
2290 ImGui::Separator();
2291
>> 2292 if (ImGui::BeginTable("##te_fields", 5,
2293 ImGuiTableFlags_BordersInnerV
2294 | ImGuiTableFlags_RowBg
2295 | ImGuiTableFlags_SizingStretchProp)) {
2296 ImGui::TableSetupColumn("name");
2297 ImGui::TableSetupColumn("type", ImGuiTableColumnFlags_WidthFixed, 90.0f);
```
### no_child_host [warn] projects/osint_graph/apps/graph_explorer/views.cpp:1632
snippet: data_table::render("##tablev_dt", {tbl}, app.table_dt_state);
```
1627 tbl.rows = s_rows_cached;
1628 tbl.cols = k_ncols;
1629
1630 // Render con chrome completo (barra de chips + breadcrumb).
1631 // app.table_dt_state persiste entre frames.
>> 1632 data_table::render("##tablev_dt", {tbl}, app.table_dt_state);
1633
1634 ImGui::End();
1635 }
1636
1637 // ----------------------------------------------------------------------------
```
## navegator_dashboard [warn] (projects/navegator/apps/navegator_dashboard)
### inline_begintable [warn] projects/navegator/apps/navegator_dashboard/autoextract_panel.cpp:528
snippet: if (ImGui::BeginTable("##ax_schema", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) {
```
523 {
524 std::lock_guard<std::mutex> lk(g_ax.mu);
525 sc_copy = g_ax.schema;
526 }
527
>> 528 if (ImGui::BeginTable("##ax_schema", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) {
529 ImGui::TableSetupColumn("field");
530 ImGui::TableSetupColumn("selector");
531 ImGui::TableSetupColumn("sample");
532 ImGui::TableSetupColumn("type");
533 ImGui::TableSetupColumn("keep");
```
### no_child_host [warn] projects/navegator/apps/navegator_dashboard/panels.cpp:234
snippet: data_table::render("##dt_browsers", {tbl}, g_browsers.dt_state, &dt_events, /*show_chrome=*/false);
```
229 data_table::BadgeRule{"visible", "#22c55e", "visible"},
230 };
231 }
232
233 std::vector<data_table::TableEvent> dt_events;
>> 234 data_table::render("##dt_browsers", {tbl}, g_browsers.dt_state, &dt_events, /*show_chrome=*/false);
235 for (auto& ev : dt_events) {
236 if (ev.kind == data_table::TableEventKind::RowDoubleClick &&
237 ev.row >= 0 && ev.row < static_cast<int>(g_browsers.instances.size())) {
238 g_session().select_browser(g_browsers.instances[ev.row].port);
239 }
```
### no_child_host [warn] projects/navegator/apps/navegator_dashboard/panels.cpp:456
snippet: data_table::render("##dt_tabs", {tbl}, g_tabs_ui.dt_state, &dt_events, /*show_chrome=*/false);
```
451 data_table::BadgeRule{"no", "#6b7280", "no"},
452 };
453 }
454
455 std::vector<data_table::TableEvent> dt_events;
>> 456 data_table::render("##dt_tabs", {tbl}, g_tabs_ui.dt_state, &dt_events, /*show_chrome=*/false);
457 for (auto& ev : dt_events) {
458 if (ev.kind == data_table::TableEventKind::RowDoubleClick &&
459 ev.row >= 0 && ev.row < static_cast<int>(visible_tabs.size())) {
460 const CdpTab* tp = visible_tabs[ev.row];
461 if (tp && !tp->ws_url.empty()) {
```
### no_child_host [warn] projects/navegator/apps/navegator_dashboard/panels.cpp:1016
snippet: data_table::render("##dt_wsframes", {ws_tbl}, g_dt_wsframes, false);
```
1011 data_table::BadgeRule{"ping", "#6b7280", "ping"},
1012 data_table::BadgeRule{"pong", "#6b7280", "pong"},
1013 };
1014 }
1015
>> 1016 data_table::render("##dt_wsframes", {ws_tbl}, g_dt_wsframes, false);
1017 ImGui::EndTabItem();
1018 }
1019 ImGui::EndTabBar();
1020 }
1021 }
```
### no_child_host [warn] projects/navegator/apps/navegator_dashboard/panels.cpp:1322
snippet: data_table::render("##dt_requests", {req_tbl}, g_net_ui.dt_state, &dt_events, /*show_chrome=*/true);
```
1317 cs.duration_warn_ms = 1000.0f;
1318 cs.duration_error_ms = 5000.0f;
1319 }
1320
1321 std::vector<data_table::TableEvent> dt_events;
>> 1322 data_table::render("##dt_requests", {req_tbl}, g_net_ui.dt_state, &dt_events, /*show_chrome=*/true);
1323 for (auto& ev : dt_events) {
1324 if (ev.kind == data_table::TableEventKind::RowDoubleClick &&
1325 ev.row >= 0 && ev.row < static_cast<int>(filtered.size())) {
1326 g_net_ui.selected_id = filtered[ev.row]->id;
1327 g_net_ui.selected_index = ev.row;
```
### inline_begintable [warn] projects/navegator/apps/navegator_dashboard/recipes_panel.cpp:238
snippet: } else if (ImGui::BeginTable("##recipes_tbl", 6,
```
233 }
234
235 if (rows_copy.empty()) {
236 ImGui::TextDisabled("No recipes in projects/navegator/profiles/default/recipes/.");
237 ImGui::TextDisabled("Use AutoExtract panel to create one.");
>> 238 } else if (ImGui::BeginTable("##recipes_tbl", 6,
239 ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) {
240 ImGui::TableSetupColumn("name");
241 ImGui::TableSetupColumn("url_pattern");
242 ImGui::TableSetupColumn("last_status");
243 ImGui::TableSetupColumn("last_at");
```
## odr_console [ok] (projects/online_data_recopilation/apps/odr_console)
### no_event_sink [info] projects/online_data_recopilation/apps/odr_console:0
snippet: no TableEvent / events_out found in any source file
## registry_dashboard [warn] (projects/fn_monitoring/apps/registry_dashboard)
### inline_begintable [warn] projects/fn_monitoring/apps/registry_dashboard/views.cpp:380
snippet: if (ImGui::BeginTable("##kpi_grid", 4, flags)) {
```
375 // del registry). Si no hay datos cargados, queda vacio y el card mostrara
376 // solo valor + delta placeholder.
377 const float* spark_data = data.date_values.empty() ? nullptr : data.date_values.data();
378 const int spark_count = static_cast<int>(data.date_values.size());
379
>> 380 if (ImGui::BeginTable("##kpi_grid", 4, flags)) {
381 struct KPI { const char* label; float value; const char* fmt; const char* icon; };
382 const KPI cards[8] = {
383 {"Functions", static_cast<float>(stats.total_functions), "%.0f", TI_FUNCTION},
384 {"Types", static_cast<float>(stats.total_types), "%.0f", TI_HEXAGON},
385 {"Apps", static_cast<float>(stats.total_apps), "%.0f", TI_APPS},
```
### inline_begintable [warn] projects/fn_monitoring/apps/registry_dashboard/views.cpp:436
snippet: if (ImGui::BeginTable("##chart_grid", 4, flags)) {
```
431 void draw_charts(RegistryData& data, float height) {
432 const ImGuiTableFlags flags = ImGuiTableFlags_SizingStretchSame
433 | ImGuiTableFlags_NoPadOuterX;
434 const float plot_h = height - 48.0f;
435
>> 436 if (ImGui::BeginTable("##chart_grid", 4, flags)) {
437 ImGui::TableNextRow();
438
439 ImGui::TableSetColumnIndex(0);
440 {
441 ImVec2 sz(ImGui::GetContentRegionAvail().x, height);
```
### inline_begintable [warn] projects/fn_monitoring/apps/registry_dashboard/views.cpp:648
snippet: if (ImGui::BeginTable("##monitor_kpi", 7, flags)) {
```
643
644 // 7 KPI cards: Calls / MCP / Reg% / Errors / Violations / Copies / Versions
645 // "MCP" = calls Claude lanza via tools registry-aware (mcp / fn_cli_run /
646 // heredoc). "Reg %" = porcentaje del total con function_id no vacio.
647 const ImGuiTableFlags flags = ImGuiTableFlags_SizingStretchSame | ImGuiTableFlags_NoPadOuterX;
>> 648 if (ImGui::BeginTable("##monitor_kpi", 7, flags)) {
649 struct KPI { const char* label; float value; const char* icon; const char* fmt; };
650 const KPI cards[7] = {
651 {"Calls", static_cast<float>(cu.total_calls), TI_ACTIVITY, "%.0f"},
652 {"MCP", static_cast<float>(cu.total_mcp), TI_PLUG_CONNECTED, "%.0f"},
653 {"Reg %", static_cast<float>(cu.registry_pct), TI_PERCENTAGE, "%.1f%%"},
```
### inline_begintable [warn] projects/fn_monitoring/apps/registry_dashboard/views.cpp:1110
snippet: if (!ImGui::BeginTable("##proj_layout", 2, flags)) return;
```
1105 }
1106
1107 // Dos columnas: izquierda arbol, derecha detalle.
1108 const ImGuiTableFlags flags = ImGuiTableFlags_Resizable
1109 | ImGuiTableFlags_SizingStretchProp;
>> 1110 if (!ImGui::BeginTable("##proj_layout", 2, flags)) return;
1111
1112 ImGui::TableSetupColumn("tree", ImGuiTableColumnFlags_WidthStretch, 1.0f);
1113 ImGui::TableSetupColumn("detail", ImGuiTableColumnFlags_WidthStretch, 2.5f);
1114 ImGui::TableNextRow();
1115
```
### inline_begintable [warn] projects/fn_monitoring/apps/registry_dashboard/views.cpp:1448
snippet: if (!ImGui::BeginTable("##explorer_layout", 2, flags)) return;
```
1443 return;
1444 }
1445
1446 const ImGuiTableFlags flags = ImGuiTableFlags_Resizable
1447 | ImGuiTableFlags_SizingStretchProp;
>> 1448 if (!ImGui::BeginTable("##explorer_layout", 2, flags)) return;
1449
1450 ImGui::TableSetupColumn("list", ImGuiTableColumnFlags_WidthStretch, 1.0f);
1451 ImGui::TableSetupColumn("detail", ImGuiTableColumnFlags_WidthStretch, 2.4f);
1452 ImGui::TableNextRow();
1453
```
### inline_begintable [warn] projects/fn_monitoring/apps/registry_dashboard/work_tab.cpp:239
snippet: if (ImGui::BeginTable("##flows_work", 8,
```
234 // Flows table
235 ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted);
236 ImGui::TextUnformatted("Flows");
237 ImGui::PopStyleColor();
238
>> 239 if (ImGui::BeginTable("##flows_work", 8,
240 ImGuiTableFlags_RowBg | ImGuiTableFlags_Borders |
241 ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_Resizable)) {
242 ImGui::TableSetupColumn("ID", ImGuiTableColumnFlags_WidthFixed, 50.0f);
243 ImGui::TableSetupColumn("Name");
244 ImGui::TableSetupColumn("Pattern", ImGuiTableColumnFlags_WidthFixed, 110.0f);
```
### inline_begintable [warn] projects/fn_monitoring/apps/registry_dashboard/work_tab.cpp:272
snippet: if (ImGui::BeginTable("##top_issues_work", 7,
```
267 ImGui::Spacing();
268 ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted);
269 ImGui::TextUnformatted("Top issues (priority alta, not done)");
270 ImGui::PopStyleColor();
271
>> 272 if (ImGui::BeginTable("##top_issues_work", 7,
273 ImGuiTableFlags_RowBg | ImGuiTableFlags_Borders |
274 ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_Resizable)) {
275 ImGui::TableSetupColumn("ID", ImGuiTableColumnFlags_WidthFixed, 70.0f);
276 ImGui::TableSetupColumn("Title");
277 ImGui::TableSetupColumn("Type", ImGuiTableColumnFlags_WidthFixed, 80.0f);
```
### no_event_sink [info] projects/fn_monitoring/apps/registry_dashboard:0
snippet: no TableEvent / events_out found in any source file
## services_monitor [ok] (apps/services_monitor)
### no_event_sink [info] apps/services_monitor:0
snippet: no TableEvent / events_out found in any source file
+7
View File
@@ -1,4 +1,11 @@
{ {
"modules-v2": {
"enabled": true,
"issue": "0107",
"description": "Sistema de modulos C++ estandarizado: 0/17 apps con drift (fn doctor modules), data_table.cpp partido en 6 sub-funciones (4777->1818 LOC, -62%), members vs uses_functions separados (tiers), docs API publica completa (modules/README.md + docs/MODULES_API.md con 21 capacidades). 0107e (min_version + codegen fail-loud) y 0107g (8 inline_begintable migrations) quedan como follow-ups bajo el mismo flag — son evolutivos, no bloqueantes.",
"added": "2026-05-17",
"enabled_at": "2026-05-17"
},
"dag-engine": { "dag-engine": {
"enabled": true, "enabled": true,
"issue": "0007", "issue": "0007",
@@ -0,0 +1,157 @@
---
name: kanban-cpp-and-agent-workflows
id: 0008
status: pending
created: 2026-05-18
updated: 2026-05-18
priority: high
risk: medium
related_issues: [0109, 0112, 0113, 0114, 0115, 0116, 0117, 0118, 0119]
apps:
- kanban_cpp
- kanban
- skill_tree
- agent_runner_api
trigger: ui-button
schedule: ""
expected_runtime_s: 0
tags: [agents, workflows, dod, worktrees, cpp, kanban, llm]
pattern: realtime-loop
---
## Goal
Montar el stack para que agentes LLM trabajen issues/cards **en paralelo, en worktrees aislados, con DoD obligatorio y evidencia adjunta**. Tres apps colaboran:
- **kanban_cpp** (nueva, C++ ImGui) — clon visual de `kanban_web` con su propio backend Go (copia + operations.db independiente). Tablero pensado para conducir agentes, no humanos.
- **skill_tree v2** (existente) — reemplaza el boton `Claude fix` (que abria terminal `wt.exe`) por `Launch workflow` que dispara un run remoto. Anade panel DoD evidence + timeline cross-app.
- **agent_runner_api** (nueva, service Go) — daemon que persiste workflows, crea worktrees, lanza `claude --headless`, valida DoD, expone HTTP + SSE. Source of truth de `agent_runs.db`.
El bucle final: humano clica card en kanban_cpp (o issue en skill_tree) -> agent_runner_api crea worktree `auto/<id>-<slug>` -> spawn agente -> agente trabaja + adjunta evidencia (screenshot, log, url) por cada item DoD -> humano valida en el panel DoD -> merge TBD a master.
## Pre-requisitos
- Backend Go de `apps/kanban/` funcional (HTTP API + WS + auth + migrations). YA.
- `claude --dangerously-skip-permissions` disponible en PATH del runner (host WSL).
- `parallel-fix-issues` skill como base para worktree spawning. YA.
- Frontmatter canonico de issues/flows (issue 0100). YA.
- Capability group `agents` definido (o crear).
## Arquitectura
```
┌─────────────────┐ ┌─────────────────┐
│ kanban_cpp │ │ skill_tree v2 │
│ (C++ ImGui) │ │ (C++ ImGui) │
│ Board/Cal/Dash │ │ Tree + DoD pan │
│ Agent runs │ │ Timeline pan │
│ Worktree mgr │ │ DoD inspector │
│ DoD inspector │ │ │
└────────┬────────┘ └────────┬────────┘
│ HTTP+SSE │ HTTP+SSE
├──────────────┬──────────┘
│ │
▼ ▼
┌───────────────┐ ┌───────────────────────┐
│ kanban_cpp │ │ agent_runner_api │
│ backend Go │ │ (service Go, :8486) │
│ (copia, :8401)│ │ ┌───────────────────┐ │
│ operations.db │ │ │ agent_runs.db │ │
└───────────────┘ │ │ - workflows │ │
│ │ - runs │ │
│ │ - worktrees │ │
│ │ - dod_items │ │
│ │ - dod_evidence │ │
│ └───────────────────┘ │
│ spawn claude headless │
│ git worktree add/rm │
│ TBD merge --no-ff │
└───────────────────────┘
```
Decisiones cerradas con el usuario:
- kanban_cpp y kanban_web tienen backends Go **independientes** (copia identica, datos separados). NO sync.
- `agent_runs.db` vive en service nuevo `agent_runner_api`. Port dedicado (default :8486).
- Workflows = `git worktree` paralelos. Trunk-based development obligatorio (rama `auto/<issue>` -> PR -> merge master).
- DoD: declarado en frontmatter (`dod_user:` + nuevo `dod_evidence_schema:`), persistido en BD con tabla `dod_evidence` (un row por evidencia adjunta).
## Flow
### Trigger 1: humano dispara workflow desde UI
1. En `skill_tree` clica issue -> boton `Launch workflow` (sustituye `Claude fix`).
- POST `http://localhost:8486/api/runs` con `{issue_id, mode: "fix-issue", dod_items: [...]}`.
2. O en `kanban_cpp` arrastra card a columna `Doing (agent)` -> mismo endpoint con `{card_id, kanban_app: "kanban_cpp"}`.
3. `agent_runner_api`:
- Crea row en `agent_runs` (status=`pending`).
- `git worktree add ../wt-<run_id> -b auto/<issue>-<slug>`.
- Lanza subprocess `claude --dangerously-skip-permissions -p "<prompt con DoD items + worktree path>"` en background.
- Devuelve `run_id` + URL SSE para stream de progreso.
### Trigger 2: agente trabaja en worktree
1. Agente lee DoD items del prompt + `dev/issues/<id>.md`.
2. Por cada item DoD: implementa -> ejecuta -> captura evidencia:
- Tipo `screenshot`: `wt.exe ...` o `import` -> path PNG en `agent_runs/<run_id>/evidence/<item_id>.png`.
- Tipo `log`: `tee` output a `agent_runs/<run_id>/evidence/<item_id>.log`.
- Tipo `url`: ruta de service deployado (ej. `http://localhost:8401/board`).
- Tipo `cmd`: comando + stdout esperado, ej. `fn doctor cpp-apps -> 0 errors`.
3. POST `http://localhost:8486/api/runs/<run_id>/evidence` por cada item -> persiste en `dod_evidence`.
4. SSE empuja `progress` a UIs conectadas (kanban_cpp + skill_tree refrescan timeline).
### Trigger 3: humano valida DoD
1. Panel DoD inspector (kanban_cpp + skill_tree): muestra checklist DoD + evidencia por item.
2. Humano aprueba item por item (`POST /api/runs/<run_id>/evidence/<id>/validate`).
3. Cuando todos validados:
- `agent_runner_api` ejecuta `cd <main_repo> && git merge --no-ff auto/<issue>` (o abre PR Gitea si configurado).
- Limpia worktree.
- Status -> `done`.
### Trigger 4: rollback / abort
1. Humano clica `Abort` en cualquier UI.
2. `agent_runner_api`: kill subprocess + `git worktree remove --force ../wt-<run_id>` + `git branch -D auto/<issue>`.
3. Run status -> `aborted`.
## Sub-issues que abre
| # | Titulo | Tipo | Notas |
|---|--------|------|------|
| 0112 | kanban_cpp app + backend copia | app | Scaffolder `/new-cpp-app kanban_cpp` + `cp -r apps/kanban/backend apps/kanban_cpp/backend` + 6 panels (Board/Cal/Dash/Runs/Worktrees/DoD). Trio app.md (`columns-3` accent `#a855f7`) |
| 0113 | agent_runner_api service + agent_runs.db | app | Service Go :8486, tag `service`, migrations en `apps/agent_runner_api/migrations/` (001 workflows + 002 runs + 003 worktrees + 004 dod_items + 005 dod_evidence) |
| 0114 | DoD evidence schema canonico | feature | Anade `dod_evidence_schema:` al frontmatter de issues/flows. Cada item: `{id, kind: screenshot\|log\|url\|cmd, expected, required: bool}`. Validator en `audit_dod_schema_go_infra` |
| 0115 | Worktree launcher con DoD | feature | Funcion `agent_launch_worktree_go_infra` (impure): `git worktree add + spawn claude + tail stderr`. Reemplaza logica inline de `parallel-fix-issues` |
| 0116 | skill_tree: replace Claude-fix por Launch workflow | feature | Modificar `apps/skill_tree/main.cpp`. Remove `spawn_claude_terminal`. Anade POST a `:8486/api/runs`. Toggle setting `legacy_claude_fix` para fallback temporal |
| 0117 | DoD evidence panel (componente C++ reutilizable) | feature | Nueva funcion `dod_evidence_panel_cpp_viz` en `cpp/functions/viz/`. Render screenshots (stb_image), logs (selectable_text), urls (ShellExecute), cmds (output expected vs actual). Reusable kanban_cpp + skill_tree |
| 0118 | Agent runs timeline panel | feature | Nueva funcion `agent_runs_timeline_cpp_viz`. Lee SSE de agent_runner_api, lista runs ordenados por started_at, color por status. Reusable cross-app |
| 0119 | kanban_cpp sync issues + flows como cards | feature | Backend lee `dev/issues/*.md` + `dev/flows/*.md`, expone 2 boards (Issues + Flows). PATCH frontmatter writes back. Watcher fsnotify. Click `Launch` invoca agent_runner_api |
Issue 0109 (skill_tree roadmap) ya existe -> se actualiza con dependencia hacia 0116/0117/0118.
DoD tecnico (Acceptance: corre + Tecnico: vive solo) se redacta dentro de **cada sub-issue**. Aqui solo vive el DoD user-facing del flow como capacidad.
## Definition of Done (user-facing, 4 surfaces obligatorios)
Flow = capacidad de usuario. DoD tecnico (migrations, e2e_checks, uses_functions, build cross-compile, capability groups) vive en los sub-issues 0110-0116, NO aqui.
- [ ] **Surface 1 (kanban_cpp board)**: usuario abre `kanban_cpp.exe` desde el App Hub, ve board con columnas + cards de su backend independiente. Drag card a columna `Doing (agent)` arranca un agente; ve barra de progreso en panel `Agent runs` sin tocar terminal.
- [ ] **Surface 2 (skill_tree Launch workflow)**: usuario clica `Launch workflow` sobre un nodo issue, ve toast `run_id=...` + nueva entrada en panel `Timeline`. El boton viejo `Claude fix` (terminal externa) ya no aparece (o queda detras de feature flag OFF). Cero `wt.exe` abierto.
- [ ] **Surface 3 (DoD inspector)**: usuario abre cualquier run desde kanban_cpp o skill_tree, ve N items DoD con: estado (pending/done/validated/failed), thumbnail screenshot, snippet log, link clickable url, output cmd vs expected. Boton `Validate` por item; al validarlos todos, el run pasa a `merged` y el worktree desaparece.
- [ ] **Surface 4 (Timeline cross-app)**: panel `Timeline` en skill_tree muestra runs de skill_tree + kanban_cpp + futuras apps en una sola lista. Filtros por status / app / fecha. Click en run abre el detalle con los items DoD y sus evidencias.
## Gotchas
- Backend copia kanban: `operations.db` independiente. Auth tokens (`users` table) NO se replican entre apps por defecto — cada app tiene sus usuarios. Decidir si compartir tabla auth o duplicar (decision: duplicar, evita coupling).
- Worktree + pre-commit hooks: comparten `.git/hooks/` con main. Si un hook llama scripts via path absoluto, ejecuta version main. Mismo gotcha que `autonomous_loop.md` -> aplicar fixes EN el worktree, nunca en main.
- claude headless en WSL: `--dangerously-skip-permissions` necesario; salida a stderr/stdout debe quedar en `agent_runs/<run_id>/agent.log` para auditoria. NO subir log a repo.
- Screenshots desde WSL apuntan a `/mnt/c/...` o nombre Windows -> el panel DoD lee paths con `wslpath` cuando hace falta.
- Trio app.md obligatorio en `kanban_cpp/app.md`: ver `feedback_app_trio_required.md`. Sin description + icon.phosphor + icon.accent, hub queda gris.
- `dod_user:` ya existe en frontmatter (issue 0102) — NO renombrar. Anadimos `dod_evidence_schema:` como bloque hermano que declara la forma de cada evidencia.
- skill_tree mantiene `Claude fix` legacy detras de feature flag `legacy_claude_fix` durante 1-2 semanas para rollback rapido (ver `feature_flags.md`).
## Telemetria esperada
- `agent_runner_api` registra cada run, cada evidence, cada validate en su propia `operations.db` (entities + executions). Cross-cut con `call_monitor` via tag `agent_run`.
- Metrica `Reg %` debe SUBIR: agentes en worktree usan funciones del registry para todo (screenshots, log capture, validators). Inline -> proposal automatica.
- Metrica nueva `agent_runs.dod_pass_rate` = `validated_items / total_dod_items` por run. Threshold inicial 0.8.
+1
View File
@@ -11,6 +11,7 @@ Tabla de casos de uso multi-app. Mantenida por `/flow create` y `/flow done`.
| [0005](0005-osint-person-lookup.md) | osint-person-lookup | manual-deep | navegator_dashboard, odr_console, graph_explorer | pending | medium | 0% | 2026-05-16 | | [0005](0005-osint-person-lookup.md) | osint-person-lookup | manual-deep | navegator_dashboard, odr_console, graph_explorer | pending | medium | 0% | 2026-05-16 |
| [0006](0006-metabase-versioning.md) | metabase-versioning | gitops | auto_metabase, dag_engine | pending | medium | 0% | 2026-05-16 | | [0006](0006-metabase-versioning.md) | metabase-versioning | gitops | auto_metabase, dag_engine | pending | medium | 0% | 2026-05-16 |
| [0007](0007-matrix-telemetry-bot.md) | matrix-telemetry-bot | event-driven | data_factory, dag_engine, call_monitor, agents_and_robots | pending | low | 0% | 2026-05-16 | | [0007](0007-matrix-telemetry-bot.md) | matrix-telemetry-bot | event-driven | data_factory, dag_engine, call_monitor, agents_and_robots | pending | low | 0% | 2026-05-16 |
| [0008](0008-kanban-cpp-and-agent-workflows.md) | kanban-cpp-and-agent-workflows | realtime-loop | kanban_cpp, kanban, skill_tree, agent_runner_api | pending | medium | 0% | 2026-05-18 |
## Leyenda ## Leyenda
@@ -0,0 +1,113 @@
---
id: "0105"
title: "Estandarizar bloque service: en app.md + indexer + fn doctor services-spec"
status: in-progress
type: feature
domain:
- meta
- apps-infra
- deploy
- telemetry
scope: multi-app
priority: alta
depends: []
blocks:
- "0106"
related:
- "0085"
- "0086"
created: 2026-05-17
updated: 2026-05-17
tags: [services, monitoring, frontmatter, indexer, fn-doctor, pc-locations]
---
# 0105 — Estandarizar `service:` en app.md
## Problema
Diagnostico (2026-05-17): `sqlite_api` cayo 20h sin alerta. Causa: nadie monitoriza. Causa-de-causa: no hay forma uniforme de saber "esta app DEBE estar corriendo en este PC con este puerto y este health endpoint".
Hoy:
- 10 apps con `tag: service` en `registry.db`.
- 8/10 con `systemctl active=inactive` segun `fn doctor services` (algunas porque viven solo en remoto, otras porque genuinamente murieron).
- `port` se descubre por `--port` en `ExecStart` de un unit file que puede o no existir local.
- `health_endpoint` solo declarado en `deploy_server/operations.db` para 1 target (registry_api).
- `systemd_unit` se asume = `<name>.service`, no documentado.
- `pc_targets` (en que PCs DEBE correr) no existe en ninguna parte.
Consecuencia: imposible escribir un monitor que reconcilie "esperado vs real" sin hardcodear cada app.
## Decision
Anadir bloque `service:` opcional al frontmatter de `app.md`. Obligatorio para apps con `tag: service`. Indexer parsea y persiste. `fn doctor services-spec` audita.
## Schema del bloque
```yaml
service:
# Endpoints HTTP (opcional — apps stdio/daemon dejan null o omiten)
port: 8484
health_endpoint: /api/health # ruta GET, 200 == sano
health_timeout_s: 3
# Identidad systemd (cuando aplica)
systemd_unit: sqlite_api.service # nombre exacto
systemd_scope: user # user|system|null (docker-compose)
restart_policy: always # always|on-failure|none
# Estrategia de runtime (extiende systemd_scope para casos no-systemd)
runtime: systemd-user # systemd-user|systemd-system|docker-compose|stdio|manual
# Donde DEBE correr — referencia pc_locations.pc_id
pc_targets:
- aurgi-pc
- home-wsl
# Banderas
is_local_only: false # true => no se monitoriza por SSH; siempre via mecanismo local
```
Reglas:
- `port` null si la app no expone HTTP (stdio MCP, daemons sin API).
- `health_endpoint` null si no hay http; monitor cae a check de proceso (systemd active + port listening).
- `pc_targets` LISTA de `pc_id` de `pc_locations`. Vacia => no se monitoriza.
- `runtime: docker-compose` => monitor chequea contenedores via `docker compose ps` por SSH al PC target.
- `is_local_only: true` => monitor solo se ejecuta en el PC donde corre el daemon (no se intenta SSH al propio host).
## Tareas
- [x] Auditar 10 services existentes (port real, unit name, descripcion)
- [ ] Editar 10 app.md con bloque `service:` realista
- [ ] Migration: anadir columnas a tabla `apps` (`port INTEGER`, `health_endpoint TEXT`, `health_timeout_s INTEGER`, `systemd_unit TEXT`, `systemd_scope TEXT`, `restart_policy TEXT`, `runtime TEXT`, `is_local_only INTEGER`)
- [ ] Migration: nueva tabla `service_targets (app_id TEXT, pc_id TEXT, role TEXT DEFAULT 'primary', PRIMARY KEY(app_id, pc_id))`
- [ ] Indexer: parsear bloque `service:` desde frontmatter y rellenar columnas + `service_targets`
- [ ] `fn doctor services-spec` (Go func + subcommand): lista apps con `tag: service` y bloque incompleto. Salida tabwriter + `--json`
- [ ] Test: `fn index` sobre fixture con bloque service produce filas correctas
- [ ] Fix retroactivo: `~/.config/systemd/user/sqlite_api.service` con `Restart=always` (no `on-failure` — TERM no es failure)
## Materia: 10 apps actuales
| App | dir | port | health | unit | scope | pc_targets | runtime |
|---|---|---|---|---|---|---|---|
| sqlite_api | projects/fn_monitoring/apps/sqlite_api | 8484 | /api/status | sqlite_api.service | user | aurgi-pc, home-wsl | systemd-user |
| dag_engine | apps/dag_engine | 8090 | /api/dags | dag_engine.service | user | aurgi-pc, home-wsl | systemd-user |
| call_monitor | projects/fn_monitoring/apps/call_monitor | null | null | call_monitor.service | user | aurgi-pc, home-wsl | systemd-user |
| kanban | apps/kanban | 8095 | /api/board | kanban.service | user | aurgi-pc | systemd-user |
| deploy_server | apps/deploy_server | 9090 | /api/health | deploy_server.service | user | aurgi-pc | systemd-user |
| registry_mcp | apps/registry_mcp | null | null | registry_mcp.service | user | aurgi-pc | stdio (manual) |
| registry_api | apps/registry_api | 8420 | /api/status | null | null | organic-machine.com | docker-compose |
| footprint_geo_stack | apps/footprint_geo_stack | 3000 | null | null | null | aurgi-pc | docker-compose |
| element_matrix_chat | projects/element_agents/apps/element_matrix_chat | null | null | null | null | organic-machine.com | docker-compose |
| agents_and_robots | projects/element_agents/apps/agents_and_robots | null | null | agents_and_robots.service | system | organic-machine.com | systemd-remote |
## DoD
- 10 app.md con bloque `service:` valido (parseable, valores reales).
- `fn index` puebla `apps.port/...` y `service_targets`.
- `fn doctor services-spec` reporta `OK` para los 10.
- Migration aplica idempotente en `registry.db` de aurgi-pc + home-wsl.
- `services_status_go_infra` extendida para leer datos del nuevo schema (no hardcoded port discovery).
## Bloquea
- 0106: app `services_monitor` (UI + backend `services_api`). Necesita `service_targets` + `apps.port`/`health_endpoint` poblados.
+92
View File
@@ -0,0 +1,92 @@
---
id: "0106"
title: "App services_monitor: dashboard cross-PC de services activos"
status: pendiente
type: app
domain:
- apps-infra
- cpp-stack
- telemetry
- deploy
scope: multi-app
priority: alta
depends:
- "0105"
blocks: []
related:
- "0085"
created: 2026-05-17
updated: 2026-05-17
tags: [services, monitoring, cross-pc, ssh, systemd, healthcheck, dashboard]
---
# 0106 — App `services_monitor`
## Problema
`fn doctor services` da snapshot puntual del PC local. Falta vista en vivo cross-PC:
- ¿Cuales de mis 10 services estan vivos en aurgi-pc?
- ¿Cuales en organic-machine.com?
- ¿Cuales murieron sin que me entere (caso sqlite_api 2026-05-17)?
## Decision
App ImGui `services_monitor` consumiendo backend Go `services_api` (port 8485). Reconcilia esperado (`service_targets` + `apps.*` del registry) vs real (systemd state + port listening + HTTP health) en cada PC target. Persistencia historica = transiciones + agregado horario.
## Componentes
### Backend `apps/services_api/` (Go, tag: service, port 8485)
Endpoints:
- `GET /api/services` lista plana `(app_id, pc_id, expected, actual, port, last_check_ts, last_healthy_ts, transitions_24h)`
- `GET /api/services/:app/:pc` detalle + ultimas N transiciones + journalctl tail
- `POST /api/services/:app/:pc/check` fuerza check inmediato
- `POST /api/services/:app/:pc/action` (action=start|stop|restart) feature-flag OFF en v1
- `GET /api/pcs` estado por PC (reachable, lag_ms, version_uname)
- `GET /api/ws/services` WS push de delta cada check
Worker pool: ciclo 10s por PC, paralelo.
Checker local (is_local_only=true o PC = self): exec `systemctl --user is-active <unit>` + `ss -tln | grep :<port>` + `curl -m <timeout> <health_endpoint>`.
Checker remoto: `ssh_exec_go_infra` con los mismos comandos + parseo de output.
BD: `services_api.db`:
- `service_check` append-only (ts, app_id, pc_id, systemd_state, port_listening, http_status, latency_ms)
- `service_transition` (ts, app_id, pc_id, from, to)
- `service_state_hourly` (hour_bucket, app_id, pc_id, healthy_ratio, transitions)
### Frontend `apps/services_monitor/` (C++ ImGui)
Patron `data_factory`. Paneles:
1. **Overview** Grid `pcs x apps`. Celda = semaforo. Click => Detail.
2. **PC Detail** apps esperadas en el PC, drift expected vs actual, accion restart (disabled v1).
3. **App Detail** por app: estado en cada PC, transitions ultimas 7d, mini chart healthy_ratio horario.
4. **Live (WS)** stream transitions.
5. **Alerts** apps expected=running AND actual=inactive > 5min. (v1 solo lista; notifs separadas).
UI: `data_table_cpp_viz`, `badge_cpp_core`, `empty_state_cpp_core`.
## Decisiones cerradas (2026-05-17)
1. **Local especial**: PC local NO se chequea via SSH. Flag `pc_is_self` por PC. Checker selecciona path: local exec vs ssh exec.
2. **Persistencia**: transitions + hourly aggregate. Append-only `service_check` con TTL 7d (vacuum job nocturno).
3. **Auto-start**: NO en v1. Solo alerta. Feature flag `services_monitor.auto_fix` OFF.
## Tareas (orden)
- [ ] Migration `services_api.db`: tabla `service_check`, `service_transition`, `service_state_hourly`
- [ ] Funciones registry: `port_listening_check_go_infra`, `http_health_probe_go_infra` (si no existen) via fn-constructor paralelo
- [ ] `services_api` MVP: worker loop + `/api/services` + WS
- [ ] systemd unit + Restart=always + actualizar issue 0105 con 11mo service
- [ ] App C++ `services_monitor` scaffold via `fn run init_cpp_app services_monitor`
- [ ] Panel Overview + WS client
- [ ] PC Detail + App Detail
- [ ] Alerts panel
## DoD
- 10 services visibles en Overview con semaforo correcto contra ground truth.
- Caida simulada (kill -9 sqlite_api) detectada en <15s.
- Recovery (auto-restart via Restart=always) detectada y reflejada en transitions.
- App lanzable en aurgi-pc + home-wsl (sin SSH a self).
- Backend `services_api` corriendo como `tag: service` (dogfooding completo).
@@ -0,0 +1,96 @@
---
id: "0107e"
title: "uses_modules con min_version + codegen fail-loud"
status: pendiente
type: feature
domain:
- meta
- cpp-stack
- tooling
scope: registry
priority: media
depends:
- "0107a"
blocks: []
related:
- "0107"
created: 2026-05-17
updated: 2026-05-17
tags: [modules, versioning, codegen, fail-loud]
---
# 0107e — Version pinning + codegen fail-loud
Parte del issue principal [0107](0107-modules-standardization.md).
## Objetivo
Permitir que `app.md` declare version minima del modulo y que el codegen falle ruidosamente si:
1. Modulo declarado en `uses_modules` tiene `version < min_version`.
2. Python3 no disponible y `uses_modules` no vacio.
3. Codegen produce `count = 0` cuando `uses_modules` declara N>0 modulos.
## Schema extendido `app.md::uses_modules`
```yaml
# Forma corta (back-compat, sin pinning):
uses_modules: [data_table_cpp]
# Forma larga (con pinning):
uses_modules:
- name: data_table_cpp
min_version: "1.4" # SemVer minor — acepta 1.4.x, 1.5.x, ..., NO 2.0.0
- name: chat_ia_cpp
min_version: "0.1"
# Mixed permitido:
uses_modules:
- data_table_cpp # sin pin
- name: framework_cpp # con pin
min_version: "1.1"
```
Reglas:
- `min_version` formato `MAJOR.MINOR` (no patch — patch siempre bug fix compatible).
- Comparacion: `module.md::version >= min_version` AND `module.md::version.major == min_version.major`. Esto previene saltos de major silenciosos.
## Tareas
- [ ] **5.1** `python/functions/infra/codegen_app_modules.py`: parser acepta string corto Y dict largo.
- [ ] **5.2** Codegen lee `module.md::version` y compara contra `min_version` declarado en `uses_modules`. Si falla → exit 3 con mensaje claro.
- [ ] **5.3** `cpp/CMakeLists.txt::add_imgui_app`: si codegen exit != 0 AND != 2 → `message(FATAL_ERROR ...)` (no WARNING como hoy).
- [ ] **5.4** Si Python3 NO encontrado AND `app.md` tiene `uses_modules` no vacio → `message(FATAL_ERROR "Python3 required to parse uses_modules; install python3.")`.
- [ ] **5.5** Si codegen devuelve count=0 cuando `uses_modules` declara N>0 → `message(FATAL_ERROR ...)` con apunta a la app.md.
- [ ] **5.6** Indexer `registry/parser.go`: parsea ambas formas y persiste en `apps.uses_modules_json` con shape canonico `[{name, min_version}]` (min_version null si no declarado).
- [ ] **5.7** Test: app sintetica con dict largo + modulo sintetico con version 1.3 y min_version 1.4 → cmake configure falla con mensaje esperado.
- [ ] **5.8** Migrar 7 apps consumidoras de data_table a usar dict largo con `min_version: "1.4"` (o lo que sea v actual cuando 0107e cierre).
## Mensajes de error esperados
```
CMake Error at cpp/CMakeLists.txt:289 (message):
codegen_app_modules failed for services_monitor: module 'data_table_cpp'
version 1.3.0 does not satisfy min_version 1.4 declared in
apps/services_monitor/app.md::uses_modules.
Either bump module version (via /version) or relax pin in app.md.
```
```
CMake Error at cpp/CMakeLists.txt:289 (message):
codegen_app_modules: app 'dag_engine_ui' declares uses_modules: [data_table_cpp]
but generated module manifest has count=0.
Likely cause: Python parser failed silently. Re-run with verbose:
python3 python/functions/infra/codegen_app_modules.py \
--app-md apps/dag_engine_ui/app.md \
--modules-root modules/ \
--app-name dag_engine_ui \
--out /tmp/test.cpp
```
## Riesgos
- **Apps existentes sin pinning rompen?**: NO. La forma corta sigue valida y trata `min_version` como null = sin chequeo. Solo apps que opten explicitamente reciben gate.
- **Build se vuelve ruidoso si modulo no se actualizo**: deliberado. Es el sentido del fail-loud.
+160
View File
@@ -0,0 +1,160 @@
---
id: "0109"
title: "App skill_tree: mapa interactivo de issues+flows en anillos concentricos por estado (roadmap)"
status: in-progress
type: epic
domain:
- meta
- cpp-stack
scope: cross-stack
priority: media
depends: []
blocks: []
related:
- "0069"
- "0085"
- "0086"
- "0087"
- "0100"
created: 2026-05-17
updated: 2026-05-17
tags:
- skill-tree
- roadmap
- meta
- cpp
- imgui
- gamification
---
# 0109 — skill_tree app (roadmap)
## Vision
App C++ ImGui que muestra los **79 issues + 7 flows** del registry como un mapa de capacidades en **anillos concentricos por estado** (centro = `completado`, exterior = `locked`/`deferred`), con **sectores radiales por dominio**. Click en nodo → panel `Inspector` con DoD + funciones del registry asociadas + 2 botones:
- **Generate ideas (`claude -p`)** → escribe a tabla `idea_drafts` para revision manual.
- **Run autonomous-task (`fn-orquestador`)** → spawn subagente en sandbox `auto/<issue>` con tail de logs.
Centro NO es nodo — es **HUD overlay** con LV, XP, conteos por dominio. Animacion lerp 1s cuando un nodo migra de anillo (cambio de status). Sin fisicas — layout estatico y determinista.
## Por que
- Discovery visual: lo que hoy es `ls dev/issues/` + lectura individual de 79 .md, se reduce a panoramica de 5s.
- Dispatcher unificado: lanzar `claude -p` o `/autonomous-task` desde 1 click contextual (hoy: copiar ID + tipear comando).
- Gamificacion derivada (XP/nivel) sin inflar nada artificial — todo deriva de trabajo real (status frontmatter + telemetria).
## No-goals (fuera de scope)
- NO editor de issues. Solo lectura + dispatch. Editar issue = boton "Open in editor" → `code <path>`.
- NO orquestador propio. Reusa `fn-orquestador` + `/autonomous-task` (issue 0069).
- NO base de datos paralela de issues. `registry.db` y `dev/issues/*.md` siguen siendo fuente unica.
## Modelo
### Tipos de nodo
| Tipo | Origen | Ring | Render |
|---|---|---|---|
| Issue (epic) | `dev/issues/NNNN-*.md` (`type=epic`) | segun status | nodo grande, color accent del dominio |
| Issue (feature/bugfix/refactor/...) | mismo, otros types | segun status | nodo medio, tono mas claro |
| Flow | `dev/flows/NNNN-*.md` | segun status (flow.status) | nodo distinto shape (rombo) |
### Aristas
- `issue.depends` / `issue.blocks` → DAG (linea solida).
- `flow.related_issues` → linea punteada flow → issue.
- On-hover: aristas al cinturon perimetral de funciones del registry (`uses_functions` cruzado con tags del issue).
### Estado lock/unlock (DERIVADO — nunca manual)
| Status visual | Regla |
|---|---|
| `done` | `status=completado` (issues) o `status=completed` (flows) |
| `in-progress` | `status=in-progress` |
| `unlocked` | `status=pendiente` Y todos `depends[]` resueltos |
| `locked` | `status=pendiente` Y algun `depends[]` no resuelto |
| `bloqueado` | `status=bloqueado` |
| `deferred` | `status=deferred` |
Flows sin `depends` usan `related_issues` — flow unlocked si todos los related estan done.
### XP / nivel
- `xp_value` por tipo: `epic=10, feature=3, bugfix=1, refactor=2, chore=1, docs=1, flow=5`.
- `xp_total = SUM(xp_value WHERE status in {completado, completed})`.
- `level = floor(sqrt(xp_total))`.
- HUD muestra: `LV X · N done · M open · K in-progress · domains mastered: ...`.
## Layout: anillos concentricos
| Ring | Radio (px) | Que contiene | Notas |
|---|---|---|---|
| 0 | 0 - 150 | `completado` | Si > 30 nodos, mostrar top-N por recencia + bucket "+N mas" |
| 1 | 150 - 280 | `in-progress` | Pulse animation suave |
| 2 | 280 - 450 | `unlocked` pendiente | Color pleno, clickable |
| 3 | 450 - 650 | `locked` (depends sin cumplir) | Gris, aristas hacia bloqueantes |
| 4 | 650+ | `deferred` + `bloqueado` | Semi-transparente |
Cada ring subdividido en **18 sectores radiales = 1 dominio** (allowlist de `dev/TAXONOMY.md`). Aristas curvas hacia el centro siguiendo el sector. Cuando un issue cambia status → lerp 1s entre posicion vieja y nueva.
## Stack tecnico
- **App C++ ImGui** via `fn::run_app` (scaffolder `init_cpp_app_bash_pipelines`).
- **Viz**: `graph_renderer_cpp_viz` (solo draw — sin force layout) + `graph_viewport_cpp_viz` + `graph_labels_cpp_viz` + `graph_spatial_hash_cpp_core` (picking O(1)).
- **Layout**: nueva fn `compute_ring_layout_cpp_core` (pure) — input `[{node_id, status, domain, recency}]`, output `[{node_id, x, y, ring, sector}]`.
- **Parser**: nueva fn `parse_md_frontmatter_cpp_core` (pure) — extrae bloque `---...---` + parse YAML simple (subset: key:value, key:list).
- **BD propia**: `apps/skill_tree/local_files/skill_tree.db` con tablas:
- `node_state_cache` (denormalizacion para render rapido)
- `agent_jobs` (claude -p + fn-orquestador spawns)
- `xp_events` (append-only para timeline)
- `idea_drafts` (drafts de `claude -p` antes de promover a proposal)
- **Registry.db**: read-only (`?mode=ro`), solo para enriquecer Inspector con info de `functions/types`.
## Sub-issues (rompimiento)
| ID | Titulo | DoD resumido |
|---|---|---|
| 0109a | App shell + parsers | Scaffolder corre. App abre. Lee 79 issues + 7 flows. Log conteos en stdout. e2e_checks build OK. |
| 0109b | Layout anillos + render estatico | Nodos pintados en su ring+sector. Aristas depends/related visibles. Sin interaccion clic. |
| 0109c | Panel Inspector + estado derivado | Click nodo → panel derecho con DoD + uses_functions. Lock/unlock derivado de depends. Reload manual F5. |
| 0109d | HUD XP + animacion migracion | Overlay HUD arriba-izq. Lerp 1s en cambio de status. xp_events append-only. |
| 0109e | Boton Ideas (claude -p → drafts) | Spawn `claude -p` con prompt contextual. Persiste en `idea_drafts`. UI aprobar/rechazar → proposals. |
| 0109f | Boton Auto-run (fn-orquestador) | Spawn `Agent(fn-orquestador)` background. Panel inferior con tail logs. Barra progreso vive (`task_runs.checks_pass/total`). |
## Riesgos / mitigaciones
| Riesgo | Mitigacion |
|---|---|
| Ring 0 con ~72 done satura visualmente | Top-N por recencia + bucket "+N mas..." expandible |
| 18 sectores * 158 nodos = legibilidad pobre | Zoom semantico: zoom-out muestra solo epics + flows |
| `claude -p` puede consumir tokens en bucle | Rate-limit UI: 1 invocacion por nodo por hora (`idea_drafts.last_request_at`) |
| Drift status del .md vs cache | F5 manual fuerza re-scan. Sin file watcher en fase A. |
| Parser YAML C++ frágil con campos exoticos | Test golden sobre los 79 issues actuales antes de mergear 0109a |
## DoD del epic
- [ ] App `skill_tree` indexada en `registry.db` con `framework=imgui`, trio icon completo, `e2e_checks` declarados, `uses_functions` no vacio.
- [ ] Compila en Linux + Windows. Desplegada via `redeploy_cpp_app_windows`.
- [ ] Tarjeta visible en `app_hub_launcher`.
- [ ] Los 6 sub-issues (a-f) mergeados a master de su sub-repo Gitea.
- [ ] Pipeline `fn doctor cpp-apps` limpio para `skill_tree`.
- [ ] `fn doctor uses-functions` sin drift para `skill_tree`.
## Funciones nuevas previstas (delegar a fn-constructor)
1. `parse_md_frontmatter_cpp_core` (pure) — parser frontmatter YAML simple en C++.
2. `compute_ring_layout_cpp_core` (pure) — posiciones determinsticas por anillo+sector.
3. `spawn_claude_p_bash_infra` o `_go_infra` — lanza `claude -p "<prompt>"` con timeout + captura stdout JSON. Reusable.
## Decisiones
| Tema | Decision | Razon |
|---|---|---|
| Centro del mapa | HUD overlay, NO nodo | Centro = "estado actual del usuario" — no es un item, es un agregado. |
| Layout | Estatico anillos+sectores | Sin fisicas. Usuario dijo "no quiero que se muevan, nos volveran locos". |
| Animacion | Lerp 1s entre rings | Da sensacion de progreso sin caos visual. |
| Boton Ideas dest | `idea_drafts` (revision manual) | Evita ruido en `proposals`. |
| Refresh | F5 manual | Simplicidad MVP. File watcher en fase posterior. |
| Service tag | NO | App interactiva, no daemon. |
| Ubicacion | `apps/skill_tree/` | Meta-tool del registry entero, no de proyecto. |
@@ -0,0 +1,78 @@
---
id: "0109g"
title: "skill_tree: panel terminal embebida (claude TUI dentro de la app)"
status: pendiente
type: feature
domain:
- meta
- cpp-stack
scope: app-scoped
priority: baja
depends:
- "0109b"
blocks: []
related:
- "0109"
created: 2026-05-17
updated: 2026-05-17
tags:
- skill-tree
- cpp
- imgui
- terminal
- pty
---
# 0109g — Terminal embebida en skill_tree
Hoy el boton `Claude fix` lanza terminal externa (Windows Terminal). Funciona pero saca al usuario fuera de la app. Issue: integrar terminal completa dentro de skill_tree como panel.
## Opciones a evaluar
### Opcion 1: lib externa ImGui terminal
- **ImTerm** (github.com/Optiroc/ImTerm): minimalista, sin PTY. NO sirve para TUI.
- **imgui-console**: similar, comandos hardcoded. NO sirve.
- **imgui-vt100** o forks: emuladores ANSI dentro de ImGui. Algunos con PTY. Investigar:
- github.com/Magenta-Inc/MagentaImGuiTerminal
- github.com/microsoft/terminal — ConPTY API + custom UI
- github.com/jbrd/imterm — soporta escape sequences basicas
- Riesgo: claude usa **alt screen + raw mode + bracketed paste + colores 24-bit + cursor moves**. La mayoria de libs no cubren todo.
### Opcion 2: PTY + parser ANSI propio
- Linux: `openpty()` + fork + exec claude. Buffer texto. Parser ANSI minimo (CSI, SGR, cursor).
- Windows: ConPTY (`CreatePseudoConsole`). Similar.
- Render ImGui con `AddText` + colores por celda. Soportar redimension via `TIOCSWINSZ`.
- Mucho trabajo (~1500 LOC). Resultado limitado (no soporta TUIs complejas tipo less, vim).
### Opcion 3: Pipe simple (no TUI)
- spawn `claude --print "..."` con un prompt y leer stdout en panel scrollable.
- NO interactivo. Solo one-shot.
- Util para `Generate ideas` (0109h), NO para `Claude fix` interactivo.
## Recomendacion
- **Corto plazo**: dejar terminal externa (esta hecha en 0109b3).
- **Medio plazo**: hacer Opcion 3 (pipe one-shot) para 0109h.
- **Largo plazo**: Opcion 2 (ConPTY/openpty propio) solo si el usuario lo pide explicitamente. Es trabajo de semana y limitara la app a un emulador de calidad mediocre.
## Investigacion previa (sub-issue 0109g1)
Antes de implementar, abrir sub-issue 0109g1 que evalua:
- ¿Hay alguna lib **imgui-pty** madura en 2026?
- ¿La calidad de `imgui-vt100` cubre claude TUI?
- ¿Cuanto cuesta ConPTY/openpty propio para soportar al menos: scrollback, colores 24-bit, cursor moves, alt screen, bracketed paste?
Si la respuesta a (1) o (2) es "si", saltar a implementar. Si la respuesta es "no", el esfuerzo de (3) probablemente no compensa vs la terminal externa.
## DoD (cuando se implemente)
- [ ] Panel "Terminal" toggable desde menu View (Ctrl+3).
- [ ] Spawn `claude --dangerously-skip-permissions` en cwd `~/fn_registry`.
- [ ] Input box manda chars al PTY.
- [ ] Output renderizado con colores ANSI minimo.
- [ ] Cierre limpio del proceso al cerrar el panel o salir de la app.
- [ ] Funciona en Windows nativo (no requiere WSL terminal externa).
## Anti-DoD
- NO soporta TUIs muy complejas (less con keyboard, vim) hasta que se justifique con un user need real.
@@ -0,0 +1,124 @@
---
id: "0109h"
title: "skill_tree: generar ideas con LLM y promover a issue/flow desde la interfaz"
status: in-progress
type: feature
domain:
- meta
- cpp-stack
scope: app-scoped
priority: alta
depends:
- "0109b"
blocks: []
related:
- "0109"
- "0085"
- "0086"
created: 2026-05-17
updated: 2026-05-17
tags:
- skill-tree
- cpp
- imgui
- llm
- claude-cli
- ideas
- ghost-nodes
---
# 0109h — Generate ideas + promote to issue/flow
## UX que el usuario quiere
Click en cualquier nodo del Tree → Inspector aparece con boton `[ ⨁ Generate ideas ]`. Click ese boton →
1. Skill_tree lanza `claude -p "<prompt contextual>"` en background.
2. LLM devuelve N (3-5) ideas JSON con `{title, description, type: issue|flow, domain}`.
3. Cada idea aparece como un **ghost-node** (nodo semi-transparente con outline animado) que **emerge del nodo source** y se anima hacia su target ring/sector segun su domain.
4. Click sobre el ghost → Inspector pivota al draft. Muestra title + description + buttons:
- `[ + Generate issue ]` (technical — escribe `dev/issues/NNNN-<slug>.md`)
- `[ + Generate flow ]` (use-case — escribe `dev/flows/NNNN-<slug>.md`)
- `[ × Discard ]` (elimina del buffer)
5. Si el usuario promueve → archivo creado, draft eliminado, F5 (auto-trigger) recarga y el nodo aparece como nodo real con ID nuevo.
## Modelo
### Ghost-node
```cpp
struct DraftNode {
std::string id; // tmp_<uuid>
std::string source_id; // ID del nodo que la genero
std::string title;
std::string description;
std::string proposed_type; // "issue" or "flow"
std::string proposed_domain;
std::string proposed_priority;
std::vector<std::string> proposed_depends;
std::vector<std::string> proposed_dod_items;
float x = 0, y = 0; // posicion actual (lerp)
float target_x = 0, target_y = 0;
double spawn_t = 0; // animacion 1s emerge
};
```
Buffer global `g_drafts` (en memoria, NO persistido — drafts viven hasta promote o discard, sin persistencia entre runs).
### Spawn LLM
`spawn_claude_p_for_ideas(node)`:
- Construye prompt:
```
Eres un asistente que propone sub-tareas para el sistema fn_registry.
Contexto del nodo origen:
ID: 0109b
Title: skill_tree layout anillos
Status: completado
Domain: meta, cpp-stack
Type: feature
Body:
<pegado del .md sin frontmatter>
Proponme 3-5 ideas de sub-tareas (issues tecnicas o flows de uso) que extiendan o validen lo logrado por este nodo. Para cada idea devuelve JSON:
{ "title": "...", "description": "...", "type": "issue|flow", "domain": "<uno de meta/cpp-stack/...>", "priority": "alta|media|baja", "depends": [], "dod": ["..."] }
Devuelve SOLO un array JSON, sin texto extra.
```
- `claude --print "<prompt>"` con timeout 60s. stdout capturado en pipe.
- Parse JSON array → cada elemento se convierte en `DraftNode`.
### Promote
`promote_draft_to_issue(draft, output_dir)`:
- Encuentra siguiente NNNN libre escaneando `dev/issues/`.
- Renderiza frontmatter YAML desde template + body.
- Escribe `dev/issues/NNNN-<slug>.md`.
- Quita `draft` de `g_drafts`.
- Trigger `reload_scan()`.
Similar para flows en `dev/flows/`.
## Sub-issues
- **0109h1** — Framework ghost-nodes (sin LLM, mock con datos hardcoded): Render + animacion + Inspector + promote.
- **0109h2** — Integracion claude -p real: spawn async, parse JSON, fill drafts.
- **0109h3** — Loading indicator + cancel.
- **0109h4** — Rate-limit: 1 invocacion por nodo por hora (`last_request_t` en draft buffer).
## Riesgos
- **claude -p timeout**: respuestas >60s posibles. Mitigacion: async + ImGui spinner.
- **JSON malformado**: el modelo a veces devuelve markdown alrededor del JSON. Mitigacion: extraer entre primeras `[` y ultimas `]`, retry parsing.
- **Costo**: cada invocacion consume tokens. Mostrar contador de invocaciones en sesion (`g_llm_calls_count`).
- **Promote a archivo escribe en `dev/issues/` que NO es sub-repo**: ese write toca fn_registry main repo. OK porque dev/issues/ es parte del registry. Pero requiere que skill_tree.exe tenga permiso de escritura — en Windows con paths WSL `\\wsl$\Ubuntu\home\lucas\fn_registry\dev\issues\` debe funcionar.
## DoD
- [ ] Boton Generate ideas en Inspector lanza spawn LLM.
- [ ] Drafts aparecen como ghost-nodes en el canvas con animacion emerge.
- [ ] Click ghost → Inspector pivota a draft.
- [ ] Buttons Generate issue / Generate flow escriben .md a disco.
- [ ] Reload F5 (o auto-trigger tras escribir) muestra el nuevo nodo en el canvas.
- [ ] Discard elimina draft del buffer.
- [ ] Rate-limit suave (1 invocacion / nodo / hora).
@@ -0,0 +1,68 @@
---
id: "0109k"
title: "skill_tree: panel Dashboard con stats por dominio + XP + level"
status: in-progress
type: feature
domain:
- meta
- cpp-stack
scope: app-scoped
priority: media
depends:
- "0109b"
blocks: []
related:
- "0109"
created: 2026-05-17
updated: 2026-05-17
tags:
- skill-tree
- cpp
- imgui
- dashboard
- gamification
---
# 0109k — Dashboard panel
Tercer panel de skill_tree (Tree + Inspector + **Dashboard**). Vista cuantitativa del arbol de habilidades, complementaria al canvas visual.
## Contenido
- **HUD top**: LV global + XP total + total nodes done/planned/todo.
- **Tabla por dominio** (18 filas, una por dominio canonico):
- Domain
- Done / Planned / Todo / Total
- % completado (barra de progreso)
- XP acumulado en ese dominio
- Level por dominio (sqrt(xp_domain))
- **Top dominios masterizados** (top 3 por % completado).
- **Dominios mas lock-loaded** (los que tienen mas locked vs unlocked — proximos en desbloquearse).
- **Distribucion XP por type** (epic vs feature vs bugfix...): mini-barras.
## XP scheme
Por type del issue (al completarse):
- `epic` → 10 XP
- `feature` → 3 XP
- `infra` → 4 XP
- `refactor` → 2 XP
- `bugfix` → 1 XP
- `chore` → 1 XP
- `docs` → 1 XP
- `spike` → 2 XP
- `planning` → 2 XP
Flows completados → 5 XP cada uno.
`xp_total = sum(xp_per_done_node)`. `level = floor(sqrt(xp_total))`.
Per-domain: igual pero filtrado por domain match (un nodo cuenta en cada uno de sus domain tags).
## DoD
- [x] Tercer panel toggable desde menu View (Ctrl+3).
- [x] HUD con LV global + XP + counts.
- [x] Tabla por dominio con barras de progreso.
- [ ] Distribucion XP por type (mini-bars).
- [ ] Top mastered / next-to-unlock.
- [ ] Refresh sincronizado con F5 del Tree.
+169
View File
@@ -0,0 +1,169 @@
---
id: "0109m"
title: "issues_api service: HTTP backend para issues+flows (skill_tree, kanban, dashboards)"
status: pendiente
type: feature
domain:
- meta
- apps-infra
scope: app-scoped
priority: media
depends: []
blocks:
- "0109h2"
related:
- "0109"
- "0106"
created: 2026-05-18
updated: 2026-05-18
tags:
- service
- go
- http
- issues
- flows
- api
---
# 0109m — issues_api service
## Por que
`skill_tree.exe` corre en Windows nativo. Necesita leer `dev/issues/*.md` + `dev/flows/*.md` que viven en WSL `~/fn_registry/`. Opciones:
| Solucion | Pro | Contra |
|---|---|---|
| UNC `\\wsl.localhost\Ubuntu\home\lucas\fn_registry` | Cero infra extra | Hardcodea distro/user. No funciona si WSL caido. |
| Service HTTP (este issue) | Robusto, reusable por otras apps (kanban, web dashboard) | Mas piezas que mantener |
| Embeber YAML parser + nested fields | Self-contained | Mucha logica duplicada |
Fix UNC ya esta hecho (0109l). Pero el patron canonico del registry son services (services_api, sqlite_api, registry_api). Este issue formaliza `issues_api`.
## Spec
Service Go HTTP en puerto **8486**. Patron identico a `services_api` (issue 0106).
### Endpoints
- `GET /api/health``{"status":"ok"}`
- `GET /api/issues` → array JSON con TODOS los issues (open + completed)
- `GET /api/issues/:id` → un issue concreto con body markdown completo
- `GET /api/flows` → array JSON con todos los flows
- `GET /api/flows/:id` → un flow concreto
- `GET /api/stats` → counts por status/domain/type (agregados para Dashboard)
- `POST /api/issues` → crea nuevo issue (escribe `.md`). Body: `{id, title, type, domain, depends, body, dod}`. Devuelve `{path}`.
- `POST /api/flows` → crea nuevo flow. Mismo shape adaptado a frontmatter de flows.
### Schema JSON issue
```json
{
"id": "0109",
"title": "...",
"status": "pendiente",
"status_eff": "pendiente_unlocked",
"type": "epic",
"domain": ["meta", "cpp-stack"],
"priority": "media",
"depends": [],
"blocks": [],
"related": ["0085"],
"tags": ["skill-tree"],
"created": "2026-05-17",
"updated": "2026-05-18",
"file_path": "dev/issues/0109-skill-tree-app-roadmap.md",
"body_md": "(opcional, solo en /api/issues/:id)",
"dod": [{"text":"App existe","done":true}, ...]
}
```
### Implementacion
```go
// apps/issues_api/main.go
package main
import (
"encoding/json"
"net/http"
"log"
"os"
// reusa funciones del registry:
// - extract_frontmatter (Go port; existe extract_frontmatter_py_core, pero
// en C++ existe parse_md_frontmatter_cpp_core — necesitamos Go port o
// reusar yaml.v3 directo aqui).
)
func main() {
root := os.Getenv("FN_REGISTRY_ROOT")
if root == "" { log.Fatal("FN_REGISTRY_ROOT not set") }
mux := http.NewServeMux()
mux.HandleFunc("/api/health", health)
mux.HandleFunc("/api/issues", listIssues(root))
mux.HandleFunc("/api/issues/", showIssue(root))
mux.HandleFunc("/api/flows", listFlows(root))
mux.HandleFunc("/api/flows/", showFlow(root))
mux.HandleFunc("/api/stats", stats(root))
log.Fatal(http.ListenAndServe(":8486", mux))
}
```
Frontmatter: existe `extract_frontmatter_py_core` (Python). Hace falta:
- **Opcion A**: crear `parse_md_frontmatter_go_core` (port del C++). Reusable por otros services Go.
- **Opcion B**: usar `gopkg.in/yaml.v3` directo dentro de `issues_api`.
Recomiendo **A** — Go port reusable. Pattern: header `parse_md_frontmatter.go` en `functions/core/`.
### Frontmatter service: block en app.md
```yaml
service:
port: 8486
health_endpoint: /api/health
health_timeout_s: 3
systemd_unit: issues_api.service
systemd_scope: user
restart_policy: always
runtime: systemd-user
pc_targets:
- aurgi-pc
- home-wsl
is_local_only: false
```
### Skill_tree consume
Cambio en `skill_tree/main.cpp`:
- Si `discover_registry_root()` falla, intentar HTTP `http://localhost:8486/api/issues`.
- Si responde 200 → parsear JSON y poblar `g_scan.nodes`.
- Fallback a UNC + file scan si no.
Cliente HTTP en C++:
- Linux: `libcurl` (ya disponible) o `popen("curl ...")`.
- Windows: WinHTTP nativo o curl.exe (Windows 10+ trae curl).
- Mas simple: spawn `curl.exe -s http://...` y parsear stdout (nlohmann json — habria que vendor).
## Sub-issues
- **0109m1** — crear `parse_md_frontmatter_go_core` port del C++ (mismas semanticas, mismos tests).
- **0109m2** — scaffolder `issues_api` app + service block + systemd unit.
- **0109m3** — implementar endpoints GET (list, show, stats).
- **0109m4** — implementar POST issues + POST flows (escribir .md valido).
- **0109m5** — cliente HTTP en skill_tree + fallback chain (env > walk > UNC > HTTP).
## Beneficios cross-app
Una vez issues_api existe, reutilizan:
- `kanban` (issue 0058 sync) podria leer/sync issues.
- Frontend web dashboard ya tendria backend.
- CI/automation puede crear issues via POST en lugar de editar .md a mano.
- `services_monitor` mostrara `issues_api` como service mas (auto via `tag: service`).
## DoD
- [ ] `apps/issues_api/` scaffoldada via `/cpp-app` ?? no — Go service via `/app`.
- [ ] Endpoints listed devuelven JSON valido.
- [ ] systemd unit instalado (`issues_api.service`).
- [ ] Visible en `services_monitor` como service activo.
- [ ] skill_tree consume HTTP cuando esta disponible, cae a UNC/file si no.
- [ ] Tests Go basicos (list, show, post).
+134
View File
@@ -0,0 +1,134 @@
---
id: "0110"
title: "Gap registry: helper HTTP cliente C++ (curl/popen) reutilizable"
status: pendiente
type: feature
domain:
- cpp-stack
- registry-quality
scope: registry
priority: media
depends: []
blocks:
- "0111"
related:
- "0106"
created: 2026-05-18
updated: 2026-05-18
tags: [http, cpp, registry-gap, curl, helper]
---
# 0110 — Helper HTTP cliente C++ en el registry
## Problema
Hoy no existe funcion HTTP cliente reutilizable en `cpp/functions/`. Cada app C++ que necesita
golpear un endpoint reinventa la capa:
| App | Fichero | Tecnica | LOC aprox |
|---|---|---|---|
| `apps/services_monitor/` | `http_client.cpp` | cURL popen/WinHTTP segun plataforma | ~150 |
| `apps/dag_engine_ui/` | inline en `main.cpp` | curl CLI via popen + parse | ~80 |
| `apps/data_factory/` | inline | popen curl | ~60 |
| `cpp/functions/core/llm_anthropic.cpp` | propio | cURL popen — solo Anthropic | — |
| `apps/process_explorer/` (issue 0111) | `http_client.cpp` local | pendiente — clonara services_monitor | ~150 esperados |
El patron ya supera el umbral `>2x` que dispara la regla de promocion (CLAUDE.md
"Si patron se repite >2x → propose nueva funcion via fn-constructor"). Cada app duplica:
- Detection de plataforma (Linux: `popen("curl -s ...")`, Win: `WinHTTPOpen`/popen)
- Manejo de basicAuth / Bearer tokens
- Timeouts
- Captura de body + status code
- Manejo de errores transitorios (DNS, conexion rechazada)
## Decision
Anadir al registry dos funciones C++ en dominio `core` (o `infra`):
### `http_request_cpp_core` (impure)
```cpp
namespace fn_http {
struct Request {
std::string method; // "GET", "POST", "PUT", "DELETE"
std::string url;
std::vector<std::pair<std::string,std::string>> headers;
std::string body; // raw bytes (JSON, etc.)
int timeout_ms = 5000;
std::string bearer_token; // shortcut: anade Authorization: Bearer <token>
std::string basic_user; // shortcut: anade Authorization: Basic base64(user:pass)
std::string basic_pass;
};
struct Response {
int status = 0; // 0 = error de transporte
std::string body;
std::vector<std::pair<std::string,std::string>> headers;
std::string error; // vacio si OK
int64_t duration_ms = 0;
};
Response request(const Request& req);
}
```
Implementacion: cURL via popen (portable WSL+Win+Linux, igual que `llm_anthropic`).
Si en el futuro queremos rendimiento real, swap a libcurl linkado estaticamente
o WinHTTP via `#ifdef _WIN32` — interfaz Request/Response no cambia.
### `http_get_json_cpp_core` (impure, pure wrapper)
Helper que envuelve `http_request` + parse JSON (via `nlohmann::json` o similar
ya disponible en el repo) para los casos comunes:
```cpp
namespace fn_http {
// Devuelve parsed JSON o lanza si status != 2xx
nlohmann::json get_json(const std::string& url,
const std::string& bearer_token = "",
int timeout_ms = 5000);
}
```
## Plan de migracion
Tras crear las funciones, abrir issue separado por cada consumer para migrar:
1. `apps/services_monitor/http_client.cpp` -> usar `fn_http::request`
2. `apps/dag_engine_ui/main.cpp` (inline)
3. `apps/data_factory/` (inline)
4. `cpp/functions/core/llm_anthropic.cpp` — refactor para usar `fn_http::request` por
debajo (mantiene API publica)
5. `apps/process_explorer/` (issue 0111) — nace ya usando el helper
## Criterios de aceptacion
- [ ] `cpp/functions/core/http_request.{cpp,h,md}` registrado en `registry.db`
- [ ] `cpp/functions/core/http_get_json.{cpp,h,md}` idem
- [ ] Tests visuales o de integracion contra `httpbin.org` (200/404/timeout/auth)
- [ ] Frontmatter completo (`params`/`output`/`tags`/`example`)
- [ ] `.md` cumple contrato self-doc (`## Ejemplo`, `## Cuando usarla`, `## Gotchas`)
- [ ] Al menos 1 consumer migrado para validar API (recomendado `services_monitor`)
- [ ] `fn doctor uses-functions` limpio
## Gotchas conocidos
- cURL popen en Windows necesita `curl.exe` en PATH — todos los WSL/Win lo tienen,
pero documentar en `## Gotchas`.
- Bodies binarios: popen complica el escape; primera version solo string bodies.
- TLS verify: por defecto on; permitir `req.insecure = true` solo para testing.
- Timeouts: cURL `--max-time` cubre handshake+transfer; documentar diferencia con
read-timeout puro.
## Por que no usar libcurl linkado
- `popen("curl ...")` no requiere anadir libcurl al toolchain MinGW cross-compile
(que ya costo configurar). `llm_anthropic` lleva meses funcionando asi.
- Cuando aparezca un caso real de latencia (>10 req/s sostenido), abrimos issue
separado para swap a libcurl.
## Out of scope (no en este issue)
- WebSocket / SSE — cliente WS C++ es otro gap; abrir issue propio cuando aplique.
- Cliente gRPC.
- Streaming responses (SSE chunk-by-chunk) — usar caso de `dag_engine_ui` para
decidir cuando.
+112
View File
@@ -0,0 +1,112 @@
---
id: "0112"
title: "App kanban_cpp: clon C++ ImGui de kanban_web con backend Go propio"
status: pendiente
type: app
domain:
- cpp-stack
- kanban
- agents
scope: app
priority: alta
depends:
- "0110"
blocks:
- "0116"
related:
- "0008"
- "0113"
- "0117"
- "0118"
created: 2026-05-18
updated: 2026-05-18
tags: [kanban, cpp, imgui, agents, backend-copy]
flow: "0008"
---
# 0112 — App `kanban_cpp`
## Problema
`kanban_web` (React + Mantine) funciona bien para humanos. Falta un kanban dedicado a **conducir agentes LLM**: arrastrar card a `Doing (agent)` -> arranca workflow, sin abrir browser ni terminal.
Flow 0008 lo requiere como surface 1 user-facing.
## Decision
Nueva app `apps/kanban_cpp/` (C++ ImGui via `fn::run_app`). Backend Go **copia identica** de `apps/kanban/backend/` con su propia `operations.db`. Frontend ImGui consume HTTP + SSE/WS del backend local.
NO sync con kanban_web — datos independientes a proposito. Auth/users duplicados (cada app sus propios usuarios).
### Estructura
```
apps/kanban_cpp/
CMakeLists.txt # add_imgui_app + linkea http_request_cpp_core
app.md # trio: description + icon.phosphor=columns-3 + icon.accent=#a855f7
appicon.ico # generado via generate_app_icon_py_infra
main.cpp # fn::run_app + 6 panels
data.{h,cpp} # HTTP client wrapper (usa http_request_cpp_core)
panel_board.{h,cpp} # columnas + cards drag-and-drop
panel_calendar.{h,cpp} # vista calendar (port de CalendarView.tsx)
panel_dashboard.{h,cpp} # KPIs (port de Dashboard.tsx)
panel_agent_runs.{h,cpp} # lista runs (usa agent_runs_timeline_cpp_viz)
panel_worktrees.{h,cpp} # git worktree manager
panel_dod.{h,cpp} # DoD inspector (usa dod_evidence_panel_cpp_viz)
backend/ # COPIA de apps/kanban/backend
main.go # port distinto: 8401 (web) -> 8403 (cpp)
db.go, handlers.go, ... # idem
migrations/ # mismas migrations
operations.db # SU PROPIA DB
```
## Panels (6 + Board)
Mapping con `apps/kanban/frontend/src/components/`:
| Mantine component | Panel C++ | Funcion registry |
|---|---|---|
| `KanbanColumn` + `KanbanCard` | `panel_board` | inline (logica especifica) |
| `CalendarView.tsx` | `panel_calendar` | inline |
| `Dashboard.tsx` | `panel_dashboard` | `kpi_card_cpp_viz` + `sparkline_cpp_viz` |
| nuevo | `panel_agent_runs` | `agent_runs_timeline_cpp_viz` (0118) |
| nuevo | `panel_worktrees` | inline (calls `agent_runner_api`) |
| nuevo | `panel_dod` | `dod_evidence_panel_cpp_viz` (0117) |
## Criterios de aceptacion
- [ ] `apps/kanban_cpp/` creado via `./fn run init_cpp_app kanban_cpp` (scaffolder canonico).
- [ ] `backend/` copiado de `apps/kanban/backend` con port distinto (8403) y migrations propias.
- [ ] `app.md` con trio completo: description 1 linea + `icon.phosphor: columns-3` + `icon.accent: "#a855f7"`.
- [ ] `appicon.ico` generado via `./fn run generate_app_icon "columns-3" "#a855f7" apps/kanban_cpp/appicon.ico`.
- [ ] App registrada en `cpp/CMakeLists.txt` (bloque `add_subdirectory`).
- [ ] Build Windows cross-compile OK: `cmake --build cpp/build/windows --target kanban_cpp -j`.
- [ ] Deploy a `/mnt/c/Users/lucas/Desktop/apps/kanban_cpp/` via `./fn run redeploy_cpp_app_windows kanban_cpp`.
- [ ] `--self-test` arranca + verifica GL loader + SQLite local + conexion al backend `:8403`.
- [ ] Trio aparece en App Hub tras `./fn run refresh_app_hub`.
- [ ] Backend `:8403` arranca via systemd unit (tag `service`).
- [ ] `uses_functions` declarado en `app.md` cubre: `http_request_cpp_core`, `kpi_card_cpp_viz`, `sparkline_cpp_viz`, `data_table_cpp_viz`, `dod_evidence_panel_cpp_viz` (0117), `agent_runs_timeline_cpp_viz` (0118).
- [ ] `fn doctor uses-functions kanban_cpp` limpio.
- [ ] `e2e_checks` declarados en `app.md`: build, --self-test, backend health, smoke board panel.
## Gotchas
- 2 services + 2 sqlite locks: nunca compartir `operations.db` entre `kanban` y `kanban_cpp`. Ports + DB independientes.
- Auth: backend copia trae `users.go`. Decision: usuarios independientes por app. Documentar en `app.md`.
- Mantine es modal-heavy (`Modal`, `Drawer`). En ImGui usar `ImGui::OpenPopup` + `BeginPopupModal`. NO replicar look pixel-perfect — adaptar a fn_tokens.
- BD drift cross-PC: `operations.db` por PC, no se sincroniza. Si el usuario quiere portabilidad de cards -> issue separado.
## Out of scope
- Sync de cards entre `kanban_web` y `kanban_cpp` (otro issue, decision deferred).
- Multi-user concurrent edit en kanban_cpp (single-user por ahora).
- Tab `Chat` (`CardChatPanel.tsx`) — postergar a v2.
## Plan implementacion
1. `./fn run init_cpp_app kanban_cpp` (no flag --project: app suelta).
2. `cp -r apps/kanban/backend apps/kanban_cpp/backend` + sed port 8401 -> 8403 + clear `operations.db`.
3. Build cliente data.{h,cpp} con `fn_http::request`.
4. Implement `panel_board` primero (MVP). Resto en orden.
5. Trio + icon + refresh_app_hub.
6. e2e_checks + smoke + deploy Windows.
+113
View File
@@ -0,0 +1,113 @@
---
id: "0113"
title: "Service agent_runner_api: orquestador de workflows con worktrees + DoD"
status: pendiente
type: app
domain:
- agents
- workflows
- apps-infra
scope: app
priority: alta
depends: []
blocks:
- "0115"
- "0116"
- "0117"
- "0118"
related:
- "0008"
- "0069"
created: 2026-05-18
updated: 2026-05-18
tags: [agents, service, worktrees, dod, claude-headless]
flow: "0008"
---
# 0113 — Service `agent_runner_api`
## Problema
Hoy hay tres puntos donde se lanza Claude:
1. `apps/skill_tree/main.cpp::spawn_claude_terminal` — abre `wt.exe` con `claude --dangerously-skip-permissions`. Termina sin trazabilidad.
2. `parallel-fix-issues` skill — worktrees paralelos pero stateless.
3. `fn-orquestador` (issue 0069) — autonomous loop dentro de Claude Code.
Ninguno persiste runs, evidencias o DoD. No hay manera de saber que workflows estan vivos cross-app.
## Decision
Service Go nuevo `apps/agent_runner_api/` puerto `:8486`, tag `service`. Single source of truth de:
- workflows declarados (templates de prompt + DoD schema)
- runs activos (worktree + subprocess Claude + status)
- evidencias DoD (path/url/log/cmd output + validated_by)
Endpoints minimos:
- `POST /api/runs` — crea worktree + lanza claude headless. Body: `{issue_id|card_id, mode, kanban_app}`.
- `GET /api/runs` — lista runs (filtros status/app/since).
- `GET /api/runs/:id` — detalle run.
- `GET /api/runs/:id/sse` — stream progreso.
- `POST /api/runs/:id/evidence` — agente adjunta evidencia.
- `POST /api/runs/:id/evidence/:eid/validate` — humano aprueba.
- `POST /api/runs/:id/merge` — TBD merge (todos items validated).
- `POST /api/runs/:id/abort` — kill subprocess + worktree remove.
- `GET /api/health` — 200 OK.
## Schema `agent_runs.db`
Migrations en `apps/agent_runner_api/migrations/`:
- `001_workflows.sql` — templates: `id, name, prompt_template, dod_schema_json, created_at`.
- `002_runs.sql``id, workflow_id, issue_id, card_id, kanban_app, branch, worktree_path, status, started_at, finished_at, agent_pid, agent_log_path`.
- `003_worktrees.sql``id, run_id, path, branch, created_at, removed_at`.
- `004_dod_items.sql` — un row por item declarado: `id, run_id, item_key, kind, expected, required, status (pending|done|validated|failed)`.
- `005_dod_evidence.sql` — un row por evidencia adjunta: `id, dod_item_id, kind, payload_path, payload_url, payload_text, attached_at, validated_at, validated_by`.
Aplicadas via `embed.FS + applyMigrations()` al arrancar.
## Frontmatter `app.md` (service)
```yaml
tags: [service, agents, go]
service:
port: 8486
health_endpoint: /api/health
health_timeout_s: 3
systemd_unit: agent_runner_api.service
systemd_scope: user
restart_policy: always
runtime: systemd-user
pc_targets:
- aurgi-pc
- home-wsl
is_local_only: true
```
## Criterios de aceptacion
- [ ] `apps/agent_runner_api/` scaffold Go (main.go, db.go, handlers.go, sse.go, agent_spawn.go).
- [ ] Migrations 001-005 versionadas + aplicadas al arrancar (idempotente).
- [ ] Endpoints arriba implementados con tests `*_test.go`.
- [ ] systemd unit `agent_runner_api.service` con `Restart=always`.
- [ ] `app.md` con trio + bloque `service:` completo (issue 0105).
- [ ] `fn doctor services-spec` valida bloque.
- [ ] Smoke test: POST /api/runs con issue dummy crea worktree real en `/tmp/wt-test-<id>`, persiste row en `agent_runs`, lanza echo subprocess (no claude real en test).
- [ ] Cleanup en abort: subprocess killed + `git worktree remove --force` + row marcada `aborted`.
- [ ] e2e_checks: build, migration apply, health, smoke run dummy, cleanup.
- [ ] Documentado en `docs/capabilities/agents.md` (capability group `agents`, ver 0114).
## Gotchas
- `git worktree add` falla si el branch ya existe. Reset hard antes (mismo patron que `autonomous_loop.md`).
- Worktree y main repo comparten `.git/hooks/`. Pre-commit puede bloquear. Permitir `--no-verify` SOLO si `events_json[].decision="skip_hook"` documentado.
- `claude --headless` necesita PATH correcto (`~/.local/bin`). Service systemd corre con user env: verificar `Environment=PATH=...`.
- Subprocess Claude puede correr horas. NO bloquear handler HTTP: spawn async, devolver `run_id` inmediato, monitorear PID en goroutine.
- SSE: clientes ImGui (kanban_cpp, skill_tree) deben reconectar con `Last-Event-ID`.
- Paths protegidos (`dev/autonomous_protected_paths.json`) aplican igual aqui. Reusar logica de fn-orquestador.
## Out of scope
- UI propio del service (es backend puro; UIs son kanban_cpp + skill_tree).
- Auth/auth tokens (local-only por ahora; agregar en issue separado si se expone fuera de localhost).
- Webhook Gitea para auto-trigger desde commits.
- Schedule cron para workflows recurrentes.
+106
View File
@@ -0,0 +1,106 @@
---
id: "0114"
title: "DoD evidence schema canonico: frontmatter + BD + validator"
status: pendiente
type: feature
domain:
- taxonomy
- dev-loop
- registry-quality
scope: registry
priority: alta
depends: []
blocks:
- "0115"
- "0117"
related:
- "0008"
- "0100"
- "0102"
created: 2026-05-18
updated: 2026-05-18
tags: [dod, evidence, frontmatter, taxonomy, validator]
flow: "0008"
---
# 0114 — DoD evidence schema canonico
## Problema
Hoy `## Definition of Done` es una lista markdown libre. `dod_user:` existe en frontmatter (issue 0102) como ratio. Falta una forma **estructurada** de declarar QUE evidencia tiene que aportar el agente por cada item DoD (screenshot, log, url, output cmd).
Sin schema, el agente no sabe que adjuntar y el validador no puede checkear automaticamente.
## Decision
Anadir bloque `dod_evidence_schema:` al frontmatter de **issues** y **flows**. Lista de items con shape canonico:
```yaml
dod_evidence_schema:
- id: surface_1_board_drag
kind: screenshot
expected: "kanban_cpp.exe board con card en columna Doing (agent), barra progreso visible"
required: true
- id: backend_health
kind: cmd
expected: "curl -fsS http://localhost:8403/api/health == 200"
required: true
- id: timeline_entry
kind: url
expected: "http://localhost:8486/api/runs?app=kanban_cpp devuelve >=1 run"
required: false
- id: agent_log
kind: log
expected: "agent_runs/<run_id>/agent.log contiene 'workflow done'"
required: true
```
### Kinds
| `kind` | Que adjunta el agente | Como valida |
|---|---|---|
| `screenshot` | path PNG en `agent_runs/<run_id>/evidence/<item_id>.png` | check existe + tamaño > 0 + dimensions sensatas |
| `log` | path file (txt/log) | check existe + grep pattern de `expected` (opcional) |
| `url` | URL string | HEAD request (2xx/3xx) o GET + match pattern |
| `cmd` | comando + stdout esperado | exec + compare exit code + grep stdout |
### Persistencia
Frontmatter declara el SCHEMA (lo que se espera). `agent_runner_api` (0113) crea un row en `dod_items` por cada entrada al iniciar run. Agente luego adjunta `dod_evidence` rows.
## Validator: `audit_dod_schema_go_infra`
Funcion Go nueva en `functions/infra/`. Lee `.md` de `dev/issues/` + `dev/flows/`, parsea frontmatter, valida:
- `id` unico por archivo.
- `kind` in [`screenshot`, `log`, `url`, `cmd`].
- `expected` no vacio.
- `required` bool (default true).
Output: tabla caveman con drift / errores.
Wrapper CLI: `fn doctor dod`.
## Criterios de aceptacion
- [ ] Plantilla `docs/templates/issue.md` + `docs/templates/flow.md` actualizadas con bloque opcional `dod_evidence_schema:` y ejemplo.
- [ ] `audit_dod_schema_go_infra` registrado (`functions/infra/audit_dod_schema.{go,md}`).
- [ ] `fn doctor dod` muestra: items por archivo + drift + errores.
- [ ] Indexer (`registry/parser.go`) lee `dod_evidence_schema:` y lo persiste si afecta a la tabla `issues`/`flows` (en `apps/issues_api/`).
- [ ] Migracion `apps/agent_runner_api/migrations/004_dod_items.sql` referencia este schema (issue 0113).
- [ ] Doc en `dev/issues/README.md` + `dev/flows/README.md`: cuando declarar evidence schema, ejemplos por kind.
- [ ] Al menos 2 issues piloto con bloque rellenado (recomendado: 0112 + 0116).
- [ ] Tests Go: `audit_dod_schema_test.go` cubre kinds validos/invalidos + frontmatter malformed.
## Gotchas
- `dod_user:` (0102) es METRICA (ratio). `dod_evidence_schema:` es DECLARACION. NO renombrar ni fusionar — son cosas distintas.
- Frontmatter YAML con array de objects: parser actual debe soportarlo. Verificar con `registry/parser.go` antes.
- Schema retroactivo: issues viejos sin bloque siguen validos (`dod_evidence_schema: []` o ausente -> sin validacion automatica).
- `cmd` con secretos/credenciales: NUNCA en el `expected`. Si el comando los necesita, env var.
## Out of scope
- UI para editar schema (eso vive en kanban_cpp/skill_tree v2).
- Validacion en CI / pre-commit (futuro: hook que rechaza issue sin schema si type=feature).
- Schema versioning — por ahora v1 implicito.
+118
View File
@@ -0,0 +1,118 @@
---
id: "0115"
title: "Funcion agent_launch_worktree: crear worktree + spawn claude headless"
status: pendiente
type: feature
domain:
- agents
- workflows
- registry-quality
scope: registry
priority: alta
depends:
- "0113"
blocks: []
related:
- "0008"
- "0069"
created: 2026-05-18
updated: 2026-05-18
tags: [agents, worktree, claude, registry-gap, go]
flow: "0008"
---
# 0115 — Funcion `agent_launch_worktree`
## Problema
`agent_runner_api` (0113) tiene que: (1) crear worktree, (2) spawn `claude --headless`, (3) tail stderr para capturar status, (4) cleanup en abort. Esa logica ya esta inline en:
- `.claude/skills/parallel-fix-issues/` (bash)
- `fn-orquestador` agent (issue 0069)
- futura `agent_runner_api`
>2x repeticion -> promover a funcion del registry (regla `delegation.md`).
## Decision
Funcion Go nueva `agent_launch_worktree_go_infra` (impure) en `functions/infra/`. API:
```go
package infra
type WorktreeLaunchConfig struct {
RepoRoot string // path al main repo
Branch string // "auto/<issue>-<slug>"
WorktreePath string // "../wt-<run_id>" o absoluto
Prompt string // prompt para claude -p
LogPath string // donde escribir stderr+stdout
Env map[string]string // PATH, HOME, etc.
SkipPerms bool // pasa --dangerously-skip-permissions
ResetIfExists bool // si branch ya existe, reset --hard a master
}
type WorktreeLaunchResult struct {
PID int
Branch string
WorktreePath string
LogPath string
StartedAt int64
Error string // vacio si OK
}
func AgentLaunchWorktree(cfg WorktreeLaunchConfig) WorktreeLaunchResult
```
Comportamiento:
1. `cd RepoRoot`.
2. Si `ResetIfExists` y branch existe: `git branch -D Branch` + `git worktree remove --force WorktreePath` (best-effort).
3. `git worktree add WorktreePath -b Branch master`.
4. `cmd := exec.Command("claude", args...)` con stdin del Prompt, stderr+stdout a `LogPath`.
5. `cmd.Start()` (no Wait — async).
6. Devuelve PID + path log.
Funcion hermana `agent_cleanup_worktree_go_infra`:
```go
func AgentCleanupWorktree(repoRoot, branch, worktreePath string, pid int) error
```
Kill PID + remove worktree + delete branch.
## Capability group `agents`
Crear `docs/capabilities/agents.md` listando:
- `agent_launch_worktree_go_infra`
- `agent_cleanup_worktree_go_infra`
- (futuro 0117) `dod_evidence_panel_cpp_viz`
- (futuro 0118) `agent_runs_timeline_cpp_viz`
- (futuro) `dod_validate_evidence_go_infra`
Tag `agents` aplicado a las funciones. `fn doctor capabilities` valida.
## Criterios de aceptacion
- [ ] `functions/infra/agent_launch_worktree.{go,md}` registrado.
- [ ] `functions/infra/agent_cleanup_worktree.{go,md}` idem.
- [ ] `params_schema` y `output` completos en frontmatter.
- [ ] `.md` cumple contrato self-doc (`## Ejemplo` con args reales, `## Cuando usarla`, `## Gotchas`).
- [ ] Tests `*_test.go` con repo temp + branch dummy + comando echo (sin claude real en CI).
- [ ] `agent_runner_api` (0113) usa estas funciones — `uses_functions` declarado en `app.md`.
- [ ] `parallel-fix-issues` skill actualizada para invocar la funcion (no reescribir inline). O issue separado si scope.
- [ ] Tag `agents` aplicado a ambas funciones.
- [ ] `docs/capabilities/agents.md` creado con tabla + ejemplo canonico end-to-end.
- [ ] `fn doctor capabilities` lista grupo `agents` sin drift.
- [ ] `fn doctor uses-functions` limpio en consumers.
## Gotchas
- `git worktree add` con branch existente FALLA. `ResetIfExists=true` cubre el caso reanudar de `fn-orquestador`.
- Worktrees comparten `.git/hooks/`. Documentar en `## Gotchas` del `.md`.
- `claude --headless` no acepta TTY. `cmd.Stdin = strings.NewReader(prompt)`. NO usar `cmd.StdinPipe()` sin cerrar.
- Subprocess vive horas. NO esperar Wait sincrono — devolver PID y dejar al caller monitorear.
- Path absoluto vs relativo: si `WorktreePath` es relativo, se resuelve respecto a `RepoRoot`. Documentar.
- `Env` debe incluir `PATH` que contenga `claude` binary. Service systemd-user no hereda PATH interactivo por defecto.
## Out of scope
- Logica TBD merge (vive en `agent_runner_api/handlers.go`).
- Watchdog/timeout (vive en `agent_runner_api`).
- Telemetria de run (vive en `agent_runs` table).
@@ -0,0 +1,95 @@
---
id: "0116"
title: "skill_tree v2: reemplazar boton Claude fix por Launch workflow"
status: pendiente
type: feature
domain:
- cpp-stack
- agents
- dev-loop
scope: app
priority: alta
depends:
- "0113"
- "0114"
blocks: []
related:
- "0008"
- "0109"
- "0117"
- "0118"
created: 2026-05-18
updated: 2026-05-18
tags: [skill-tree, agents, ui, feature-flag]
flow: "0008"
---
# 0116 — skill_tree v2: Launch workflow
## Problema
`apps/skill_tree/main.cpp::spawn_claude_terminal` abre `wt.exe new-tab wsl.exe -- bash -ic "claude --dangerously-skip-permissions"`. Side effects:
- Terminal externa fuera de la app (mala UX para agentes paralelos).
- Sin trazabilidad (run no persistido).
- Sin DoD ni evidencias.
- Imposible cancelar desde la UI.
Flow 0008 surface 2: usuario clica `Launch workflow` -> run trackeado en `agent_runner_api`, NO se abre terminal.
## Decision
Modificar `apps/skill_tree/main.cpp`:
1. **Eliminar** (o esconder detras de feature flag `legacy_claude_fix`) el boton `TI_TERMINAL_2 " Claude fix"`.
2. **Anadir** boton `TI_PLAY " Launch workflow"`:
- POST `http://localhost:8486/api/runs` con `{issue_id, mode: "fix-issue"}`.
- Captura `run_id` del response.
- Toast `run_id=...` visible 3s.
- Refresh panel `Timeline` (issue 0118).
3. Pasar al panel `DoD inspector` (issue 0117) si el run tiene `dod_evidence_schema` declarado.
### Feature flag `legacy_claude_fix`
`dev/feature_flags.json`:
```json
"legacy_claude_fix": {
"enabled": false,
"issue": "0116",
"description": "Mostrar boton Claude fix viejo (terminal externa). Default OFF tras 0116",
"added": "2026-05-18",
"enabled_at": null
}
```
Logica: si flag ON, muestra ambos botones (Launch workflow + Claude fix legacy) durante rollback window. Si OFF (default), solo Launch workflow.
Eliminar flag + codigo legacy en issue separado tras 2 semanas estables.
## Criterios de aceptacion
- [ ] `apps/skill_tree/main.cpp` modificado: boton `Launch workflow` operativo.
- [ ] POST a `:8486/api/runs` usa `http_request_cpp_core` (0110) — NO popen inline.
- [ ] Feature flag `legacy_claude_fix` registrado en `dev/feature_flags.json` con `enabled: false`.
- [ ] Cuando flag OFF: `Claude fix` no se renderiza.
- [ ] Cuando flag ON: ambos botones aparecen para rollback.
- [ ] Toast `run_id=...` visible al lanzar.
- [ ] Si `agent_runner_api` no responde (`:8486` down): toast error + sugerencia `systemctl --user start agent_runner_api`.
- [ ] `uses_functions` actualizado en `apps/skill_tree/app.md`: anadir `http_request_cpp_core`.
- [ ] `e2e_checks` actualizados: smoke launch workflow con backend mock.
- [ ] Test manual: clic en nodo issue -> ve run en kanban_cpp `panel_agent_runs` y en skill_tree `Timeline` (0118).
- [ ] Version bump `apps/skill_tree/app.md::version` minor (`/version apps/skill_tree minor "..."`).
## Gotchas
- `spawn_claude_terminal` esta en main.cpp inline. NO eliminar la funcion en este issue — solo no llamarla cuando flag OFF. Eliminar en issue separado tras 2 semanas.
- HTTP POST sincrono bloquearia frame ImGui. Usar `std::async` o spawn thread + flag `pending_launch`. Renderizar spinner.
- Sin `agent_runner_api` corriendo, el boton es decorativo. Doctor check: `fn doctor services` debe mostrar `agent_runner_api active`.
- Si el issue NO tiene `dod_evidence_schema` declarado: run igualmente arranca, pero sin items DoD precreados.
## Out of scope
- Panel DoD inspector (0117).
- Panel Timeline (0118).
- Tab Calendar/Dashboard de skill_tree (otros sub-issues 0109*).
- Eliminacion definitiva de `spawn_claude_terminal` (issue separado tras rollback window).
+100
View File
@@ -0,0 +1,100 @@
---
id: "0117"
title: "Funcion cpp dod_evidence_panel: render screenshots/logs/urls/cmd-output"
status: pendiente
type: feature
domain:
- cpp-stack
- agents
- registry-quality
scope: registry
priority: alta
depends:
- "0110"
- "0114"
blocks: []
related:
- "0008"
- "0112"
- "0116"
created: 2026-05-18
updated: 2026-05-18
tags: [dod, evidence, cpp, imgui, viz, registry-gap]
flow: "0008"
---
# 0117 — Funcion `dod_evidence_panel_cpp_viz`
## Problema
kanban_cpp (0112) y skill_tree v2 (0116) necesitan ambos un panel que muestre items DoD + evidencias adjuntas. Sin funcion compartida, se duplica logica (regla `delegation.md`).
## Decision
Funcion C++ en `cpp/functions/viz/dod_evidence_panel.{cpp,h,md}`. API:
```cpp
namespace fn_viz {
struct DodItem {
std::string id;
std::string kind; // "screenshot" | "log" | "url" | "cmd"
std::string expected;
bool required;
std::string status; // "pending" | "done" | "validated" | "failed"
};
struct DodEvidence {
std::string item_id;
std::string kind;
std::string payload_path; // para screenshot/log
std::string payload_url; // para url
std::string payload_text; // para cmd output
int64_t attached_at;
bool validated;
std::string validated_by;
};
struct DodPanelState {
std::vector<DodItem> items;
std::vector<DodEvidence> evidences;
std::string run_id;
std::function<void(const std::string&)> on_validate; // callback(evidence_id)
std::function<void(const std::string&)> on_reject; // idem
};
void render_dod_evidence_panel(DodPanelState& state);
}
```
Renderiza:
- Lista de items con icono por status (`TI_CIRCLE_DASHED`/`TI_CIRCLE_CHECK`/`TI_CIRCLE_DOT`/`TI_CIRCLE_X`).
- Por item: evidencia adjunta segun kind:
- `screenshot`: thumbnail via `stb_image_load` + `ImGui::Image`. Click -> open full-size en popup.
- `log`: `selectable_text_cpp_core` (registry) con scroll + grep pattern de `expected` highlighted.
- `url`: `TI_EXTERNAL_LINK` clickable -> `ShellExecuteW` (Win) / `xdg-open` (Linux).
- `cmd`: dos columnas (expected vs actual) usando `data_table_cpp_viz`. Diff highlight rojo si mismatch.
- Botones por evidencia: `Validate` (verde, callback `on_validate`) / `Reject` (rojo, callback `on_reject`).
## Criterios de aceptacion
- [ ] `cpp/functions/viz/dod_evidence_panel.{cpp,h,md}` registrado.
- [ ] `params_schema` y `output` completos en frontmatter.
- [ ] Tag `agents` aplicado.
- [ ] `.md` cumple contrato self-doc (`## Ejemplo` con DodPanelState concreto, `## Cuando usarla`, `## Gotchas`).
- [ ] Demo en `cpp/apps/primitives_gallery/demos_viz.cpp` con DodPanelState dummy (4 kinds + 2 validados + 2 pending).
- [ ] `kanban_cpp::panel_dod` usa la funcion (declarado en `uses_functions`).
- [ ] `skill_tree` (panel inferior cuando hay run activo) usa la funcion.
- [ ] Tests visuales: golden image en `primitives_gallery --capture`.
- [ ] `fn doctor capabilities` muestra grupo `agents` con esta funcion listada.
- [ ] `uses_functions` declara: `selectable_text_cpp_core`, `data_table_cpp_viz`, `icons_tabler_cpp_core`.
## Gotchas
- `stb_image_load` para PNGs grandes (screenshots full HD): clamp dimensions del thumbnail a 320x180. Liberar texturas con `glDeleteTextures` al cerrar panel.
- WSL paths vs Windows: si `payload_path` viene como `/mnt/c/...`, convertir a `C:\...` solo para `ShellExecuteW`. Para `stb_image` da igual.
- Cache de texturas: re-cargar PNG cada frame es caro. Map `path -> GLuint` con LRU. Invalidar si mtime cambia.
- Callbacks `on_validate`/`on_reject`: invocan POST HTTP al `agent_runner_api`. NO bloquear frame; spawn thread.
- `cmd` evidence con stdout largo: clip a 50 lineas + boton `Show full`.
## Out of scope
- Editor de schema DoD (vive en kanban_cpp o futura UI).
- Comparacion AI de screenshots (futuro: visual diff).
- Persistencia local de validaciones offline.
+100
View File
@@ -0,0 +1,100 @@
---
id: "0118"
title: "Funcion cpp agent_runs_timeline: panel SSE de runs cross-app"
status: pendiente
type: feature
domain:
- cpp-stack
- agents
- registry-quality
scope: registry
priority: alta
depends:
- "0110"
- "0113"
blocks: []
related:
- "0008"
- "0112"
- "0116"
- "0117"
created: 2026-05-18
updated: 2026-05-18
tags: [agents, timeline, sse, cpp, imgui, viz, registry-gap]
flow: "0008"
---
# 0118 — Funcion `agent_runs_timeline_cpp_viz`
## Problema
Panel de timeline de runs es identico en kanban_cpp (0112) y skill_tree v2 (0116). Sin funcion compartida = duplicacion.
## Decision
Funcion C++ en `cpp/functions/viz/agent_runs_timeline.{cpp,h,md}`. API:
```cpp
namespace fn_viz {
struct AgentRun {
std::string id;
std::string app; // "kanban_cpp" | "skill_tree" | ...
std::string issue_id;
std::string card_id;
std::string branch;
std::string status; // "pending" | "running" | "done" | "validated" | "merged" | "aborted" | "failed"
int64_t started_at;
int64_t finished_at;
int dod_total;
int dod_done;
int dod_validated;
};
struct TimelineFilter {
std::vector<std::string> apps; // vacio = todos
std::vector<std::string> statuses; // vacio = todos
int64_t since_ts; // 0 = sin filtro
};
struct TimelineState {
std::string sse_url; // ej "http://localhost:8486/api/runs/sse"
std::vector<AgentRun> runs;
TimelineFilter filter;
std::function<void(const std::string&)> on_select; // callback(run_id)
};
void render_agent_runs_timeline(TimelineState& state);
void poll_sse_runs(TimelineState& state); // llamar 1x/frame, no bloquea
}
```
Renderiza tabla con `data_table_cpp_viz`:
- Cols: status (icon), app (chip color), issue/card, branch, dod_done/total, dod_validated/total, duration, started_at.
- Sort por started_at desc por defecto.
- Click row -> `on_select(run_id)`.
- Filtros arriba: combo apps multi-select + combo statuses + date picker `since`.
- SSE en background thread: append runs nuevos, update statuses en vivo.
## Criterios de aceptacion
- [ ] `cpp/functions/viz/agent_runs_timeline.{cpp,h,md}` registrado.
- [ ] `params_schema` y `output` completos.
- [ ] Tag `agents` aplicado.
- [ ] `.md` cumple contrato self-doc (`## Ejemplo`, `## Cuando usarla`, `## Gotchas`).
- [ ] SSE client en background thread con reconnect + `Last-Event-ID` resume.
- [ ] Demo en `cpp/apps/primitives_gallery/demos_viz.cpp` con TimelineState mock (5 runs varied status).
- [ ] `kanban_cpp::panel_agent_runs` usa la funcion.
- [ ] `skill_tree` `panel_timeline` usa la funcion.
- [ ] `fn doctor capabilities` muestra grupo `agents` con esta funcion.
- [ ] `uses_functions` declara: `http_request_cpp_core`, `data_table_cpp_viz`, `icons_tabler_cpp_core`, `tokens_cpp_core`.
## Gotchas
- SSE reconnect: si `agent_runner_api` cae, reintenta cada 5s con backoff. Estado `disconnected` visible.
- Thread-safety: SSE thread escribe `state.runs`; UI thread lee. Mutex o cola SPSC.
- Memory: si runs > 1000 historicos, paginar (LRU 200 visibles). Antiguos se descargan on-demand via `GET /api/runs?since=...`.
- App `chip color`: mapear app_name -> accent del trio (kanban_cpp `#a855f7`, skill_tree color actual). Cache.
- Click row durante streaming SSE: no resetear seleccion al refrescar lista; preservar por `run_id`.
## Out of scope
- Editor inline de DoD desde timeline (eso es panel DoD 0117).
- Export CSV/JSON.
- Notificaciones desktop al cambiar status.
@@ -0,0 +1,102 @@
---
id: "0119"
title: "kanban_cpp: leer dev/issues + dev/flows como cards (sync layer)"
status: pendiente
type: feature
domain:
- cpp-stack
- kanban
- dev-loop
scope: app
priority: alta
depends:
- "0112"
blocks: []
related:
- "0008"
- "0109m"
- "0114"
created: 2026-05-18
updated: 2026-05-18
tags: [kanban, issues, flows, sync, frontmatter]
flow: "0008"
---
# 0119 — kanban_cpp sync layer: issues + flows como cards
## Problema
kanban_cpp (0112) clona el backend de kanban_web con `operations.db` vacia. Pero la intencion del flow 0008 es **gestionar issues y flows directamente desde kanban_cpp**. Sin sync con `dev/issues/*.md` + `dev/flows/*.md`, el board nace vacio y el usuario tendria que recrear cards a mano.
## Decision
Anadir endpoint al backend de kanban_cpp que lee frontmatter de los `.md` y los expone como cards/columnas virtuales. Lectura directa de filesystem (no via `issues_api` 0109m — el API service aun no existe; cuando exista, swap).
### Mapping `.md` -> card
| Frontmatter | Campo card |
|---|---|
| `id` | `card.external_id` |
| `title` | `card.title` |
| `status` (`pendiente`/`en-curso`/`done`/`deferred`) | `column` (mapping) |
| `priority` (alta/media/baja) | `card.priority` |
| `type` (feature/bug/chore/app) | `card.tag` |
| `tags` | `card.tags` |
| `flow` | `card.flow_id` |
| `dod_evidence_schema` (0114) | `card.dod_items[]` |
| body `## Problema` + `## Decision` | `card.description` |
### Mapping status -> columna kanban
```
pendiente -> Backlog
en-curso -> Doing
en-revisión -> Review
done -> Done
deferred -> Deferred
```
### Tableros separados
- Board `issues`: source = `dev/issues/*.md`.
- Board `flows`: source = `dev/flows/*.md`, columnas distintas (Pending / Running / Done / Deferred). flow body trae `## Definition of Done (user-facing, 4 surfaces obligatorios)`.
### Endpoints nuevos backend kanban_cpp
- `GET /api/boards/issues/cards` — lee `dev/issues/*.md`, cachea 30s.
- `GET /api/boards/flows/cards` — lee `dev/flows/*.md`, cachea 30s.
- `PATCH /api/boards/<board>/cards/<id>` — escribe vuelta al `.md` (cambia `status` en frontmatter). Usa `edit_yaml_frontmatter_go_core` del registry (verificar existe; si no, crear via fn-constructor).
- `POST /api/boards/<board>/cards/<id>/launch` — proxy a `agent_runner_api:8486/api/runs`.
### Watcher de cambios
`fsnotify` sobre `dev/issues/` + `dev/flows/`. Cuando hay write -> invalida cache + push SSE a UI -> kanban_cpp refresca board.
## Criterios de aceptacion
- [ ] `apps/kanban_cpp/backend/issues_source.go` parsea frontmatter via funcion del registry.
- [ ] `apps/kanban_cpp/backend/flows_source.go` idem para flows.
- [ ] Endpoints arriba implementados con tests.
- [ ] `fsnotify` watcher activo; cambio en `.md` propaga a UI en < 2s.
- [ ] PATCH actualiza solo el campo `status` del frontmatter, preserva resto.
- [ ] Round-trip: PATCH `/cards/0113` status=`en-curso` -> file mtime cambia -> `dev/issues/0113-*.md` tiene `status: en-curso`.
- [ ] Click `Launch` en card invoca `agent_runner_api` con `issue_id` + DoD items extraidos del frontmatter.
- [ ] Tag taxonomy issue 0103 respetada — solo statuses canonicos.
- [ ] kanban_cpp UI muestra ambos boards (tabs `Issues` / `Flows`) con cards reales del repo al arrancar.
- [ ] e2e_check: crear `.md` dummy -> aparece en board en <5s.
## Gotchas
- Frontmatter mal formado (yaml invalid): card aparece con badge `parse-error` + tooltip detalle. NO crashea backend.
- Cambios concurrentes: humano edita `.md` con vim mientras agente lo PATCHea via API. Usar lock file `.md.lock` corto + retry.
- `fsnotify` no funciona bien en WSL para `/mnt/c/` paths. Backend corre en WSL, lee paths nativos (`/home/lucas/fn_registry/dev/...`). Verificar.
- Issue / flow grandes (>500 lineas body): NO mandar full body en `/cards` (lento). Devolver solo frontmatter + primeras 5 lineas. Body completo via `/cards/<id>` on demand.
- Cards sin `status` (frontmatter incompleto): default `pendiente`.
- Reordenamiento manual: drag-and-drop en UI cambia status (cruzar columna) PERO no orden vertical persistente — no hay campo `order` en frontmatter. Decision: orden por `updated` desc dentro de cada columna.
## Out of scope
- Sync bidireccional con `issues_api` (0109m) — cuando exista, swap filesystem por API call.
- Conflict resolution manual (3-way merge UI).
- Edicion inline del body markdown desde la card (solo PATCH de `status` + `priority` por ahora).
- Bulk operations (mover N cards a la vez).
+11
View File
@@ -115,3 +115,14 @@
| [0083](0083-imagegen-spike02-cross-validation.md) | imagegen — notebook 02 validacion cruzada diffusers vs sdcpp_python | pendiente | alta | feature | — | | [0083](0083-imagegen-spike02-cross-validation.md) | imagegen — notebook 02 validacion cruzada diffusers vs sdcpp_python | pendiente | alta | feature | — |
| [0084](0084-imagegen-studio-go-app.md) | imagegen_studio — app Go binario producto (Fase 3 plan stack) | pendiente | media | feature | 0082 | | [0084](0084-imagegen-studio-go-app.md) | imagegen_studio — app Go binario producto (Fase 3 plan stack) | pendiente | media | feature | 0082 |
| [0099](0099-datahub-app-launcher.md) | datahub — launcher central para arrancar todas las apps del registry | pendiente | alta | feature | — | | [0099](0099-datahub-app-launcher.md) | datahub — launcher central para arrancar todas las apps del registry | pendiente | alta | feature | — |
| [0105](0105-service-frontmatter-standardization.md) | Estandarizar bloque `service:` en app.md + indexer + `fn doctor services-spec` | in-progress | alta | feature | — |
| [0106](0106-services-monitor-app.md) | App `services_monitor`: dashboard cross-PC de services activos | in-progress | alta | app | 0105 |
| [0107](completed/0107-modules-standardization.md) | Estandarizar sistema de modulos C++: drift + split data_table + tiers + version pinning + docs API | completado | alta | refactor | flag `modules-v2` activo |
| [0107a](completed/0107a-fn-doctor-modules.md) | `fn doctor modules` — detectar drift uses_modules vs uses_functions | completado | alta | feature | parte de 0107 |
| [0107b](completed/0107b-clean-data-table-consumers.md) | Limpiar uses_functions de 8 apps consumidoras de data_table (67 entradas) | completado | alta | refactor | parte de 0107 |
| [0107c](completed/0107c-split-data-table.md) | Partir `modules/data_table/data_table.cpp` (4777 LOC) en sub-funciones del registry | completado | alta | refactor | parte de 0107 |
| [0107d](completed/0107d-module-tiers-policy.md) | Tiers — members vs uses_functions en module.md | completado | alta | refactor | parte de 0107 |
| [0107e](0107e-version-pinning-codegen.md) | uses_modules con min_version + codegen fail-loud | pendiente | media | feature | follow-up de 0107 |
| [0107f](completed/0107f-modules-api-docs.md) | `modules/README.md` + `docs/MODULES_API.md` — contrato publico de modulos | completado | alta | docs | parte de 0107 |
| [0107g](completed/0107g-migrate-inline-begintable.md) | Migrar inline `ImGui::BeginTable` a `data_table::render` (4/8 migradas + 4 abortadas con razon tecnica + 9 LAYOUT-TABLE comentados) | completado | media | refactor | parte de 0107 |
| [0108](completed/0108-tables-playground-aggressive-testbed.md) | App `tables_qa` — testbed agresivo data_table: 10 tabs + panel QA flotante + perf tests (1K..10M filas) + Run Tests + apphub + tables_playground deprecado | completado | alta | app | depende 0107 |
@@ -0,0 +1,260 @@
---
id: "0107"
title: "Estandarizar sistema de modulos C++: limpiar drift data_table + politica API + version pinning + /version command"
status: pendiente
type: refactor
domain:
- meta
- cpp-stack
- tooling
scope: multi-app
priority: alta
depends: []
blocks:
- "0108"
related:
- "0097"
- "0081"
- "0086"
created: 2026-05-17
updated: 2026-05-17
tags: [modules, cpp, data-table, framework, refactor, fn-doctor, versioning]
---
# 0107 — Estandarizar sistema de modulos C++
## Problema
Auditoria 2026-05-17 sobre `modules/` (framework + data_table) revela que el sistema de modulos C++ esta a medio camino: la idea (modulos opt-in versionados con manifest auditable) es solida, pero su implementacion tiene fugas que invalidan el contrato.
### Drift uses_modules ↔ uses_functions (7/7 apps consumidoras)
`module.md` dice "cuando declaras `uses_modules`, NO repetir los miembros en `uses_functions`". Realidad medida hoy:
| App | uses_functions total | miembros data_table duplicados |
|---|---|---|
| services_monitor | 12 | 12 |
| dag_engine_ui | 13 | 12 |
| odr_console | 5 | 5 |
| navegator_dashboard | 20 | 12 |
| graph_explorer | 42 | 12 |
| registry_dashboard | 37 | 11 |
| app_gestion | 12 | 12 |
7 de 7 apps violan la regla clave que justifica el sistema. `fn doctor cpp-apps` no detecta el drift.
### `data_table.cpp` = 4777 LOC
El "modulo" es un god-file con UI entera (chips, viz, grid, drill, joins, AI, button, color rules) dentro de un `.cpp`. Imposible auditar consumidores parciales, imposible registrar miembros como funciones reales del registry (cada uno con su `.md`).
### Boundary modulo vs funcion borrosa
`lua_engine`, `llm_anthropic`, `join_tables` son members de `data_table`. Pero lua/llm/join son utiles fuera de tablas. Forzar membership infla el surface del modulo y obliga a las apps a tragarse lua+llm+anthropic+join aunque solo quieran render simple. No hay tier "data_table_core" vs "data_table_full".
### Versionado declarado, no enforced
`module.md` tiene `version: 1.4.0`. `app.md` dice `uses_modules: [data_table_cpp]` sin version. Bump breaking de modulo → todas las apps rompen sin warning hasta compile error.
### Codegen silencioso
`execute_process(... codegen_app_modules)` emite WARNING solo si rc != 0 y != 2. Si Python falta → stub vacio sin error. About panel muestra "0 modules" en apps que SI deberian tener 1.
### Hard dep oculto
`fn_module_data_table` linkea `fn_framework` PRIVATE para `fn::local_path()`. `module.md` no lo documenta como precondicion publica. Si alguien intenta usar el modulo en una app no-framework, falla en link sin mensaje claro.
### Sin doc API de modulos
No hay un sitio canonico que diga "para usar el modulo X, incluye Y.h, llama X::render(...), pasa State Z". Cada modulo lo improvisa en su `module.md`.
### Sin /version command
No hay flujo estandar para bumpear semver de un modulo o framework. Cada PR lo hace a ojo, sin coherencia entre `module.md::version` y `## Capability growth log`.
## Decision
Issue desglosado en 6 sub-issues independientes detras de feature flag `modules-v2`:
1. **0107a**`fn doctor modules` que detecta drift uses_modules vs uses_functions.
2. **0107b** — Limpiar `uses_functions` de las 7 apps consumidoras de data_table (eliminar miembros duplicados).
3. **0107c** — Partir `modules/data_table/data_table.cpp` (4777 LOC) en sub-funciones del registry (`data_table_chips`, `data_table_grid`, `data_table_viz_panels`, `data_table_drill`, `data_table_ai_panel`, `data_table_color_rules`). Cada una con `.md` propio. Entrypoint queda thin.
4. **0107d** — Mover members generales (`lua_engine`, `llm_anthropic`, `join_tables`, `auto_detect_type`) fuera de `data_table` module. Quedan funciones sueltas que el modulo USA pero no posee. Crear tiers explicitos.
5. **0107e** — Version pinning en `uses_modules` (`uses_modules: [{name: data_table_cpp, min_version: "1.4"}]`) + codegen fail-loud (error si Python falta o count=0 cuando deberia ser >0).
6. **0107f**`modules/README.md` (catalogo) + `docs/MODULES_API.md` (contrato publico por modulo: header path, namespace, entry function, State struct, lifecycle).
Ademas:
- `/version` slash command para bumpear semver de modulo/framework consistentemente (`module.md::version` + `## Capability growth log` + git commit).
- `/fix-issue` referenciara `/version` cuando el cambio toque framework/modules.
Feature flag `modules-v2` activado solo cuando 0107a-f cierran. Antes de cerrar, recompilar TODAS las apps cpp para verificar que el refactor no rompe linkage. Aceptamos coste de recompilacion total.
## Restriccion explicita
Prohibido empezar `chat_ia` (proximo modulo planeado) hasta que 0107 cierre. Razon: si arrancamos otro modulo sin estandar estable, replicamos los mismos bugs en el doble de superficie.
## Tareas (resumen — detalle en sub-issues)
- [ ] **1** Auditoria automatizada → `fn doctor modules` (0107a)
- [ ] **2** Limpiar drift en 7 apps consumidoras (0107b)
- [ ] **3** Partir `data_table.cpp` en sub-funciones del registry (0107c)
- [ ] **4** Politica members generales + tiers (0107d)
- [ ] **5** Version pinning + codegen fail-loud (0107e)
- [ ] **6** Docs API publica modulos (0107f)
- [ ] **7** Recompilar todas las apps cpp + verificar smoke (al cerrar)
- [ ] **8** Activar feature flag `modules-v2`
- [ ] **9** `/version` + `/fix-issue` (no son sub-issues; tareas inline en este issue principal)
## Desglose multi-issue
| Sub-issue | Rama | Alcance | Estado |
|-----------|------|---------|--------|
| 0107a-fn-doctor-modules | issue/0107a-fn-doctor-modules | Check drift uses_modules vs uses_functions + version skew | pendiente |
| 0107b-clean-data-table-consumers | issue/0107b-clean-data-table-consumers | Eliminar miembros duplicados en 7 app.md | pendiente |
| 0107c-split-data-table | issue/0107c-split-data-table | Partir data_table.cpp 4777 LOC en sub-funciones del registry | pendiente |
| 0107d-module-tiers-policy | issue/0107d-module-tiers-policy | Sacar lua/llm/join del modulo data_table; tiers + politica | pendiente |
| 0107e-version-pinning-codegen | issue/0107e-version-pinning-codegen | min_version en uses_modules + codegen fail-loud | pendiente |
| 0107f-modules-api-docs | issue/0107f-modules-api-docs | modules/README.md + docs/MODULES_API.md | pendiente |
### Feature flag
Nombre: `modules-v2`
Se activa cuando 0107a-f cierran + recompilacion total verificada + `fn doctor modules` reporta 0 drift.
### Progreso por tarea
- [ ] **1.1** Implementar check drift en `fn doctor cpp-apps` o sub-comando nuevo — 0107a
- [ ] **1.2** Output JSON con apps que violan regla — 0107a
- [ ] **1.3** Tests sobre fixture sintetica (1 modulo, 3 apps simuladas) — 0107a
- [ ] **2.1** Editar 7 `app.md` removiendo miembros data_table — 0107b
- [ ] **2.2** Verificar build pasa post-clean (no rompe nada — solo metadata) — 0107b
- [ ] **3.1** Identificar fronteras funcionales en data_table.cpp 4777 LOC — 0107c
- [ ] **3.2** Crear `cpp/functions/viz/data_table_chips.cpp` + .h + .md — 0107c
- [ ] **3.3** Crear `cpp/functions/viz/data_table_grid.cpp` + .h + .md — 0107c
- [ ] **3.4** Crear `cpp/functions/viz/data_table_viz_panels.cpp` + .h + .md — 0107c
- [ ] **3.5** Crear `cpp/functions/viz/data_table_drill.cpp` + .h + .md — 0107c
- [ ] **3.6** Crear `cpp/functions/viz/data_table_ai_panel.cpp` + .h + .md — 0107c
- [ ] **3.7** Crear `cpp/functions/viz/data_table_color_rules.cpp` + .h + .md — 0107c
- [ ] **3.8** `data_table.cpp` queda como entrypoint thin que compone las sub-funciones — 0107c
- [ ] **3.9** Bump `module.md::version` a 2.0.0 (breaking interno, API publica `data_table::render` intacta) — 0107c
- [ ] **4.1** Crear `cpp/functions/core/lua_engine.cpp` (ya existe) como funcion suelta; quitar de `module.md::members` — 0107d
- [ ] **4.2** Idem `llm_anthropic`, `join_tables`, `auto_detect_type` — 0107d
- [ ] **4.3** Actualizar `modules/data_table/CMakeLists.txt`: estos `.cpp` ya no se enlazan dentro del modulo; apps que los necesiten los anaden a su CMakeLists — 0107d
- [ ] **4.4** Definir tiers en `module.md`: `core_members` (esenciales) vs `optional_members` (deps externas pesadas) — 0107d
- [ ] **5.1** Parser `app.md::uses_modules` acepta string corto y dict largo — 0107e
- [ ] **5.2** Codegen comprueba `min_version` vs `module.md::version` — error si no cumple — 0107e
- [ ] **5.3** Codegen: si `Python3 NOT FOUND` y app tiene `uses_modules` → CMake FATAL_ERROR — 0107e
- [ ] **5.4** Codegen: si parser devuelve count=0 pero app.md declara `uses_modules` no-vacio → FATAL_ERROR — 0107e
- [ ] **6.1** `modules/README.md` con tabla modulos + version + descripcion + link a contrato — 0107f
- [ ] **6.2** `docs/MODULES_API.md` con contrato canonico (template + ejemplos data_table + framework) — 0107f
- [ ] **6.3** Actualizar `.claude/rules/cpp_apps.md` referenciando `docs/MODULES_API.md` — 0107f
- [ ] **7.1** `redeploy_all_cpp_apps_bash_pipelines` + verificar 0 errores de link — issue principal, al cerrar
- [ ] **7.2** Smoke manual de cada app con `data_table::render` — issue principal, al cerrar
- [ ] **8** Flip `modules-v2: enabled: true` en `dev/feature_flags.json` — issue principal
- [ ] **9.1** Crear `.claude/commands/version.md` (slash command bump semver) — issue principal, ya en este turno
- [ ] **9.2** Crear `.claude/commands/fix-issue.md` que referencie `/version` — issue principal, ya en este turno
## Arquitectura
### Archivos afectados
**0107a** (`fn doctor modules`):
- `functions/infra/audit_modules_drift.go` (NEW) — funcion del registry
- `functions/infra/audit_modules_drift.md` (NEW)
- `cmd/fn/doctor.go` — subcomando `modules`
- `apps/registry_mcp/...` — exponer via `mcp__registry__fn_doctor subcommand="modules"`
**0107b**:
- 7 `app.md` editados: services_monitor, dag_engine_ui, odr_console, navegator_dashboard, graph_explorer, registry_dashboard, app_gestion.
**0107c**:
- `modules/data_table/data_table.cpp` — pasa de 4777 LOC a ~400 (entrypoint que compone).
- `cpp/functions/viz/data_table_chips.cpp/.h/.md` (NEW) — ~600 LOC
- `cpp/functions/viz/data_table_grid.cpp/.h/.md` (NEW) — ~1200 LOC
- `cpp/functions/viz/data_table_viz_panels.cpp/.h/.md` (NEW) — ~800 LOC
- `cpp/functions/viz/data_table_drill.cpp/.h/.md` (NEW) — ~300 LOC
- `cpp/functions/viz/data_table_ai_panel.cpp/.h/.md` (NEW) — ~500 LOC
- `cpp/functions/viz/data_table_color_rules.cpp/.h/.md` (NEW) — ~400 LOC
- `modules/data_table/module.md` — bump version + actualizar members.
- `modules/data_table/CMakeLists.txt` — anadir las sub-funciones a la static lib.
**0107d**:
- `modules/data_table/module.md` — quitar `lua_engine`, `llm_anthropic`, `join_tables`, `auto_detect_type` de members.
- `modules/data_table/CMakeLists.txt` — estos `.cpp` salen.
- Apps consumidoras que necesiten lua/llm/join → declarar en `uses_functions` + anadir el `.cpp` a su CMake.
**0107e**:
- `python/functions/infra/codegen_app_modules.py` — soporte dict largo `{name, min_version}`.
- `cpp/CMakeLists.txt::add_imgui_app` — fail-loud en codegen errors.
- `registry/parser.go` — indexer entiende dict largo.
**0107f**:
- `modules/README.md` (NEW)
- `docs/MODULES_API.md` (NEW)
- `.claude/rules/cpp_apps.md` — link nuevo doc.
**Slash commands** (este issue):
- `.claude/commands/version.md` (NEW)
- `.claude/commands/fix-issue.md` (NEW si no existe)
### pkg/ puro vs shell/ impuro
`audit_modules_drift_go_infra` (0107a) es **impuro** — lee `registry.db` + filesystem (`app.md`, `module.md`). Vive en `functions/infra/`. Core logico (comparar listas de IDs, detectar miembros duplicados) es **puro** y vive como sub-funcion interna del paquete `infra`.
## Ejemplo de uso
```bash
# 1. Detectar drift
fn doctor modules
# Output:
# Module drift report
# ===================
# data_table_cpp (v1.4.0): 7/7 consumers list members in uses_functions
# services_monitor — duplicates: data_table_cpp_viz, viz_render_cpp_viz, ...
# dag_engine_ui — duplicates: ...
# Total apps with drift: 7
# Total modules: 2
# Exit: 1
# 2. Bump version de un modulo (slash command)
/version modules/data_table minor "split data_table.cpp into 6 sub-functions; API publica intacta"
# - Detecta version actual en module.md (1.4.0)
# - Calcula proxima (1.5.0 si minor, 2.0.0 si major)
# - Anade entrada a ## Capability growth log con fecha de hoy
# - Stage en git pero NO commit
# 3. Pinning version en una app
# app.md:
# uses_modules:
# - name: data_table_cpp
# min_version: "1.4"
# Codegen falla en cmake si module.md::version < 1.4
# 4. fix-issue referencia /version
/fix-issue 0107c
# Flow normal del fix-issue + "¿este cambio bumpea version de modulo/framework? si si → /version"
```
## Decisiones de diseno
1. **No tocar API publica `data_table::render(...)`**. Refactor interno. Apps existentes no cambian su llamada.
2. **Aceptamos recompilacion total**. El usuario lo dijo explicito. Coste de tiempo razonable a cambio de limpieza.
3. **Feature flag `modules-v2` no protege codigo runtime** — protege la "promesa" del sistema. Cuando flag flip, `fn doctor modules` debe pasar verde.
4. **`/version` NO hace commit**. Solo edita archivos + stage. El commit final lo hace el flujo normal del fix-issue.
5. **Bloqueamos `chat_ia`**. Si saltamos a otro modulo sin estandar, el caos se duplica.
## Prerequisitos
- Issue 0097 (modules infra inicial) — completado, esto es la evolucion.
- `fn doctor cpp-apps` existe (0081) — reutilizamos paths.
## Riesgos
- **Refactor 4777 LOC**: alto riesgo de regresion visual/funcional. Mitigacion: smoke manual app-por-app + `primitives_gallery --capture` golden images antes/despues.
- **Apps que dependen indirectamente de lua/llm/join** sin declararlo: post-0107d podrian fallar en link. Mitigacion: `fn doctor uses-functions` + recompilacion total como gate.
- **Codegen fail-loud** rompe builds que hoy pasan con stub: deliberado, parte de la idea — pero hay que arreglar todos los app.md antes de mergear 0107e.
## Notas
- `/version` se referenciara desde `/fix-issue` con prompt: "este cambio toca `modules/` o `cpp/framework/`? si si → run `/version <path> [major|minor|patch] [reason]` antes de commit".
- Politica: bump de modulo SIN bump de version = bug. `fn doctor modules` lo detectara via diff hash de `members` + `description` vs ultima version registrada.
@@ -0,0 +1,82 @@
---
id: "0107a"
title: "fn doctor modules — detectar drift uses_modules vs uses_functions y version skew"
status: pendiente
type: feature
domain:
- meta
- cpp-stack
- tooling
scope: registry
priority: alta
depends: []
blocks:
- "0107b"
related:
- "0107"
created: 2026-05-17
updated: 2026-05-17
tags: [modules, fn-doctor, drift, audit, cpp]
---
# 0107a — `fn doctor modules`
Parte del issue principal [0107](0107-modules-standardization.md). Feature flag `modules-v2`.
## Objetivo
Subcomando `fn doctor modules` + funcion del registry `audit_modules_drift_go_infra` que detecta:
1. App declara `uses_modules: [X]` Y un miembro de X aparece en `uses_functions` → drift.
2. App declara `uses_modules: [X]` pero su `CMakeLists.txt` NO linkea `fn_module_X` → mismatch.
3. App linkea `fn_module_X` pero NO declara `uses_modules: [X]` → mismatch inverso.
4. App declara `uses_modules: [{name: X, min_version: "1.4"}]` y `module.md::version` < 1.4 → version skew (post 0107e).
## Tareas
- [ ] **1.1** `functions/infra/audit_modules_drift.go` con firma:
```go
type ModuleDriftReport struct {
ModuleID string
ModuleVersion string
ConsumersTotal int
ConsumersWithDrift int
Violations []DriftViolation
}
type DriftViolation struct {
AppID string
Kind string // "duplicate_members" | "uses_modules_no_link" | "link_no_uses_modules" | "version_skew"
DuplicatedIDs []string
Message string
}
func AuditModulesDrift(registryDB string, cppRoot string) ([]ModuleDriftReport, error)
```
- [ ] **1.2** `.md` correspondiente con frontmatter completo + ejemplo lanzable.
- [ ] **1.3** Subcomando en `cmd/fn/doctor.go`: `fn doctor modules` + `fn doctor modules --json`.
- [ ] **1.4** Exponer via MCP: `mcp__registry__fn_doctor subcommand="modules"`.
- [ ] **1.5** Test sintetico: fixture con 1 modulo + 3 apps (1 limpia, 1 con drift de duplicados, 1 con version skew).
- [ ] **1.6** Anadir entrada a `.claude/rules/fn_doctor.md` mapeando subcomando.
## Output esperado (texto)
```
fn doctor modules
=================
data_table_cpp v1.4.0 — 7 consumers
services_monitor DRIFT 12 duplicated members in uses_functions
dag_engine_ui DRIFT 12 duplicated members in uses_functions
odr_console DRIFT 5 duplicated members in uses_functions
navegator_dashboard DRIFT 12 duplicated members in uses_functions
graph_explorer DRIFT 12 duplicated members in uses_functions
registry_dashboard DRIFT 11 duplicated members in uses_functions
app_gestion DRIFT 12 duplicated members in uses_functions
framework_cpp v1.1.0 — 0 explicit consumers (transitive via add_imgui_app)
Summary: 2 modules, 7 apps with drift, 0 version skews.
Exit: 1 (drift detected)
```
## Riesgos
- Falso positivo si un app legitimamente necesita un miembro fuera del scope del modulo (ej. usar `lua_engine` standalone). Mitigacion: post-0107d (members generales fuera del modulo), este caso desaparece. Mientras tanto: flag `--ignore-known` con allowlist temporal.
@@ -0,0 +1,73 @@
---
id: "0107b"
title: "Limpiar uses_functions de 7 apps consumidoras de data_table (eliminar miembros duplicados)"
status: pendiente
type: refactor
domain:
- meta
- cpp-stack
scope: multi-app
priority: alta
depends:
- "0107a"
blocks: []
related:
- "0107"
created: 2026-05-17
updated: 2026-05-17
tags: [modules, drift, app-md, cleanup]
---
# 0107b — Limpiar drift en 7 apps consumidoras
Parte del issue principal [0107](0107-modules-standardization.md).
## Objetivo
Eliminar de `uses_functions` en 7 `app.md` los IDs que ya son miembros de `data_table` module (declarado en `uses_modules`).
## Apps afectadas
| App | Path | Drift count |
|---|---|---|
| services_monitor | apps/services_monitor/app.md | 12 |
| dag_engine_ui | apps/dag_engine_ui/app.md | 12 |
| odr_console | projects/online_data_recopilation/apps/odr_console/app.md | 5 |
| navegator_dashboard | projects/navegator/apps/navegator_dashboard/app.md | 12 |
| graph_explorer | projects/osint_graph/apps/graph_explorer/app.md | 12 |
| registry_dashboard | projects/fn_monitoring/apps/registry_dashboard/app.md | 11 |
| app_gestion | apps/app_gestion/app.md | 12 |
## Miembros a quitar (segun module.md de data_table v1.4)
- `data_table_cpp_viz`
- `compute_stage_cpp_core`
- `compute_pipeline_cpp_core`
- `compute_column_stats_cpp_core`
- `tql_emit_cpp_core`
- `tql_helpers_cpp_core`
- `tql_apply_cpp_core`
- `tql_to_sql_cpp_core`
- `lua_engine_cpp_core` (hasta 0107d que lo saca del modulo)
- `join_tables_cpp_core` (idem)
- `auto_detect_type_cpp_core` (idem)
- `llm_anthropic_cpp_core` (idem)
- `viz_render_cpp_viz`
NOTA: 0107d sacara lua/join/auto_detect/llm del modulo. Cuando eso pase, esas apps DEBEN volver a anadirlos a `uses_functions` (si los usan directamente). 0107b limpia el estado actual contra `module.md` v1.4; despues de 0107d se ejecuta `fn doctor modules` otra vez y se ajusta.
## Tareas
- [ ] **2.1** Para cada app.md, eliminar las lineas listadas en "Miembros a quitar" del bloque `uses_functions`.
- [ ] **2.2** `fn index` despues.
- [ ] **2.3** Verificar con `fn doctor modules` que `services_monitor` etc. reportan 0 drift.
- [ ] **2.4** Build completo de las 7 apps. Linkage NO debe cambiar (los .cpp seguian viniendo via `fn_module_data_table` enlazado en su CMake).
- [ ] **2.5** Smoke manual rapido (lanzar y cerrar) de cada app.
## Riesgos
- Si `fn doctor uses-functions` se ejecuta antes de que `uses_modules` se entienda como cobertura, marcara las apps como "missing imports". Mitigacion: arreglar primero `audit_uses_functions_go_infra` para que considere `uses_modules` como cobertura transitiva. Tarea inline 2.0 antes de 2.1.
## Notas
- Es solo metadata. No toca codigo, no rompe build. Coste = editar 7 archivos + fn index.
@@ -0,0 +1,74 @@
---
id: "0107c"
title: "Partir modules/data_table/data_table.cpp (4777 LOC) en sub-funciones del registry"
status: pendiente
type: refactor
domain:
- cpp-stack
- meta
scope: module
priority: alta
depends: []
blocks:
- "0107d"
related:
- "0107"
- "0081"
created: 2026-05-17
updated: 2026-05-17
tags: [modules, data-table, refactor, registry, viz]
---
# 0107c — Partir `data_table.cpp` 4777 LOC
Parte del issue principal [0107](0107-modules-standardization.md).
## Problema
`modules/data_table/data_table.cpp` es un god-file de 4777 LOC con UI entera dentro: barra de chips, tabla grid, paneles de viz, drill-down, joins, panel Ask AI, button event sink, color rules, breadcrumb, tooltips. Imposible auditar consumidores parciales. Cada bloque deberia ser una funcion del registry con su `.md` propio (Ejemplo + Cuando usarla + Gotchas).
## Decision
Partir en **6 sub-funciones** dentro de `cpp/functions/viz/` (no en `modules/data_table/` — son funciones del registry reutilizables). El modulo bundla las 6 + el entrypoint thin.
| Sub-funcion | LOC objetivo | Responsabilidad |
|---|---|---|
| `data_table_chips_cpp_viz` | ~600 | Barra de chips superior: filtros activos, TQL preview, save/load query |
| `data_table_grid_cpp_viz` | ~1200 | Render del grid: cells, sorting, freeze cols, declarative renderers (Badge/Progress/Duration/Icon/Button/Dots/CategoricalChip/ColorScale) |
| `data_table_viz_panels_cpp_viz` | ~800 | Paneles de viz lateral: histograms, line, scatter, value-counts |
| `data_table_drill_cpp_viz` | ~300 | Drill-down stack + breadcrumb |
| `data_table_ai_panel_cpp_viz` | ~500 | Panel Ask AI: prompt, llamada a `llm_anthropic`, render respuesta, export |
| `data_table_color_rules_cpp_viz` | ~400 | Editor de reglas de color por columna + aplicacion |
Entrypoint `data_table.cpp` queda ~400 LOC: compone las 6 sub-funciones, gestiona `State`, dispatcher de eventos.
## Tareas
- [ ] **3.1** Leer `data_table.cpp` end-to-end e identificar fronteras funcionales (comentario header de cada bloque sirve).
- [ ] **3.2** `cpp/functions/viz/data_table_chips.cpp` + `.h` + `.md` (NEW).
- [ ] **3.3** `cpp/functions/viz/data_table_grid.cpp` + `.h` + `.md` (NEW).
- [ ] **3.4** `cpp/functions/viz/data_table_viz_panels.cpp` + `.h` + `.md` (NEW).
- [ ] **3.5** `cpp/functions/viz/data_table_drill.cpp` + `.h` + `.md` (NEW).
- [ ] **3.6** `cpp/functions/viz/data_table_ai_panel.cpp` + `.h` + `.md` (NEW).
- [ ] **3.7** `cpp/functions/viz/data_table_color_rules.cpp` + `.h` + `.md` (NEW).
- [ ] **3.8** Reducir `data_table.cpp` a entrypoint thin que llama las 6.
- [ ] **3.9** `modules/data_table/CMakeLists.txt`: anadir las 6 sub-funciones a la static lib.
- [ ] **3.10** `modules/data_table/module.md`: bump `version: 2.0.0` (breaking interno, API publica intacta) + extender `members:` con las 6 nuevas + entrada en `## Capability growth log`.
- [ ] **3.11** Recompilar TODAS las apps que linkean `fn_module_data_table` (7 apps).
- [ ] **3.12** Smoke manual de cada app: tabla renderiza, chips funcionan, viz panels OK, AI panel OK, drill OK, color rules OK.
- [ ] **3.13** `primitives_gallery --capture` golden image antes vs despues — diff visual cero.
## Riesgos
- **State struct**: hoy `data_table::State` es opaco interno del .cpp. Tras split, las 6 sub-funciones necesitan acceder a partes de el. Opciones:
- (a) Mover `State` a `data_table_types.h` publico (mas exposicion pero claro).
- (b) Definir `State` en `data_table.h` con sub-structs por sub-funcion (`State::chips_state`, `State::grid_state`, etc.) y cada sub-funcion recibe su sub-state.
- **Recomendacion**: (b). Mantiene encapsulacion y cada sub-funcion tiene firma clara.
- **API publica `data_table::render(...)` no cambia**. Es la regla dura. Si la firma debe cambiar, ya no es 0107c sino issue nuevo con migration plan.
- **Tiempo de refactor**: 4777 LOC → 6 archivos requiere cuidado quirurgico. Lanzamos `fn-constructor` en paralelo.
## Notas
- Las 6 sub-funciones son `purity: impure` (manipulan ImGui state global).
- Cada `.md` con `tags: [viz, table, imgui, ui]` + `framework: imgui`.
- El refactor lo hara un sub-agente fn-constructor lanzado en paralelo desde el flujo principal del issue 0107.
@@ -0,0 +1,91 @@
---
id: "0107d"
title: "Sacar lua_engine/llm_anthropic/join_tables/auto_detect_type del modulo data_table — politica de tiers"
status: pendiente
type: refactor
domain:
- cpp-stack
- meta
scope: module
priority: alta
depends:
- "0107c"
blocks: []
related:
- "0107"
created: 2026-05-17
updated: 2026-05-17
tags: [modules, data-table, policy, tiers, lua, llm]
---
# 0107d — Politica members generales + tiers
Parte del issue principal [0107](0107-modules-standardization.md).
## Problema
`data_table` module hoy lleva como miembros `lua_engine`, `llm_anthropic`, `join_tables`, `auto_detect_type`. Esos 4 son **utiles fuera de tablas**:
- `lua_engine`: scripting general.
- `llm_anthropic`: LLM wrapper, util en chat_ia (proximo modulo) y otros.
- `join_tables`: util en cualquier app que combine tablas (no solo data_table::render).
- `auto_detect_type`: util en data import generico.
Forzar membership infla el modulo y obliga a las apps a tragarse 4 deps pesadas (lua + curl + libllm + http) aunque solo quieran render basico.
## Decision
Politica de tiers:
```yaml
# module.md (post-0107d)
members:
# core_members: esenciales, sin ellos no hay funcionalidad
- data_table_chips_cpp_viz
- data_table_grid_cpp_viz
- data_table_drill_cpp_viz
- data_table_color_rules_cpp_viz
- data_table_viz_panels_cpp_viz
- compute_stage_cpp_core
- compute_pipeline_cpp_core
- compute_column_stats_cpp_core
- tql_emit_cpp_core
- tql_helpers_cpp_core
- tql_apply_cpp_core
- tql_to_sql_cpp_core
- viz_render_cpp_viz
uses_functions:
# Deps externas usadas por el modulo (no son miembros del modulo)
- lua_engine_cpp_core # TQL scripting (opt-in via feature flag interno)
- llm_anthropic_cpp_core # Ask AI panel (opt-in via FN_LLM_ANTHROPIC)
- join_tables_cpp_core # Joins
- auto_detect_type_cpp_core # Detect tipos al cargar nueva tabla
```
Distincion:
- **`members`**: funciones que el modulo POSEE — viven en `cpp/functions/viz/` y nadie mas las usa directamente (renderizan dentro del modulo).
- **`uses_functions`**: funciones que el modulo CONSUME — viven en `cpp/functions/core/`, son utiles fuera del modulo, otras apps pueden importarlas directamente.
Apps consumidoras de `data_table`:
- Si solo llaman `data_table::render(...)` → solo `uses_modules: [data_table_cpp]`, nada mas.
- Si ademas usan `lua_engine` directamente para sus propios scripts → anaden `lua_engine_cpp_core` a `uses_functions` (no es duplicado, es uso directo independiente).
## Tareas
- [ ] **4.1** Editar `modules/data_table/module.md`: separar `members` core de `uses_functions`. Bump version 2.1.0.
- [ ] **4.2** Editar `modules/data_table/CMakeLists.txt`: `lua_engine.cpp`, `llm_anthropic.cpp`, `join_tables.cpp`, `auto_detect_type.cpp` quedan dentro de la static lib (el modulo los usa internamente), pero el linkage transitivo se controla via PUBLIC vs PRIVATE. Si una app NO usa directamente lua/llm fuera del modulo, igual los recibe pero solo como impl detail del modulo. Decidir: SI mantenemos lua/llm/join dentro de la static lib del modulo (PUBLIC link) o sacamos al app para que cada una decida (PRIVATE en modulo, app linkea por su lado).
- [ ] **4.3** Documentar tier en `docs/MODULES_API.md` (0107f).
- [ ] **4.4** `fn doctor modules` (0107a) entiende la distincion members vs uses_functions y NO marca drift cuando una app lista en `uses_functions` algo que el modulo declara en su `uses_functions` (no es miembro).
- [ ] **4.5** Actualizar 7 app.md: si un app necesita lua/llm/join standalone, declararlo. Si no, no.
## Decisiones de diseno
**Decision 4.2 detallada:** mantener lua/llm/join dentro de `fn_module_data_table` static lib (PUBLIC), porque:
- 100% de las apps que linkean `fn_module_data_table` hoy lo usan para tablas, que usan internamente lua/llm/join.
- Cero apps quieren un "data_table ligero sin lua". Si llegara ese caso → split modulo en `data_table_core` + `data_table_full`. Mientras tanto, KISS.
- La distincion `members` vs `uses_functions` queda solo en `module.md` (metadata) — el CMakeLists agrupa todo bajo la static lib.
## Riesgos
- Ambiguedad "¿el modulo posee X o lo usa?": resolver via 1 regla simple — si `X` aparece como funcion suelta consumida por otras apps fuera del modulo, va a `uses_functions`. Si nadie mas la usa, es `member`.
@@ -0,0 +1,131 @@
---
id: "0107f"
title: "modules/README.md (catalogo) + docs/MODULES_API.md (contrato publico por modulo)"
status: pendiente
type: docs
domain:
- meta
- cpp-stack
- docs
scope: registry
priority: alta
depends: []
blocks: []
related:
- "0107"
created: 2026-05-17
updated: 2026-05-17
tags: [modules, docs, api, contract]
---
# 0107f — Docs API publica de modulos
Parte del issue principal [0107](0107-modules-standardization.md).
## Objetivo
Resolver "no hay un sitio canonico que diga como usar un modulo". Dos archivos:
1. `modules/README.md` — catalogo (tabla con nombre, version, descripcion 1-linea, link a contrato).
2. `docs/MODULES_API.md` — contrato canonico publico de cada modulo (template + ejemplos).
## Estructura `modules/README.md`
```markdown
# Modulos C++ del registry
Bundles versionados de funciones del registry expuestos como static libs.
Apps opt-in via `app.md::uses_modules` + `target_link_libraries(... fn_module_<X>)`.
| Modulo | Version | Static lib | Header | Entry | Descripcion | Contrato |
|---|---|---|---|---|---|---|
| framework_cpp | 1.1.0 | fn_framework | cpp/framework/app_base.h | fn::run_app(cfg, render) | Shell ImGui (GLFW+OpenGL+ImGui+ImPlot+themas+layouts+logger) | [framework_cpp](../docs/MODULES_API.md#framework_cpp) |
| data_table_cpp | 2.0.0 | fn_module_data_table | modules/data_table/data_table.h | data_table::render(...) | Tabla completa TQL+viz+drill+AI+button events | [data_table_cpp](../docs/MODULES_API.md#data_table_cpp) |
## Como anadir un modulo
Ver [docs/MODULES_API.md#cycle](../docs/MODULES_API.md#ciclo-de-vida-de-un-modulo) y `.claude/rules/cpp_apps.md`.
```
## Estructura `docs/MODULES_API.md`
```markdown
# Modules API
Contrato publico canonico de cada modulo C++ del registry. Una app DEBE
poder integrar un modulo leyendo solo esta pagina (sin abrir el .cpp).
## Template por modulo
### <module_name>
**Static lib target:** `<cmake_target>`
**Header path:** `<include>`
**Namespace:** `<namespace>`
**Entry function:** `<signature>`
**State struct:** `<type>` (lifecycle: <lifecycle notes>)
**Public types:** lista de tipos publicos consumidos (TableInput, TableEvent, etc.)
**Dependencias transitivas:** lista de libs externas (lua54, imgui, etc.)
**Min ImGui version:** <X.Y>
**Thread safety:** <main thread only | safe to call from any thread>
#### Opt-in en una app
1. `app.md`: anadir `uses_modules: [{name: <id>, min_version: "X.Y"}]`.
2. `CMakeLists.txt`: `target_link_libraries(<app> PRIVATE <cmake_target>)`.
3. Header: `#include "<header_path>"`.
4. Reservar `<state>` persistente entre frames.
5. Llamar `<entry>` cada frame.
#### Ejemplo minimo
```cpp
[bloque de codigo lanzable]
```
#### Eventos / callbacks
[Tabla de eventos publicos si aplica]
#### Gotchas
[Lista de gotchas conocidos]
#### Version policy
Semver. Major = breaking ABI/API de la entry function o State publico.
Minor = additive (nuevo evento, nuevo renderer opcional). Patch = bugfix.
---
## framework_cpp
[Aplicar template]
## data_table_cpp
[Aplicar template]
## Ciclo de vida de un modulo
1. Crear `modules/<name>/` con `module.md`, `CMakeLists.txt`, `<name>.cpp`, `<name>.h`.
2. `module.md::members` lista funciones del registry que el modulo bundla.
3. `module.md::version` SemVer estricto.
4. `CMakeLists.txt` define static lib `fn_module_<name>` con sus deps.
5. Anadir entrada en `modules/README.md`.
6. Anadir seccion en `docs/MODULES_API.md` siguiendo el template.
7. `fn index` registra el modulo.
8. Cada bump de version: `/version modules/<name> <major|minor|patch> "<reason>"` (ver `.claude/commands/version.md`).
```
## Tareas
- [ ] **6.1** Crear `modules/README.md` con tabla canonica.
- [ ] **6.2** Crear `docs/MODULES_API.md` con template + secciones para los 2 modulos actuales.
- [ ] **6.3** Anadir referencia desde `.claude/rules/cpp_apps.md` a `docs/MODULES_API.md`.
- [ ] **6.4** Anadir referencia desde `cpp/PATTERNS.md` a `docs/MODULES_API.md`.
- [ ] **6.5** Anadir referencia desde `.claude/CLAUDE.md` en la seccion "Estructura" listando `modules/` y enlazando docs.
## Riesgos
- Doc drift: facil que `module.md` y `MODULES_API.md` se desincronicen. Mitigacion: `fn doctor modules` (0107a) verifica que cada modulo registrado tiene seccion en `MODULES_API.md`. Si no, warning.
@@ -0,0 +1,209 @@
---
id: "0107g"
title: "Migrar inline ImGui::BeginTable a data_table::render en apps con tablas de datos reales"
status: en-progreso
type: refactor
domain:
- cpp-stack
- meta
scope: multi-app
priority: media
depends:
- "0107c"
blocks: []
related:
- "0107"
created: 2026-05-17
updated: 2026-05-17
tags: [modules, data-table, drift, audit, inline-begintable]
---
# 0107g — Migrar inline BeginTable a `data_table::render` (data tables reales)
Parte del issue principal [0107](0107-modules-standardization.md). Detectado por `audit_data_table_usage_go_infra` (output en `dev/data_table_integration_audit.md`).
## Problema
Audit automatico identifica ~12 hits de `ImGui::BeginTable` inline en apps que YA declaran `uses_modules: [data_table_cpp]`. Mezcla legitimos + bugs:
- **Legitimos** (NO migrar): KPI grids, schema forms k/v, layout 2-col splitters, chart grids. NO son tablas de datos.
- **Bugs reales**: tablas de datos con filas dinamicas + sort/filter potencial que reinventan logica que el modulo provee.
Resultado: codigo duplicado, comportamiento inconsistente, color/badge/sort/filter "casi-pero-no" igual entre apps. Conexiones raras: cada app personaliza su tabla a mano.
## Decision
Migrar los hits identificados como bugs reales a `data_table::render`. Dejar los legitimos como excepciones documentadas en `docs/MODULES_API.md::Cuando usar data_table::render vs BeginTable directo`.
## Tabla de migracion
| App | Path | Linea | Es bug? | Accion |
|---|---|---|---|---|
| dag_engine_ui | apps/dag_engine_ui/tabs.cpp | 382 | **BUG** (`##dt_run_steps`, 6 cols, scroll Y, runs dinamicas) | Migrar |
| dag_engine_ui | apps/dag_engine_ui/tabs.cpp | 731 | LEGITIMO (`##health_kpis`, 4 cols stretch same, KPI grid) | Dejar + comentar |
| navegator_dashboard | projects/navegator/apps/navegator_dashboard/autoextract_panel.cpp | 528 | **BUG** (`##ax_schema`, 5 cols, filas dinamicas schema) | Migrar |
| navegator_dashboard | projects/navegator/apps/navegator_dashboard/recipes_panel.cpp | 238 | **BUG** (`##recipes_tbl`, 6 cols, filas dinamicas recipes) | Migrar |
| graph_explorer | projects/osint_graph/apps/graph_explorer/extract_panel.cpp | 981 | **BUG** (`##ents`, 5 cols, filas dinamicas entities) | Migrar |
| graph_explorer | projects/osint_graph/apps/graph_explorer/extract_panel.cpp | 1027 | **BUG** (`##rels`, 5 cols, filas dinamicas relations) | Migrar |
| graph_explorer | projects/osint_graph/apps/graph_explorer/main.cpp | 1127 | LEGITIMO (`##enr_params`, form k/v) | Dejar + comentar |
| graph_explorer | projects/osint_graph/apps/graph_explorer/views.cpp | 885 | LEGITIMO (`##insp_id`, inspector form k/v) | Dejar + comentar |
| graph_explorer | projects/osint_graph/apps/graph_explorer/views.cpp | 958 | LEGITIMO (`##insp_fields`, inspector form) | Dejar + comentar |
| graph_explorer | projects/osint_graph/apps/graph_explorer/views.cpp | 1546 | INFO comment, ya migrado a data_table::render | Ignorar (es comentario) |
| graph_explorer | projects/osint_graph/apps/graph_explorer/views.cpp | 1854 | **BUG** (`##te_rows`, col_count dinamico, data table type explorer) | Migrar (segunda fase de la migration ya empezada) |
| graph_explorer | projects/osint_graph/apps/graph_explorer/views.cpp | 2292 | DISCUTIBLE (`##te_fields`, 5 cols, fields de un tipo — semi-dinamico) | Evaluar; si rows >20 migrar, sino dejar |
| registry_dashboard | projects/fn_monitoring/apps/registry_dashboard/views.cpp | 380 | LEGITIMO (`##kpi_grid`, KPI cards) | Dejar + comentar |
| registry_dashboard | projects/fn_monitoring/apps/registry_dashboard/views.cpp | 436 | LEGITIMO (`##chart_grid`, plots grid) | Dejar + comentar |
| registry_dashboard | projects/fn_monitoring/apps/registry_dashboard/views.cpp | 648 | LEGITIMO (`##monitor_kpi`, KPI cards) | Dejar + comentar |
| registry_dashboard | projects/fn_monitoring/apps/registry_dashboard/views.cpp | 1110 | LEGITIMO (`##proj_layout`, 2-col splitter) | Dejar + comentar |
| registry_dashboard | projects/fn_monitoring/apps/registry_dashboard/views.cpp | 1448 | LEGITIMO (`##explorer_layout`, 2-col splitter) | Dejar + comentar |
| registry_dashboard | projects/fn_monitoring/apps/registry_dashboard/work_tab.cpp | 239 | **BUG** (`##flows_work`, 8 cols, filas dinamicas flows) | Migrar |
| registry_dashboard | projects/fn_monitoring/apps/registry_dashboard/work_tab.cpp | 272 | **BUG** (`##top_issues_work`, 7 cols, filas dinamicas issues) | Migrar |
| app_gestion | apps/app_gestion/main.cpp | 722 | DISCUTIBLE (`##linked_tbl`, 4 cols, lista de modulos linked — semi-dinamico, rows <20) | Evaluar; bias a dejar como esta |
**Total a migrar (BUGs): 8 tablas en 4 apps.**
**Total LEGITIMOS (dejar + comentar): 9.**
**Total DISCUTIBLES: 2 — decision contextual.**
**Total comentarios/already-migrated: 1.**
## Tareas
- [x] **1.1** Migrar `dag_engine_ui/tabs.cpp:382` (`##dt_run_steps`) → `data_table::render`. HECHO. Function col → Button action="open_fn"; Status → CategoricalChip; Duration → Duration renderer.
- [~] **1.2** Migrar `navegator_dashboard/autoextract_panel.cpp:528` (`##ax_schema`). ABORTADO: form editor con InputText/Checkbox editables inline en cada fila (field, selector, type, keep). data_table::render no soporta CellEdit como InputText inline. Comentado con LAYOUT-TABLE.
- [x] **1.3** Migrar `navegator_dashboard/recipes_panel.cpp:238` (`##recipes_tbl`). HECHO. Patron B: 4 columnas Button (run/edit/delete/open_df) + ev.row para indexar yaml_path. last_status → CategoricalChip.
- [~] **1.4** Migrar `graph_explorer/extract_panel.cpp:981` (`##ents`). ABORTADO: form editor con InputText editables por fila (type_buf, name_buf) + Checkbox "sel". Mutacion directa de structs entities[i]. No mapeable a data_table. Comentado con LAYOUT-TABLE.
- [~] **1.5** Migrar `graph_explorer/extract_panel.cpp:1027` (`##rels`). ABORTADO: form editor con Checkbox "sel" + inmutabilidad necesaria (relations[i].selected se muta inline). Comentado con LAYOUT-TABLE.
- [~] **1.6** Migrar `graph_explorer/views.cpp:1854` (`##te_rows`). ABORTADO: interactividad app-específica no mapeable — Selectable + single-click ramificado por estado de promocion (promoted/unpromoted), dblclick promote-flow, PopupContextItem con promote/demote/focus condicionales, SmallButton Promote-out-of-group, paginacion manual. Equivalente exact en data_table events no existe. Comentado explicando razon.
- [x] **1.7** Migrar `registry_dashboard/work_tab.cpp:239` (`##flows_work`). HECHO. 8 cols. Status + Risk → CategoricalChip. BeginChild host 220px.
- [x] **1.8** Migrar `registry_dashboard/work_tab.cpp:272` (`##top_issues_work`). HECHO. 7 cols. Status + Deps + Prio → CategoricalChip. Deps string "-"/"OK"/"blocked" preserva logica de color original. BeginChild host -1.
- [x] **2.1** Anadir comentario `// LAYOUT-TABLE — KPI/form/splitter, no data; keep BeginTable inline.` encima de los 9 hits LEGITIMOS para que `audit_data_table_usage` los excluya en proximas pasadas. HECHO en: ##health_kpis, ##enr_params, ##insp_id, ##insp_fields, ##kpi_grid, ##chart_grid, ##monitor_kpi, ##proj_layout, ##explorer_layout. Los 3 ABORTADOS tambien comentados con razon tecnica.
- [ ] **2.2** Actualizar `audit_data_table_usage_go_infra` para leer ese comentario y filtrar `[warn] -> [ignored:declared_layout_table]`.
- [x] **3.1** Decidir los 2 DISCUTIBLES (`te_fields`, `linked_tbl`) con criterio "si rows pueden crecer > 50, migrar". Decision: DEJAR. `te_fields` max ~20 fields por tipo; `linked_tbl` max ~10 modules linked. Rows no escalan.
- [x] **4.1** Envolver TODAS las llamadas a `data_table::render` en `ImGui::BeginChild` host. HECHO en las 3 tablas migradas: flows_work (220px), top_issues_work (-1), recipes_tbl (300px), dt_run_steps (usa el BeginChild preexistente ##run_steps_wrap).
- [ ] **5.1** Re-ejecutar audit:
```
./fn run audit_data_table_usage
```
Verificar: 0 BUG hits, 9 LEGITIMOS comentados, 11 `no_child_host` resueltos o documentados como excepcion.
- [x] **5.2** Build de las 4 apps modificadas. HECHO: dag_engine_ui, registry_dashboard, graph_explorer compilan OK (Linux). navegator_dashboard es Windows-only (CMakeLists.txt retorna en non-WIN32); sintaxis verificada via g++ -fsyntax-only sin errores.
## Patrones de migracion canonicos
### Patron A: ImGui::BeginTable inline → data_table::render basico
```cpp
// ANTES
if (ImGui::BeginTable("##recipes_tbl", 6, flags)) {
ImGui::TableSetupColumn("name");
ImGui::TableSetupColumn("url_pattern");
// ...
for (const auto& r : recipes) {
ImGui::TableNextRow();
ImGui::TableNextColumn(); ImGui::TextUnformatted(r.name.c_str());
ImGui::TableNextColumn(); ImGui::TextUnformatted(r.url_pattern.c_str());
// ...
}
ImGui::EndTable();
}
// DESPUES
static data_table::State g_st_recipes;
static std::vector<std::string> g_back_recipes; // backing
static std::vector<const char*> g_ptrs_recipes; // ptrs row-major
g_back_recipes.clear();
for (const auto& r : recipes) {
g_back_recipes.push_back(r.name);
g_back_recipes.push_back(r.url_pattern);
// ... resto cols
}
g_ptrs_recipes.clear();
for (auto& s : g_back_recipes) g_ptrs_recipes.push_back(s.c_str());
data_table::TableInput tbl;
tbl.name = "recipes";
tbl.headers = {"name", "url_pattern", "last_status", "last_at", "tries", "ok"};
tbl.types = {data_table::ColumnType::String, data_table::ColumnType::String,
data_table::ColumnType::String, data_table::ColumnType::Date,
data_table::ColumnType::Int, data_table::ColumnType::Bool};
tbl.cells = g_ptrs_recipes.data();
tbl.rows = (int)recipes.size();
tbl.cols = 6;
// Status como CategoricalChip (ganancia inmediata sobre BeginTable)
tbl.column_specs.resize(tbl.cols);
for (int i = 0; i < tbl.cols; i++) tbl.column_specs[i].id = tbl.headers[i];
tbl.column_specs[2].renderer = data_table::CellRenderer::CategoricalChip;
tbl.column_specs[2].chips = {{"ok","#22c55e"},{"error","#ef4444"},{"pending","#a3a3a3"}};
std::vector<data_table::TableEvent> events;
ImGui::BeginChild("##recipes_host", ImVec2(-1, -1));
data_table::render("##recipes_dt", {tbl}, g_st_recipes, &events);
ImGui::EndChild();
for (const auto& ev : events) {
if (ev.kind == data_table::TableEventKind::RowDoubleClick) {
open_recipe_detail(ev.row);
}
}
```
### Patron B: BeginTable inline con interactividad (boton por fila)
Si la BeginTable inline tiene un boton "Delete" / "Edit" por fila → migrar usando `CellRenderer::Button` + `action_id`:
```cpp
// ANTES
ImGui::TableNextColumn();
if (ImGui::SmallButton(("Delete##" + r.id).c_str())) { delete_recipe(r.id); }
// DESPUES — anadir columna actions con button renderer
tbl.headers.push_back("actions");
tbl.types.push_back(data_table::ColumnType::String);
data_table::ColumnSpec actions_spec;
actions_spec.id = "actions";
actions_spec.renderer = data_table::CellRenderer::Button;
actions_spec.button_action = "delete_recipe";
actions_spec.button_label = "Delete";
actions_spec.button_color_hex = "#ef4444";
tbl.column_specs.push_back(actions_spec);
tbl.cols++;
// Backing extra
for (const auto& r : recipes) {
g_back_recipes.push_back(r.id); // celda actions = el id (consumido en ev.value)
}
// Handler
for (const auto& ev : events) {
if (ev.kind == data_table::TableEventKind::ButtonClick && ev.action_id == "delete_recipe") {
delete_recipe(ev.value); // ev.value == r.id de la fila clicada
}
}
```
## Riesgos
- **Backing storage**: las apps deben mantener `std::vector<std::string>` (estable) + `std::vector<const char*>` (ptrs row-major). Helper `cells_to_ptrs()` ya esta usado en data_factory — generalizar como `cpp/functions/core/cells_to_ptrs.cpp` si patron se repite >2 veces (ya pasa).
- **State persistente**: cada migracion requiere `static data_table::State g_st_<name>;`. Si la app tiene N tablas, N states.
- **Comportamiento sutil**: filter/sort/freeze ahora son user-toggle, no controlados por la app. La app pierde control fino, pero gana consistencia.
## Bonus: nueva funcion del registry `cells_to_ptrs_cpp_core`
Patron `g_back + g_ptrs` aparece en data_factory + (post 0107g) en 4 apps mas. Promover a funcion del registry:
```cpp
// cpp/functions/core/cells_to_ptrs.h
namespace fn {
// Converts a row-major flat vector<string> to a row-major vector<const char*>
// pointing into the backing storage. Stable pointers — backing must not be
// resized while ptrs are in use.
void cells_to_ptrs(const std::vector<std::string>& back,
std::vector<const char*>& ptrs);
}
```
Issue separado o sub-task de 0107g segun apetito.
## Notas
- El audit `audit_data_table_usage_go_infra` ya existe (FRESH 7d). Se referencia desde `fn doctor modules` (0107a) para mostrar drift en CI/dashboard.
@@ -0,0 +1,356 @@
---
id: "0108"
title: "App tables_qa — testbed agresivo del modulo data_table + deprecar tables_playground"
status: in-progress
type: app
domain:
- cpp-stack
- apps-infra
- meta
scope: app
priority: alta
depends:
- "0107"
blocks: []
related:
- "0081"
- "0097"
created: 2026-05-17
updated: 2026-05-17
tags: [data-table, modules, testbed, qa, regression, version-selector, apphub]
---
# 0108 — tables_playground como testbed agresivo del modulo data_table
## Problema
`apps/primitives_gallery/playground/tables/` hoy es el playground original de tablas — fue el origen de `modules/data_table`. Pero esta DESACOPLADO del modulo: linkea sus PROPIAS copias de `data_table.cpp`, `data_table_logic.cpp`, `tql.cpp`, `tql_to_sql.cpp`, `lua_engine.cpp`, `llm_anthropic.cpp`, `viz.cpp` (versiones legacy). El `self_test.cpp` (430 checks) prueba la logica legacy, no el modulo.
Consecuencias:
- Bug en el modulo no se detecta en el playground.
- Cualquier mejora del modulo (post-0107) NO se valida contra el playground hasta que algun app lo encuentre en runtime.
- El playground es deuda — codigo duplicado que nadie mantiene a la par.
Ademas, las apps consumidoras (`app_gestion`, `data_factory`, `registry_dashboard`, `services_monitor`, etc.) usan el modulo con patrones repetidos pero no documentados como "casos de uso canonicos":
- `CellRenderer::CategoricalChip` con `chips` map (status, kind, enabled).
- `CellRenderer::ColorScale` con `range_min`/`range_max`/`range_alpha` (duracion en ms).
- `CellRenderer::Badge` con `badges` map (version pinning, status, env).
- `CellRenderer::Duration` con `duration_warn_ms` / `duration_error_ms`.
- `CellRenderer::Button` con `button_action` → consumido en `events_out` (`TableEventKind::ButtonClick`).
- `TableEventKind::RowDoubleClick` → abrir detalle / drilldown.
- Joins entre `TableInput[0]` (main) y `TableInput[i]` (joinables) con `JoinStrategy`.
- Color rules condicionales (numericas + categoricas) — declaradas en `State.stages[k].color_rules`.
NO HAY un sitio donde un agente o humano pueda ver TODOS estos patrones funcionando lado-a-lado para verificar visualmente que el modulo se comporta bien. El primitives_gallery hace eso para componentes basicos, pero no para tablas avanzadas.
## Decision (actualizada 2026-05-17)
Crear **app NUEVA `tables_qa`** (no evolucionar `tables_playground` in-place). Razon: playground tiene 8 .cpp legacy + self_test 2921 LOC mezclado. Mas limpio crear desde 0 con `init_cpp_app` + `uses_modules: [data_table_cpp]` desde el principio.
`tables_playground` se **deprecara** tras `tables_qa` verde:
- `apps/primitives_gallery/playground/tables/` → mover a `apps/primitives_gallery/playground/tables_legacy_archive/` (gitignored o conservado por historia).
- Entry en `cpp/CMakeLists.txt` que registra `tables_playground` se elimina.
- `tables_playground_self_test` deja de buildear.
### Identidad de la app
- `name`: `tables_qa`
- `description`: "Testbed agresivo del modulo data_table — multi-tabla, menu QA toggleable, inyector de eventos, counters live, version selector + downgrade side-by-side."
- `icon.phosphor`: `"test-tube"` (Phosphor — claro QA, sin ambiguedad)
- `icon.accent`: `"#f59e0b"` (amber — testing zone, distinto de los colores existentes de otras apps)
- `dir_path`: `apps/tables_qa`
- `repo_url`: `https://gitea.organic-machine.com/dataforge/tables_qa`
- `tags`: `[imgui, dashboard, qa, testing, data-table, regression]`
### Registro en App Hub
Tras build verde:
1. `./fn run generate_app_icon "test-tube" "#f59e0b" "apps/tables_qa/appicon.ico"` — genera .ico multi-res.
2. Anadir trio obligatorio (`description` + `icon.phosphor` + `icon.accent`) al `app.md` (ya cubierto en frontmatter del scaffolder).
3. `./fn run refresh_app_hub` — regenera icons PNG + manifest TSV del hub + reinicia `app_hub_launcher` si esta corriendo.
4. Verificar tarjeta visible en hub con icono amber + descripcion.
## Decision (original — refundar a aplicacion completa)
Refundar `tables_playground` como **testbed agresivo del modulo `fn_module_data_table`**:
1. **Migrar al modulo**: el playground PASA a depender de `fn_module_data_table` static lib. Eliminar las copias locales (`data_table.cpp`, `data_table_logic.cpp`, `tql.cpp`, `lua_engine.cpp`, `viz.cpp`, `tql_to_sql.cpp`, `llm_anthropic.cpp`). El playground se convierte en cliente del modulo igual que las apps reales.
2. **Multi-tabla**: 6-8 tablas demo cubriendo cada `CellRenderer` + cada `TableEventKind` + joins + color rules. Cada una con su `State` persistente. Tab navegable (`ImGui::BeginTabBar`).
3. **Menu de acciones replicando apps**: barra superior con toggles que activan/desactivan features tipicas usadas por apps:
- "Buttons en celdas (Cancel/Retry/Inspect)" → emite ButtonClick.
- "Color condicional numerico (ColorScale en duraciones)".
- "Color condicional categorico (CategoricalChip por status)".
- "Badge version pinning".
- "Duration con thresholds (warn=1000ms / error=5000ms)".
- "Dots sparkline (status timeline)".
- "Icon map (TI_CHECK/TI_X/TI_CIRCLE)".
- "Join Left/Inner/Right/Full entre tablas".
- "Row double-click → modal de detalle".
- "Row right-click → context menu".
- "TQL pipeline (filter/breakout/agg/sort) con chips".
- "Ask AI panel (TQL natural language)".
- "Save/Load TQL desde disco".
- "Export CSV".
- "Drill-down entre stages".
4. **Version selector + downgrade**:
- Mostrar version actual del modulo en header (`fn::framework_version()` / `data_table::module_version()` — anadir API).
- Selector de "modo compatibilidad" para simular comportamiento de versiones anteriores (`v1.4`, `v1.3`, etc.). Implementacion: feature flags por version dentro del modulo o branch-by-config al construir `TableInput`. Util para auditar regresiones cuando bumpemos versiones.
- Side-by-side: renderizar la misma `TableInput` con la version actual a la izquierda y con el modo compatibilidad a la derecha. Diff visual inmediato.
5. **Capture & compare visual**: integrar con el sistema `primitives_gallery --capture` existente (golden images) para que cada commit corra screenshots del testbed y compare contra master. Si pixel diff > threshold → CI rojo.
6. **Self-test del modulo**: nuevo `tables_playground_module_test.cpp` que ejerza la API publica `data_table::render()` con casos sinteticos. Headless (sin GLFW window) usando `imgui_test_engine` (si FN_BUILD_TESTS=ON). Cubre:
- Cada `CellRenderer` enum se renderiza sin crash.
- `events_out` recibe el evento correcto al simular click.
- State se mantiene entre frames.
- TQL pipeline produce output esperado.
- Joins respetan strategy.
## Arquitectura
### Estructura final del playground
```
apps/primitives_gallery/playground/tables/
CMakeLists.txt # linka fn_module_data_table; ELIMINA sources legacy
main.cpp # entry: fn::run_app + render_playground()
playground.cpp # render_playground(): tab bar + acciones menu
tables/ # NEW — 1 archivo por tabla demo
tbl_basic.cpp # Text + numerico, base case
tbl_renderers.cpp # 1 columna por cada CellRenderer (visual review)
tbl_buttons.cpp # Cancel/Retry/Inspect — emite ButtonClick
tbl_color_rules.cpp # ColorScale numerico + CategoricalChip
tbl_durations.cpp # Duration renderer + thresholds
tbl_dots.cpp # Dots sparkline (status timelines)
tbl_joins.cpp # 2 tablas + Left/Inner/Right/Full
tbl_tql.cpp # Pipeline TQL completo + Ask AI
tbl_drill.cpp # Drill-down stages
version_compat.cpp # version selector + side-by-side downgrade
test_module.cpp # NEW: self_test contra API publica del modulo
e2e_run.sh # actualizar: build + test_module + capture
(ELIMINAR: data_table.cpp, data_table_logic.cpp, tql.cpp, tql_to_sql.cpp,
lua_engine.cpp, llm_anthropic.cpp, viz.cpp, tql_duckdb.cpp, self_test.cpp)
README.md # NEW: documenta cada tabla demo + checklist e2e
```
### API publica del modulo expuesta al playground (documentar)
```cpp
// Entry function
namespace data_table {
void render(const char* id,
const std::vector<TableInput>& tables,
State& state,
std::vector<TableEvent>* events_out = nullptr,
bool show_chrome = true);
}
// Tipos publicos consumidos por las apps
struct TableInput {
std::string name;
std::vector<std::string> headers;
std::vector<ColumnType> types;
const char* const* cells; // row-major, rows*cols
int rows;
int cols;
std::vector<ColumnSpec> column_specs; // renderer config per col
};
struct ColumnSpec {
std::string id;
CellRenderer renderer = CellRenderer::Text;
// Badge / CategoricalChip / Dots
std::vector<BadgeRule> badges;
std::vector<ChipRule> chips;
// Progress
bool progress_scale_100 = false;
std::string progress_color_hex;
// Duration
float duration_warn_ms = 1000.0f;
float duration_error_ms = 5000.0f;
// Icon
std::vector<IconMapEntry> icon_map;
// Button
std::string button_action;
std::string button_label;
std::string button_color_hex;
// ColorScale
double range_min = 0.0;
double range_max = 1.0;
float range_alpha = 0.25f;
std::vector<ColorStop> color_stops;
// Tooltip
std::string tooltip;
bool tooltip_on_hover = false;
};
enum class CellRenderer : uint8_t {
Text=0, Badge=1, Progress=2, Duration=3, Icon=4, Button=5,
Dots=8, CategoricalChip=9, ColorScale=10,
};
enum class TableEventKind : uint8_t {
ButtonClick=1, RowDoubleClick=2, RowRightClick=3, CellEdit=4,
};
struct TableEvent {
TableEventKind kind;
int row;
int col;
std::string column_id;
std::string action_id; // ColumnSpec::button_action on ButtonClick
std::string value;
};
struct State {
// (opaca al consumidor — gestionada internamente; debe persistir entre frames)
};
// Module metadata (NEW en este issue)
namespace data_table {
const char* module_version(); // ej. "2.0.0"
const char* module_description();
}
```
Esta especificacion va a `docs/MODULES_API.md` (issue 0107f) como el contrato canonico de `data_table_cpp`.
### Patron de uso canonico (apps)
```cpp
static data_table::State st;
data_table::TableInput tbl;
tbl.name = "runs";
tbl.headers = {"id", "status", "duration_ms", "started_at"};
tbl.types = {ColumnType::String, ColumnType::String, ColumnType::Float, ColumnType::Date};
tbl.cells = &cells_flat[0];
tbl.rows = N;
tbl.cols = 4;
// Configure renderers
tbl.column_specs.resize(tbl.cols);
for (int i = 0; i < tbl.cols; i++) tbl.column_specs[i].id = tbl.headers[i];
tbl.column_specs[1].renderer = data_table::CellRenderer::CategoricalChip;
tbl.column_specs[1].chips = { {"ok", "#22c55e"}, {"error", "#ef4444"}, {"running", "#f59e0b"} };
tbl.column_specs[2].renderer = data_table::CellRenderer::ColorScale;
tbl.column_specs[2].range_min = 0.0;
tbl.column_specs[2].range_max = 5000.0;
tbl.column_specs[2].range_alpha = 0.30f;
// Button column for retry action
data_table::ColumnSpec retry_col;
retry_col.id = "actions";
retry_col.renderer = data_table::CellRenderer::Button;
retry_col.button_action = "retry_run";
retry_col.button_label = "Retry";
tbl.headers.push_back("actions");
tbl.column_specs.push_back(retry_col);
// Render + consume events
std::vector<data_table::TableEvent> events;
data_table::render("##runs_tbl", {tbl}, st, &events);
for (auto& ev : events) {
if (ev.kind == data_table::TableEventKind::ButtonClick && ev.action_id == "retry_run") {
// app handler
}
if (ev.kind == data_table::TableEventKind::RowDoubleClick) {
// open detail modal
}
}
```
## Tareas
- [ ] **1.1** Investigar TODOS los patrones de uso del modulo en apps (script audit):
```bash
grep -rhnE "data_table::(render|TableInput|State|ColumnSpec|TableEvent|CellRenderer::|TableEventKind::|JoinStrategy)" \
apps/*/main.cpp apps/*/*.cpp projects/*/apps/*/*.cpp 2>/dev/null | sort -u > /tmp/data_table_api_usage.txt
```
Producir tabla de patrones canonicos en `docs/MODULES_API.md`.
- [ ] **2.1** Backup `apps/primitives_gallery/playground/tables/{data_table.cpp,data_table_logic.cpp,tql.cpp,tql_to_sql.cpp,lua_engine.cpp,llm_anthropic.cpp,viz.cpp,tql_duckdb.cpp,self_test.cpp}` a `apps/primitives_gallery/playground/tables/legacy_archive/` (gitignored o conservado por historia).
- [ ] **2.2** Editar `apps/primitives_gallery/playground/tables/CMakeLists.txt`:
- Quitar sources legacy.
- `target_link_libraries(tables_playground PRIVATE fn_module_data_table)`.
- El target `tables_playground_self_test` se renombra a `tables_playground_module_test` con sources nuevos.
- [ ] **3.1** Crear `playground.cpp` con tab bar + menu acciones.
- [ ] **3.2** Crear 9 archivos `tables/tbl_*.cpp` con cada caso demo.
- [ ] **3.3** Crear `version_compat.cpp` con selector + side-by-side.
- [ ] **3.4** Anadir API `data_table::module_version()` + `module_description()` a `modules/data_table/data_table.h`. Implementacion lee `version_generated.h` (post 0107c bump a 2.0.0).
- [ ] **4.1** Crear `test_module.cpp` headless con `imgui_test_engine` (gated por `FN_BUILD_TESTS=ON`):
- 1 test por `CellRenderer` enum.
- 1 test por `TableEventKind`.
- 1 test de joins.
- 1 test de TQL pipeline.
- Compara cells output via golden TSV.
- [ ] **4.2** Migrar lo aplicable del `self_test.cpp` legacy (430 checks) a tests del modulo. Lo que prueba logica pura ya extraida al registry (parse_number, compare, tql parsing, lua) va a `cpp/functions/core/*_test.cpp`. Lo que prueba render() va a `test_module.cpp`.
- [ ] **5.1** Actualizar `e2e_run.sh`: build + module_test + capture screenshot.
- [ ] **5.2** Integrar con `primitives_gallery --capture` (golden images de cada tab del testbed).
- [ ] **5.3** README.md con checklist de QA por tab.
- [ ] **6.1** Verificar que las apps consumidoras (7) siguen compilando y comportandose igual.
- [ ] **6.2** `fn index` para que el playground actualizado quede registrado (aunque sea playground, su `app.md` puede declarar `uses_modules: [data_table_cpp]` con min_version 2.0.0).
## Decisiones de diseño
1. **Eliminar sources legacy**: opcion alternativa era mantenerlos como golden-reference. Decision: eliminarlos. Razon: si quedan vivos, la tentacion de "arreglar el playground" sin tocar el modulo permanece. Forzar a usar el modulo cierra el loop.
2. **Side-by-side downgrade**: opcion alternativa era branchear el modulo en `data_table_v1` static lib paralelo. Decision: feature flags por version dentro del modulo actual (`v_compat_mode`). Menos duplicacion, mas pragmatico.
3. **`module_version()` como API publica**: alternativa era leer `version_generated.h` desde la app. Decision: API publica + estable. Las apps que quieran mostrar la version del modulo en su About panel lo hacen via la funcion, no via include privado.
## Prerequisitos
- Issue 0107 (estandarizacion modulos) cerrado y `modules-v2` activo. **Bloqueo duro**: sin 0107, el sistema sigue con drift y refactorizar el playground encima de codigo inestable es desperdicio.
## Riesgos
- **Headless test con imgui_test_engine**: requiere `FN_BUILD_TESTS=ON`. Si no esta disponible en CI, los tests no corren. Mitigacion: documentar requirement en CI config + fallback a tests de logica pura sin UI.
- **Version compat mode** es facil de hacer mal: el codigo del modulo se llena de `if (v_compat < X)`. Mitigacion: limite estricto — solo se mantiene compat para las 2 versiones previas (`v_current - 1`, `v_current - 2`). Mas alla, se elimina y se documenta breaking change.
- **Capture and compare** depende de fonts/DPI/driver GL → false positives en CI. Mitigacion: capture solo en Linux x86_64 con driver mesa fijado.
## Infra de testing reutilizable (existente en el repo)
El testbed se construye combinando estos mecanismos ya disponibles. Ningun nuevo motor.
| Mecanismo | Ubicacion | Que aporta al testbed |
|---|---|---|
| Catch2 unit | `cpp/tests/` (50+ tests, macro `add_fn_test`) | Tests de logica pura de sub-funciones del registry usadas por el modulo (`compute_column_stats`, `tql_emit`, `lua_engine`, `join_tables`, `tql_to_sql`). Ya existen — los tomamos como dado. |
| Golden PNG diff | `cpp/tests/golden/` (43 PNGs) + `png_diff.cpp` | Cada tab del testbed exporta golden via `primitives_gallery --capture`. Diff en CI bloquea drift visual. |
| Auto-driving worker | `apps/altsnap_jitter_test/main.cpp` (modelo de referencia) | Pattern: worker thread fakea eventos + counters monotonicos en `fn::internal::*` + `set_force_alt_for_test()` bypass. Replicar para `data_table`: counters `fn::internal::data_table::{button_click_count, row_double_click_count, color_rule_applied_count, ...}` + worker que inyecta clicks. |
| Dear ImGui Test Engine | `cpp/framework/app_base.h:205-225` (`fn::run_app_test`, gated por `FN_BUILD_TESTS=ON`) | UI-level tests: `IM_REGISTER_TEST` lambdas que llaman `ctx->ItemClick`, `ctx->ItemDoubleClick`, verifican events emitidos. **CERO consumidores actuales** — el testbed sera el primer cliente. |
| `--self-test` flag | `.claude/rules/e2e_validation.md` (patron canonico) | `tables_playground --self-test` corre toda la suite headless, exit 0/1. Compatible con CI sin display. |
| Cross-platform e2e | `bash/functions/infra/e2e_run_cpp_windows.sh` | Funcion bash: cross-compile mingw + deploy Desktop + taskkill + run native + capturar stdout/exit. Linux CI valida Windows. |
### Panel de control QA agresivo (UI del testbed)
Barra superior con:
1. **Toggles por feature** (15+ checkboxes). Cada toggle muta `ColumnSpec` o `State` en runtime, mismo patron que apps reales hacen en codigo de setup. El usuario QA puede activar/desactivar y ver cambio visual inmediato sin recompilar.
2. **Inyector de eventos**: 4 botones — "Simulate ButtonClick", "Simulate RowDoubleClick", "Simulate RowRightClick", "Simulate Drill". Cada uno llama el worker thread pattern de altsnap → verifica que `TableEvent` apropiado se emite.
3. **Counters live**: contador por evento, contador por renderer aplicado, latency p50/p95 por frame (medir desde `ImGui::GetIO().Framerate`). Visible siempre, util para detectar regresiones de performance.
4. **Version selector**: dropdown con versiones del modulo (`2.0.0` actual, `1.4.0` compat, `1.3.0` compat...). Cambio re-renderiza la misma data side-by-side: actual vs version seleccionada. Diff visual inmediato + diff de events emitidos.
5. **Boton "Run --self-test"**: ejecuta toda la suite headless dentro de la misma ventana. Output en log panel. Util para iteracion rapida sin relanzar.
6. **Boton "Export golden"**: corre `--capture` sobre cada tab, escribe PNGs a `golden/data_table_testbed/`. Para regenerar baseline tras cambio visual intencional.
### Counters internos a anadir al modulo
Issue 0108 anade en `modules/data_table/data_table.cpp`:
```cpp
namespace data_table::internal {
int button_click_count();
int row_double_click_count();
int row_right_click_count();
int color_rule_applied_count();
int tql_stages_executed();
int last_render_duration_us();
void reset_counters();
}
```
Mismo modelo que `fn::internal::*` para altsnap. Test-only observability, cost cero en prod (counters atomicos triviales).
## Notas
- Issue 0108 NO empieza hasta 0107 cerrar. Bloqueado duro.
- Se referencia desde `docs/MODULES_API.md` (0107f) como el ejemplo canonico de "como usar el modulo data_table".
- Cuando 0108 cierre, abrir issue 0109 paralelo para `chat_ia` (que era el siguiente modulo planeado al inicio de 0107).
@@ -0,0 +1,60 @@
---
id: "0109a"
title: "skill_tree app shell + parsers issues/flows"
status: completado
type: feature
domain:
- meta
- cpp-stack
scope: app-scoped
priority: media
depends: []
blocks:
- "0109b"
related:
- "0109"
created: 2026-05-17
updated: 2026-05-17
tags:
- skill-tree
- cpp
- imgui
- parsers
---
# 0109a — skill_tree shell + parsers
Primer slice del epic 0109. Foco: app C++ scaffoldada, compilando, leyendo los 79 issues + 7 flows y reportando conteos en stdout. Sin render del grafo todavia — solo plumbing.
## Tareas
1. Scaffolder `./fn run init_cpp_app skill_tree --domain tools --desc "..." --tags "dashboard,meta"`.
2. Editar `app.md` generado: trio icon (`tree-structure`, `#c026d3`), `e2e_checks`, `uses_functions` iniciales.
3. Generar `appicon.ico` via `generate_app_icon_py_infra`.
4. Crear funcion `parse_md_frontmatter_cpp_core` (delegar a fn-constructor):
- Input: `std::string content` (texto del .md completo).
- Output: `MdFrontmatter` struct con `std::unordered_map<std::string, YamlValue>` y `std::string body`.
- `YamlValue` = variant `{string, list<string>, null}`. Subset YAML suficiente para issues actuales.
- Pure. Test golden: parsea los 79 issues + 7 flows sin error.
5. En `main.cpp` (scaffold inicial): al arrancar, scan `dev/issues/*.md` + `dev/flows/*.md`, parse cada uno, contar por status/domain. Log a stdout:
```
skill_tree v0.1.0
issues: 79 (28 pendiente, 3 in-progress, 72 completado, ...)
flows: 7 (5 pending, 2 completed)
parse errors: 0
```
6. `e2e_checks` build + self-test warning.
7. Compilar + deploy Windows via `redeploy_cpp_app_windows`.
8. Refrescar hub via `refresh_app_hub`.
## DoD
- [ ] App existe en `apps/skill_tree/` con `.git/` apuntando a `dataforge/skill_tree`.
- [ ] `app.md` con trio completo + `e2e_checks` + `uses_functions` declarados.
- [ ] `appicon.ico` generado.
- [ ] `fn index` exitoso, `mcp__registry__fn_show id="skill_tree"` muestra metadata.
- [ ] `parse_md_frontmatter_cpp_core` indexado en registry.
- [ ] `cmake --build cpp/build --target skill_tree` exitoso.
- [ ] `./skill_tree` (Linux) o `skill_tree.exe` (Windows) imprime conteos correctos.
- [ ] Tarjeta visible en `app_hub_launcher`.
- [ ] `fn doctor cpp-apps` limpio.
@@ -0,0 +1,69 @@
---
id: "0109b"
title: "skill_tree layout anillos + render canvas ImDrawList con cards"
status: completado
type: feature
domain:
- meta
- cpp-stack
scope: app-scoped
priority: media
depends:
- "0109a"
blocks:
- "0109c"
related:
- "0109"
created: 2026-05-17
updated: 2026-05-17
tags:
- skill-tree
- cpp
- imgui
- layout
- canvas
---
# 0109b — skill_tree layout anillos + render canvas
Segundo slice del epic 0109. Reemplaza la lista textual del Tree por un canvas interactivo basado en `ImDrawList`. Pivote desde `graph_renderer_cpp_viz` (GPU) → `ImDrawList` (CPU) para mantener simplicidad: 166 nodos no justifican el pipeline GPU.
## Decisiones tomadas durante la implementacion
- **Stack: `ImGui::ImDrawList`**, NO `graph_renderer_cpp_viz`. Razon: 166 nodos cabian de sobra en CPU; `graph_renderer` exige `init_gl_loader=true`, build de `GraphData` con tipos OSINT, shaders, FBO + texture flip-Y. Diferencia ~120 LOC + un monton de rebuilds para cero beneficio observable.
- **Sin fisicas** (el usuario lo pidio explicito). Layout deterministico via `compute_ring_layout_cpp_core`.
- **5 rings**: done (0), in-progress (1), unlocked (2), locked (3), deferred/bloqueado (4).
- **18 sectores radiales** = 18 dominios canonicos (`dev/TAXONOMY.md`). Labels en el aro exterior.
- **Lock derivation**: `pendiente` se subdivide en `pendiente_unlocked` (todos los `depends[]` en done) vs `pendiente_locked` (algun depends sin completar). Set de `done` IDs se computa al cargar y se cruza con cada `depends[]`.
- **Animacion lerp 1s** entre prev y current position cuando un nodo cambia de `status_eff` entre dos `reload_scan()`s. Ease-in-out cuadratica.
- **Cards con texto**: cada nodo muestra su ID en blanco con sombra negra para legibilidad sobre cualquier color de ring.
- **Diferencial visual flows vs issues**: issues = circulos, flows = rombos.
- **Pan**: drag con boton derecho o medio.
- **Zoom**: rueda del raton, centrado en cursor (re-anchora coordenadas mundo bajo el puntero).
- **Picking**: O(N) radius check (166 nodos = trivial; spatial hash innecesario).
## Tareas
1. Crear funcion del registry `compute_ring_layout_cpp_core` (pure, 10 tests Catch2, FNV-1a determinista para sub-jitter angular).
2. Reescribir `main.cpp::draw_tree()` como canvas con `ImGui::InvisibleButton` + `ImDrawList`.
3. Implementar `derive_status_eff()` para lock/unlock.
4. Implementar `apply_layout()` con preservacion de prev_x/prev_y para animacion.
5. Render: aristas curvas Bezier (depends + related) + nodos con outline + label ID + tooltip on hover.
6. HUD strip con LV/XP/contadores.
7. Self-test 0-exit cuando `parse_errors == 0 && unmapped == 0`.
8. Build Linux + deploy Windows.
## DoD
- [x] `compute_ring_layout_cpp_core` indexada (10/10 tests, 142 assertions).
- [x] `apps/skill_tree/main.cpp` usa `parse_md_frontmatter_cpp_core` + `compute_ring_layout_cpp_core`.
- [x] `app.md uses_functions` actualizado con ambas.
- [x] Self-test imprime breakdown por ring: `done=77 in-progress=2 unlocked=64 locked=22 deferred=1 unmapped=0`.
- [x] Linux build OK.
- [x] Windows deploy OK (PID corriendo).
- [x] Tarjeta visible en `app_hub_launcher`.
- [x] `fn doctor cpp-apps` limpio.
## Sigue
0109c: Inspector evolucionado con DoD parseado de la seccion `## DoD` del .md (checkboxes interactivos read-only) + lista de `uses_functions` del registry para esa issue.
+704
View File
@@ -0,0 +1,704 @@
# Modules API — contrato publico de modulos C++
Contrato canonico de cada modulo C++ del registry. Una app DEBE poder integrar un modulo leyendo SOLO esta pagina, sin abrir el `.cpp`. Si una app inventa un patron distinto al documentado aqui, es bug — abrir issue o anadir la capacidad al modulo, NO improvisar.
Issue 0107 (modules-standardization). Mantenido en sync con `modules/*/module.md::version` por el slash command `/version`.
---
## Indice
1. [framework_cpp](#framework_cpp) — shell ImGui obligatorio
2. [data_table_cpp](#data_table_cpp) — tabla TQL completa
3. [Capability matrix consolidada](#capability-matrix-consolidada)
4. [Ciclo de vida de un modulo](#ciclo-de-vida-de-un-modulo)
5. [Como NO conectarse a un modulo (anti-patrones)](#como-no-conectarse-a-un-modulo)
---
## framework_cpp
**Static lib target:** `fn_framework`
**Header path:** `cpp/framework/app_base.h`
**Namespace:** `fn` + `fn_ui`
**Entry function:** `int fn::run_app(AppConfig cfg, std::function<void()> render_fn)`
**State:** no requerido (singleton interno via `fn::run_app` lifecycle)
**Linkage en apps:** transitivo via macro `add_imgui_app(<target> ...)` — NO se declara en `uses_modules`.
**Version actual:** `1.1.0`
### Opt-in
Apps cpp del registry obtienen `fn_framework` automaticamente via `add_imgui_app(<target> ...)`. NO listar miembros del framework en `uses_functions` ni en `uses_modules`.
### Ejemplo minimo
```cpp
#include "framework/app_base.h"
int main() {
fn::AppConfig cfg;
cfg.title = "My App";
cfg.width = 1280;
cfg.height = 720;
cfg.about = { "my_app", "1.0.0", "Lo que hace mi app." };
cfg.log = { "my_app.log", 1 }; // file relativo a local_files/, level Info
return fn::run_app(cfg, []() {
ImGui::Begin("Main");
ImGui::Text("hello");
ImGui::End();
});
}
```
### API completa
| Campo `AppConfig` | Tipo | Default | Para que |
|---|---|---|---|
| `title` | `const char*` | `"fn_registry"` | Titulo de la ventana |
| `width` / `height` | `int` | 1280 / 720 | Tamano inicial |
| `vsync` | `bool` | `true` | VSync GL |
| `viewports` | `bool` | `true` | Multi-viewport (paneles dragged-out fuera del main) |
| `theme` | `ThemeMode` | `FnDark` | `FnDark` / `ImGuiDark` / `ImGuiLight` / `None` |
| `bg_r/g/b` | `float` | `fn_tokens::colors::bg` | Color background main |
| `about` | `AppAboutInfo` | `{}` | `name`, `version`, `description` para About window |
| `panels` + `panel_count` | `PanelToggle*` | `nullptr` | Toggles del menubar (paneles abre/cierra) |
| `layouts_cb` | `LayoutCallbacks*` | `nullptr` | Override del menu Layouts (default: auto-storage SQLite) |
| `auto_layouts` | `bool` | `true` | Si crear `LayoutStorage` por defecto |
| `auto_layouts_db` | `const char*` | `"layouts.db"` | Archivo SQLite relativo a `local_files/` |
| `view_extras` | `std::function<bool()>` | `{}` | Items extra en menu View |
| `init_gl_loader` | `bool` | `false` | Solo si la app llama `gl*` directo |
| `auto_dockspace` | `bool` | `true` | DockSpaceOverViewport antes de `render_fn` cada frame |
| `log` | `AppLogConfig` | `{}` | `file_path`, `level` 0-3 |
| `pre_frame` | `std::function<void()>` | `{}` | Hook antes del menubar (LayoutStorage custom apply) |
### Capacidades automaticas (zero opt-in)
- Window dark titlebar (Windows DWMWA_USE_IMMERSIVE_DARK_MODE).
- AltSnap-safe sizemove + Alt+RMB resize + Alt+LMB move (via Win32 WndProc subclass per HWND).
- App icon attach a HWND main + viewports secundarios (si `<app_dir>/appicon.ico` existe).
- Layouts persistentes (save/load/list/delete/reset) con restore-on-open + save-on-close.
- About / Settings / Logs windows + menubar.
- `imgui.ini` + `app_settings.ini` bajo `<exe_dir>/local_files/`.
- Migration desde cwd/exe_dir a `local_files/` para instalaciones viejas.
### Helpers publicos
```cpp
const char* fn::exe_dir(); // dir del exe
const char* fn::local_dir(); // <exe_dir>/local_files/
const char* fn::local_path(const char*); // <local_dir>/<name>
const char* fn::asset_dir(); // <exe_dir>/assets/
const char* fn::asset_path(const char*); // <asset_dir>/<name>
void fn::migrate_to_local_files(const char* const* names, size_t n);
const char* fn::framework_version();
const char* fn::framework_description();
```
### Gotchas
- `Restart=always` en systemd unit (NO `on-failure`) — `SIGTERM` limpio = exit success.
- App write-paths SIEMPRE via `fn::local_path("X")`. NO `fopen("X.db", ...)` con path relativo.
- `init_gl_loader = true` SOLO si la app llama GL directo (`gl_loader_init`). ImGui/ImPlot puro NO lo necesita.
- Anti-jitter pausa render durante `WM_ENTERSIZEMOVE` — apps con telemetria live deben tolerar pausa transitoria.
---
## data_table_cpp
**Static lib target:** `fn_module_data_table`
**Header path:** `data_table/data_table.h`
**Namespace:** `data_table`
**Entry function:** `void data_table::render(const char* id, const std::vector<TableInput>& tables, State& state, std::vector<TableEvent>* events_out, bool show_chrome)`
**State:** `data_table::State` opaco. Debe persistir entre frames (`static` per panel, NO stack-local).
**Linkage en apps:** explicito.
**Version actual:** `1.4.0` (sera `2.0.0` post 0107c).
### Opt-in en una app
1. `app.md` frontmatter:
```yaml
uses_modules:
- name: data_table_cpp
min_version: "1.4" # o "2.0" post 0107c
```
2. `CMakeLists.txt`:
```cmake
target_link_libraries(<app> PRIVATE fn_module_data_table)
```
3. Header en el `.cpp`:
```cpp
#include "data_table/data_table.h"
#include "core/data_table_types.h"
```
4. Reservar `data_table::State` persistente entre frames. NO ponerla en stack.
### Tipos publicos
```cpp
struct TableInput {
std::string name; // identificador estable (joins)
std::vector<std::string> headers; // names de columna
std::vector<ColumnType> types; // String/Int/Float/Bool/Date/Json/Auto
const char* const* cells; // row-major, cells[r*cols + c]
int rows;
int cols;
std::vector<ColumnSpec> column_specs; // renderer config per col
};
enum class ColumnType : uint8_t {
Auto = 0, String, Int, Float, Bool, Date, Json,
};
struct ColumnSpec {
std::string id; // estable; usado en TQL
CellRenderer renderer = CellRenderer::Text;
// Renderer-specific (ver tabla mas abajo)
std::vector<BadgeRule> badges;
std::vector<ChipRule> chips;
std::vector<IconMapEntry> icon_map;
bool progress_scale_100 = false;
std::string progress_color_hex;
float duration_warn_ms = 1000.0f;
float duration_error_ms = 5000.0f;
std::string button_action;
std::string button_label;
std::string button_color_hex;
char dots_separator = ',';
float dots_glyph_size = 0.0f;
int dots_max = 0;
bool dots_show_count = false;
double range_min = 0.0;
double range_max = 1.0;
float range_alpha = 0.25f;
std::vector<ColorStop> color_stops;
std::string tooltip; // "auto" -> show cell value
bool tooltip_on_hover = false;
};
enum class CellRenderer : uint8_t {
Text=0, Badge=1, Progress=2, Duration=3, Icon=4, Button=5,
Dots=8, CategoricalChip=9, ColorScale=10,
};
struct BadgeRule { std::string value, color_hex, label; };
struct ChipRule { std::string match, color; };
struct IconMapEntry { std::string value, icon_name, color_hex; };
struct ColorStop { float position; std::string color; };
enum class TableEventKind : uint8_t {
ButtonClick=1, RowDoubleClick=2, RowRightClick=3, CellEdit=4,
};
struct TableEvent {
TableEventKind kind;
int row; // index en TableInput, NO en stage output
int col;
std::string column_id; // ColumnSpec.id de la columna clicada
std::string action_id; // para ButtonClick: ColumnSpec.button_action
std::string value; // valor de la celda
};
enum class JoinStrategy : uint8_t { Left, Inner, Right, Full };
```
### Capacidad 1: render basico (Text)
Caso minimo — todas las columnas como texto plano.
```cpp
static data_table::State st;
data_table::TableInput tbl;
tbl.name = "items";
tbl.headers = {"id", "name", "price"};
tbl.types = {data_table::ColumnType::String,
data_table::ColumnType::String,
data_table::ColumnType::Float};
tbl.cells = &cells_flat[0]; // row-major
tbl.rows = N;
tbl.cols = 3;
data_table::render("##items_tbl", {tbl}, st);
```
NO `column_specs` necesario — `CellRenderer::Text` es default. Tabla aparece con sort, filter chips, freeze cols, todo gratis.
### Capacidad 2: Badge — colored badge per value
Para columnas con valores categoricos (status, version, env).
```cpp
tbl.column_specs.resize(tbl.cols);
for (int i = 0; i < tbl.cols; i++) tbl.column_specs[i].id = tbl.headers[i];
data_table::ColumnSpec& cs = tbl.column_specs[1]; // col "status"
cs.renderer = data_table::CellRenderer::Badge;
cs.badges = {
data_table::BadgeRule{"ok", "#22c55e", ""}, // verde
data_table::BadgeRule{"error", "#ef4444", "ERR"}, // rojo + label custom
data_table::BadgeRule{"running", "#f59e0b", ""}, // amber
};
```
- Match exacto, case-sensitive.
- `label` vacio = usa el valor de la celda como texto.
- Si ningun rule matchea, se renderiza como Text (sin badge).
### Capacidad 3: CategoricalChip — dot + texto
Igual que Badge pero solo dot de color a la izquierda del texto. Menos visual que Badge.
```cpp
cs.renderer = data_table::CellRenderer::CategoricalChip;
cs.chips = {
{"go", "#3b82f6"},
{"py", "#22c55e"},
{"cpp", "#a855f7"},
};
```
Pattern canonico apps:
- `app_gestion`: `status_col` + `enabled_col`.
- `data_factory`: kind columns + status columns.
- `registry_dashboard`: language column.
### Capacidad 4: ColorScale — gradient en background de celda
Numerico continuo. Mapea `range_min..range_max` a gradient (default green→amber→red).
```cpp
cs.renderer = data_table::CellRenderer::ColorScale;
cs.range_min = 0.0;
cs.range_max = 5000.0; // ms
cs.range_alpha = 0.30f; // bajo para legibilidad
// Opcional: custom stops
cs.color_stops = {
{0.0f, "#22c55e"},
{0.5f, "#f59e0b"},
{1.0f, "#ef4444"},
};
```
Pattern canonico: `duration_ms`, `latency_ms`, `bytes_transferred`. Apps: `data_factory` (node_runs), `services_monitor` (health latency).
### Capacidad 5: Duration — milisegundos con thresholds
Especifico para tiempos. Renderiza valor formateado (`123ms`, `1.2s`) + color por threshold.
```cpp
cs.renderer = data_table::CellRenderer::Duration;
cs.duration_warn_ms = 1000.0f;
cs.duration_error_ms = 5000.0f;
```
< warn = verde, warn..error = amber, > error = rojo.
### Capacidad 6: Progress — barra de progreso
```cpp
cs.renderer = data_table::CellRenderer::Progress;
cs.progress_scale_100 = false; // true -> valor en 0..100, false -> 0..1
cs.progress_color_hex = "#3b82f6"; // override; "" -> ImPlot auto
```
### Capacidad 7: Icon — Tabler icon lookup
```cpp
cs.renderer = data_table::CellRenderer::Icon;
cs.icon_map = {
{"ok", "TI_CHECK", "#22c55e"},
{"error", "TI_X", "#ef4444"},
{"pending", "TI_CIRCLE", "#a3a3a3"},
};
```
Iconos disponibles: `cpp/functions/core/icons_tabler.h` (Tabler v3.41, 1500+ glyphs).
### Capacidad 8: Button — clickable + event sink
Genera click events que la app procesa.
```cpp
cs.renderer = data_table::CellRenderer::Button;
cs.button_action = "retry_run"; // semantic id que la app reconoce
cs.button_label = "Retry"; // "" -> usa valor de la celda como label
cs.button_color_hex = "#3b82f6"; // "" -> color default
```
Consumir events:
```cpp
std::vector<data_table::TableEvent> events;
data_table::render("##runs", {tbl}, st, &events);
for (const auto& ev : events) {
if (ev.kind == data_table::TableEventKind::ButtonClick) {
if (ev.action_id == "retry_run") {
retry_job(ev.value); // ev.value = celda donde se clico
}
else if (ev.action_id == "cancel_job") {
cancel_job(ev.value);
}
}
}
```
`events_out` puede ser `nullptr` si no quieres consumir eventos (back-compat).
### Capacidad 9: Dots — inline status sparkline
Celda contiene tokens separados (`"ok,error,ok,ok"`). Renderiza dots de color por token.
```cpp
cs.renderer = data_table::CellRenderer::Dots;
cs.badges = { // reutiliza BadgeRule.color_hex (NO label)
{"ok", "#22c55e", ""},
{"error", "#ef4444", ""},
};
cs.dots_separator = ',';
cs.dots_glyph_size = 0.0f; // 0 = default font size
cs.dots_max = 10; // hard limit; 0 = unlimited
cs.dots_show_count = true; // append " (N)" tras los dots
```
Pattern canonico: timeline de status de runs recientes.
### Capacidad 10: Tooltip por celda
```cpp
cs.tooltip = "auto"; // muestra el valor de la celda en hover
// o:
cs.tooltip = "Click to retry";
cs.tooltip_on_hover = true;
```
### Capacidad 11: RowDoubleClick — drill / open detail
Sin setup especial. Solo consumir el event.
```cpp
for (const auto& ev : events) {
if (ev.kind == data_table::TableEventKind::RowDoubleClick) {
open_detail_modal(ev.row);
}
}
```
`ev.row` es el indice en `TableInput.cells`, NO en el output del stage activo si hay TQL aplicado.
### Capacidad 12: RowRightClick — context menu app-side
Igual que RowDoubleClick. App abre su propio popup ImGui cuando recibe el event.
```cpp
if (ev.kind == data_table::TableEventKind::RowRightClick) {
g_ctx_menu_row = ev.row;
ImGui::OpenPopup("##row_ctx");
}
// En el siguiente frame:
if (ImGui::BeginPopup("##row_ctx")) {
if (ImGui::MenuItem("Inspect")) inspect(g_ctx_menu_row);
if (ImGui::MenuItem("Delete")) delete_row(g_ctx_menu_row);
ImGui::EndPopup();
}
```
### Capacidad 13: Joins entre tablas
Pasar multiples `TableInput`. El usuario configura join via UI de chips (drag table → drop). El primer `tables[0]` es main, los demas son joinables.
```cpp
data_table::TableInput main_tbl, lookup_tbl;
// ... fill ambas ...
data_table::render("##joined", {main_tbl, lookup_tbl}, st, &events);
```
Strategies: `JoinStrategy::{Left, Inner, Right, Full}`. Default Left.
### Capacidad 14: TQL pipeline (filter / breakout / agg / sort)
Pipeline declarativo construido por usuario via chips bar. Estado en `State.stages[]`. App no toca esto — el modulo lo gestiona.
Stages adicionales (`State.stages.resize(N)`) si la app quiere PRE-cargar un pipeline:
```cpp
data_table::Stage s;
s.filters.push_back({0, data_table::Op::Eq, "go"}); // col 0 == "go"
s.breakouts.push_back("lang"); // group by lang
s.aggregations.push_back({data_table::AggFn::Count}); // count(*)
st.stages.push_back(s);
st.active_stage = (int)st.stages.size() - 1;
```
Agg fns: `Count`, `Sum`, `Avg`, `Min`, `Max`, `Median`, `P95`, `Distinct`.
### Capacidad 15: Ask AI panel
Usuario abre via boton "Ask AI" en chips bar. Modal con prompt natural language → llama `llm_anthropic::ask` (si `FN_LLM_ANTHROPIC` definido) → devuelve TQL o SQL ejecutable → Apply mutates `State.stages`.
App NO necesita setup. Solo `ANTHROPIC_API_KEY` en env.
### Capacidad 16: Color rules condicionales
Reglas de color que el usuario configura via header menu → "Conditional color". Tres tipos:
- **CellBg**: color de fondo fijo si valor matchea.
- **CategoricalDot**: dot de color izquierdo (igual que `chips` pero configurado en runtime por usuario).
- **NumericRange**: gradient sobre rango numerico (igual que `ColorScale` pero por regla, no por spec).
Persistencia en `State.stages[k].color_rules` — la app puede preset reglas en su setup si quiere, pero normal es dejar al usuario.
### Capacidad 17: Drill-down between stages
Usuario click derecho en celda de stage > 0 → "Drill into N=valor" → anade filter al stage previo, activa ese stage. Stack de back/forward en `State.drill_back/forward`.
App no toca. Solo `data_table::render` lo provee.
### Capacidad 18: Save/Load TQL desde disco
Modal "Save TQL" / "Load TQL" — escribe/lee `<state>.tql` en `local_files/`. App no toca.
### Capacidad 19: Export CSV
Boton "Export CSV" en chips bar. Escribe el output del stage activo a CSV. App no toca.
### Capacidad 20: `show_chrome` toggle
Si `show_chrome=false`, oculta chips bar + breadcrumb. Util para dashboards densos donde la tabla es read-only. Usuario puede reactivar via boton "Show UI".
```cpp
data_table::render("##compact", {tbl}, st, &events, /*show_chrome=*/false);
```
### Capacidad 21: Module version metadata (post 0107c)
```cpp
const char* data_table::module_version(); // "2.0.0"
const char* data_table::module_description();
```
Para que About panel de la app muestre version del modulo, no solo del app.
### Cuando usar `data_table::render` vs `ImGui::BeginTable` directo
| Caso | API correcta | Razon |
|---|---|---|
| **Tabla de datos** con N filas dinamicas, sort/filter/freeze/drill | `data_table::render` | Es lo que el modulo aporta |
| **KPI grid** (4-8 cards en grid de columnas) | `ImGui::BeginTable` directo | No es tabla de datos — layout helper |
| **Form key/value** (inspector, settings, schema metadata) | `ImGui::BeginTable` directo | Form fijo, columnas estaticas |
| **2-col layout** (tree izda / detail dcha) | `ImGui::BeginTable` directo | Splitter pattern, no data table |
| **Chart grid** (4 plots lado a lado) | `ImGui::BeginTable` directo | Layout multi-panel, no datos |
| **Schema editor row-form** (1 fila por field, con inputs editables por columna) | discutible — `ImGui::BeginTable` si rows < 20, `data_table::render` con `CellRenderer::Button`/edit si rows >> 20 | Depende del scale |
| **Recipes / Flows / Issues list** (data dinamica con sort/filter) | `data_table::render` (migrar si esta inline) | Es tabla de datos canonica |
Regla heuristica: si la tabla necesita SORT, FILTER, FREEZE o RENDERERS DECLARATIVOS → usar `data_table::render`. Si es solo layout → BeginTable directo OK.
Audit automatico: `mcp__registry__fn_run audit_data_table_usage` detecta apps con `inline_begintable` warnings + verifica `state_not_persistent`, `no_child_host`, `no_event_sink`, `cmake_missing_link`. Output formato `dev/data_table_integration_audit.md`.
### Host container — BeginChild recommended
`data_table::render` NO crea su propio `BeginChild` para el contenido principal (si para sub-paneles de viz). Si la app no envuelve la llamada en un host con bounds definidos:
- La tabla se renderiza inline con `ImGui::GetContentRegionAvail()` actual.
- Si el contenedor padre es scrollable, la tabla NO scrollea independientemente — comparte el scroll del padre.
- Si hay widgets DESPUES del `render()` en el mismo Begin/BeginChild, compiten por espacio.
Pattern recomendado:
```cpp
ImGui::BeginChild("##tbl_host", ImVec2(-1, -1)); // o tamano fijo
data_table::render("##my_tbl", {tbl}, st, &events);
ImGui::EndChild();
```
Excepcion: si la tabla es lo UNICO que renderiza dentro de su `ImGui::Begin(...)` window, BeginChild es opcional. El audit lo marca como `no_child_host [warn]`, no error.
### Gotchas
- **`State` debe persistir entre frames**: `static` per panel. Si la pones en stack, pierdes filter chips/sort/etc cada frame. Cada panel necesita SU `State` (no compartir entre tablas distintas).
- **`cells` row-major**: `cells[row*cols + col]`. Apps que pasan column-major rompen sin warning.
- **`TableInput.name` no vacio si hay joins**: si declaras varias tablas, cada una con `name` unico para que el join UI las identifique.
- **`column_specs.size() == cols`**: si no resizeas, el modulo asume `CellRenderer::Text` para columnas faltantes.
- **`ColumnSpec.id` debe ser estable** entre frames. Si cambia, las preferencias de sort/filter del usuario sobre esa col se pierden.
- **`button_action` debe ser unico** dentro del scope semantic de la app. Apps mismas que reusan `"retry"` en multiples columnas/contextos deberian diferenciar (`"retry_run"`, `"retry_test"`).
- **`badges` vs `chips`**: Badge = fondo coloreado completo + label; CategoricalChip = solo dot + texto plano. Eligir segun densidad visual deseada.
- **`Dots` reusa `badges`** map pero IGNORA `BadgeRule.label`. Solo `color_hex` por token.
- **`ColorScale` requiere ColumnType numerico** (`Float`, `Int`, `Date`). String no calcula gradient.
- **`tooltip = "auto"`** = string magico literal. NO `tooltip.empty()`.
- **events_out se LIMPIA cada frame** — la app debe leerlo INMEDIATAMENTE tras `render()`. No acumular entre frames.
- **`events.row` NO indexa al output del stage** — siempre apunta a `TableInput.cells` original. Si tu app hizo TQL pipeline, mapea manualmente via `state.row_mapping` si necesitas el row del output.
- **Multi-instancia**: cada `data_table::render` debe usar `id` unico (`"##tbl_a"`, `"##tbl_b"`). Si dos paneles comparten id, ImGui se confunde.
### Pattern canonico end-to-end (replicable)
```cpp
// Setup BadgeRule sets reusables a nivel de app
static std::vector<data_table::BadgeRule> run_status_badges() {
return {
{"ok", "#22c55e", ""},
{"error", "#ef4444", "ERR"},
{"running", "#f59e0b", ""},
{"pending", "#a3a3a3", ""},
};
}
// Inside panel render
static data_table::State st;
data_table::TableInput tbl;
tbl.name = "runs";
tbl.headers = {"id", "status", "duration_ms", "started_at", ""};
tbl.types = {
data_table::ColumnType::String,
data_table::ColumnType::String,
data_table::ColumnType::Float,
data_table::ColumnType::Date,
data_table::ColumnType::String, // actions col (vacio, solo button)
};
tbl.cells = &flat_cells[0];
tbl.rows = N;
tbl.cols = 5;
tbl.column_specs.resize(tbl.cols);
for (int i = 0; i < tbl.cols; i++) tbl.column_specs[i].id = tbl.headers[i].empty()
? std::string("actions") : tbl.headers[i];
// Status → CategoricalChip
tbl.column_specs[1].renderer = data_table::CellRenderer::CategoricalChip;
tbl.column_specs[1].chips = {
{"ok", "#22c55e"}, {"error", "#ef4444"}, {"running", "#f59e0b"},
};
// duration_ms → ColorScale + Duration thresholds combinados
tbl.column_specs[2].renderer = data_table::CellRenderer::Duration;
tbl.column_specs[2].duration_warn_ms = 1000.0f;
tbl.column_specs[2].duration_error_ms = 5000.0f;
// Actions col → Button retry
tbl.column_specs[4].renderer = data_table::CellRenderer::Button;
tbl.column_specs[4].button_action = "retry_run";
tbl.column_specs[4].button_label = "Retry";
tbl.column_specs[4].tooltip = "Re-run this job";
tbl.column_specs[4].tooltip_on_hover = true;
// Render
std::vector<data_table::TableEvent> events;
data_table::render("##runs_tbl", {tbl}, st, &events);
// Consume events
for (const auto& ev : events) {
if (ev.kind == data_table::TableEventKind::ButtonClick && ev.action_id == "retry_run") {
retry_run_by_id(ev.value); // ev.value = id (col 0 de la fila clicada... NO)
// ^ CORRECCION: ev.value = valor de la celda del boton
// = "Retry" si button_label fijo. Para obtener el ID
// de la fila: mapear via ev.row y leer tbl.cells[ev.row*cols + 0]
}
if (ev.kind == data_table::TableEventKind::RowDoubleClick) {
open_run_detail_modal(/*run_id=*/ tbl.cells[ev.row * tbl.cols + 0]);
}
}
```
---
## Capability matrix consolidada
| Capacidad | Renderer / Event | ColumnSpec fields | Apps que la usan hoy |
|---|---|---|---|
| Plain text | `Text` (default) | — | todas |
| Colored badge | `Badge` | `badges` | registry_dashboard, app_gestion (version pinning) |
| Categorical dot | `CategoricalChip` | `chips` | services_monitor, dag_engine_ui, data_factory |
| Numeric heatmap | `ColorScale` | `range_min/max/alpha`, `color_stops` | data_factory (duration), services_monitor (latency) |
| Duration with thresholds | `Duration` | `duration_warn_ms`, `duration_error_ms` | data_factory (node_runs) |
| Progress bar | `Progress` | `progress_scale_100`, `progress_color_hex` | (raro — candidato para nuevos consumers) |
| Tabler icon | `Icon` | `icon_map` | (raro — candidato) |
| Click button | `Button` + ButtonClick | `button_action`, `button_label`, `button_color_hex` | graph_explorer (cancel/delete_job), data_factory |
| Status timeline | `Dots` | `dots_separator`, `dots_max`, `badges` map | registry_dashboard (recent runs) |
| Hover tooltip | (cualquier renderer) | `tooltip`, `tooltip_on_hover` | varios |
| Open detail | RowDoubleClick | — | app_gestion, data_factory, services_monitor |
| Context menu | RowRightClick | — | (raro) |
| Joins | (UI-driven) | usar varios `TableInput` | (raro — UI feature) |
| TQL pipeline | (UI-driven) | preset via `State.stages` | data_factory (preload filters) |
| Ask AI | (UI-driven) | `FN_LLM_ANTHROPIC` env | usuario lo dispara |
| Color rules runtime | (UI-driven) | `State.stages[k].color_rules` | usuario lo configura |
| Drill-down | (UI-driven) | `State.drill_back/forward` | usuario lo dispara |
| Save/Load TQL | (UI-driven) | — | usuario lo dispara |
| Export CSV | (UI-driven) | — | usuario lo dispara |
| Hide chrome | `show_chrome=false` | parametro de render() | dashboards densos |
---
## Como NO conectarse a un modulo
Anti-patrones que generan "conexiones raras entre apps":
| Anti-patron | Consecuencia | Sustituir por |
|---|---|---|
| Llamar funciones internas del modulo (helpers `static` en `data_table.cpp`) via include de path interno | Acoplamiento fuerte; rompe en cualquier refactor | API publica `data_table::*` solo |
| Reescribir logica que el modulo ya hace (parser TQL propio, color picker propio) | Duplicacion + drift | Usar API; si falta capacidad, abrir issue |
| Acceder a `State` fields directamente sin pasar por la API | State es opaco — campos pueden cambiar versiones | Usar la UI (chips bar) o setear `State.stages` segun esta doc |
| Inventar nuevos `action_id` cross-app esperando que otro modulo lo entienda | events son scoped a la app que los emite | Cada app maneja sus propios action_ids |
| Asumir `events_out` acumula entre frames | Se limpia cada frame | Procesar inmediatamente despues de `render()` |
| Pasar `State` por valor / hacer copia | Pierde sort/filter/drill cada frame | `static data_table::State st;` referencia |
| Compartir `State` entre dos `render()` con distintos `id` | UI dual-rendering corrupta | UN State per panel |
| Reuso de `column_specs` con `id` que cambia entre frames | Sort/filter preferences perdidas | `id` estable como `TableInput.headers[i]` |
| Saltarse `column_specs.resize(cols)` y solo settear algunas | Modulo asume `Text` para faltantes — comportamiento confuso | resize SIEMPRE a `cols` |
| Pasar TableInput de UNA tabla con `name=""` y esperar joins | Joins necesitan name unico | Setear `name` siempre |
| Mezclar Badge + CategoricalChip + Dots en misma columna (no compatible) | Solo uno por columna | Eligir basado en densidad visual |
| Acoplar app a header interno (`modules/data_table/data_table_internal.h`) | header NO PUBLICO — solo para .cpp del modulo | API publica `data_table/data_table.h` solo |
---
## Ciclo de vida de un modulo
Crear modulo nuevo (ej. `chat_ia_cpp` tras 0107 cerrar):
1. `mkdir modules/chat_ia/` con:
- `module.md` (frontmatter: name, version, description, members, dir_path).
- `CMakeLists.txt` definiendo `add_library(fn_module_chat_ia STATIC ...)`.
- `chat_ia.cpp` + `chat_ia.h` (entry function).
- `chat_ia_internal.h` (compartido entre sub-cpp del modulo si tiene >1 archivo).
2. Anadir `add_subdirectory` en `cpp/CMakeLists.txt`.
3. Crear seccion en este doc (`docs/MODULES_API.md`) siguiendo el template de `data_table_cpp`.
4. Anadir fila en `modules/README.md`.
5. `fn index` registra el modulo en `registry.db::modules`.
6. Cada bump de version: `/version modules/chat_ia <major|minor|patch> "<reason>"`. Edita `module.md::version` + `## Capability growth log`.
7. Apps consumidoras declaran:
```yaml
uses_modules:
- name: chat_ia_cpp
min_version: "0.1"
```
+ `target_link_libraries(<app> PRIVATE fn_module_chat_ia)`.
8. `fn doctor modules` audita drift uses_modules vs uses_functions (0107a).
---
## E2E testing por modulo
Cada modulo declara su test en `modules/<name>/e2e_run.sh` (o equivalente). Ver issue 0108 para el testbed agresivo de `data_table_cpp` que combina:
- Catch2 unit (`cpp/tests/`) para sub-funciones puras.
- Golden PNG diff (`primitives_gallery --capture`).
- Auto-driving worker (modelo `altsnap_jitter_test`).
- Dear ImGui Test Engine (`FN_BUILD_TESTS=ON`).
- `--self-test` flag headless.
- Cross-platform run (`e2e_run_cpp_windows.sh`).
Modulos sin testbed dedicado → al menos build smoke + linkage de >=2 apps consumidoras debe verificarse en `e2e_run.sh`.
---
## Mantenimiento de este doc
- Actualizar tras cada bump de version de un modulo (referenciado por `/version`).
- Si una app introduce un patron nuevo no listado en capability matrix → anadirlo aqui ANTES de commit (o la fila queda undocumentada).
- `fn doctor modules` (0107a) verifica que cada modulo en `registry.db::modules` tiene seccion aqui. Si no, WARN.
- Generado parcialmente desde `module.md` via codegen futuro (issue postponed). Hoy mantenido a mano.
+1
View File
@@ -37,6 +37,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output | | [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output |
| [validator](validator.md) | 6 | Funciones que verifican datos/config contra reglas. Pre-flight de sinks y gates en DAGs | | [validator](validator.md) | 6 | Funciones que verifican datos/config contra reglas. Pre-flight de sinks y gates en DAGs |
| [navegator](navegator.md) | 4 | Automatización de browser via CDP + AX tree + LLM: obtener, limpiar, chunkear AX tree y llamar a Claude CLI | | [navegator](navegator.md) | 4 | Automatización de browser via CDP + AX tree + LLM: obtener, limpiar, chunkear AX tree y llamar a Claude CLI |
| [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers |
## Como anadir grupo ## Como anadir grupo
+52
View File
@@ -0,0 +1,52 @@
# cpp-dashboard-viz
Primitivas de dashboards/observability en C++ ImGui: cards de KPI, sparklines, line/bar/scatter/pie/heatmap plots, panel containers. Estilo y altura consistente con `fn_tokens` + ImPlot. Pensadas para grids densos y monitores tipo `services_monitor` / `process_explorer` / `registry_dashboard`.
## Funciones del grupo
| ID | Firma corta | Que hace |
|---|---|---|
| `kpi_card_cpp_viz` | `kpi_card(label,value,delta,history,...,[y_min,y_max],[fmt],[icon])` | Card con valor grande + delta + sparkline. Overload v1.4 con bounds fijos. |
| `sparkline_cpp_viz` | `sparkline(id,values,count,[color],[y_min,y_max],w,h)` | Mini line chart inline (sin ImPlot) para tablas/cards. v1.1 con Y fijo. |
| `line_plot_cpp_viz` | `line_plot(title,xs,ys,count,[y_min,y_max],height)` | Line plot ImPlot con ejes pineados. v1.2 con Y fijo. |
| `bar_chart_cpp_viz` | `bar_chart(title,labels,values,count,...)` | Barras verticales con tooltip y rotacion automatica. |
| `scatter_plot_cpp_viz` | `scatter_plot(title,xs,ys,count,height)` | Scatter 2D ImPlot. |
| `pie_chart_cpp_viz` | `pie_chart(title,labels,values,count,height)` | Pie/donut con tooltip por slice. |
| `heatmap_cpp_viz` | `heatmap(title,data,rows,cols,...)` | Mapa de calor 2D. |
| `histogram_cpp_viz` | `histogram(title,values,count,bins,height)` | Histograma con bins automaticos. |
| `dashboard_panel_cpp_core` | `dashboard_panel_begin/end(title,...)` | Contenedor estilizado con borde y padding. |
| `plot_theme_cpp_core` | gestion de temas | Presets dark/light/high-contrast para ImPlot. |
## Ejemplo canonico — grid de KPIs con escala absoluta
```cpp
#include "viz/kpi_card.h"
#include "viz/line_plot.h"
#include "core/icons_tabler.h"
// CPU%, RAM% comparten dominio 0-100 -> Y fijo permite comparar visualmente
// entre cards y entre hosts en el mismo grid.
ImGui::Columns(2, "kpis", false);
kpi_card("CPU", cpu_pct, 0.0f, cpu_history, n, 0.0f, 100.0f, "%.1f%%", TI_CPU);
ImGui::NextColumn();
kpi_card("RAM", ram_pct, 0.0f, ram_history, n, 0.0f, 100.0f, "%.1f%%", TI_DATABASE);
ImGui::Columns(1);
// Grafico grande tambien fijo:
float xs[N], ys[N]; /* ... */
line_plot("CPU %", xs, ys, N, 0.0f, 100.0f, 160.0f);
```
## Fronteras
- **NO incluye `data_table_*`** — tablas TQL viven en `cpp-tables` y `data-table-renderers`.
- **NO incluye 3D** (`scatter_3d`, `surface_plot_3d`, `contour`, `mc_*_gpu`) — exploraciones cientificas, no dashboards.
- **NO incluye `graph_renderer` / `graph_icons`** — GPU graph layout es otro dominio.
- Apps que componen estas primitivas (`services_monitor`, `process_explorer`, `registry_dashboard`) NO entran aqui — son artefactos, no funciones.
## Notas
- Todas las primitivas son `pure` (no I/O, no estado mutable salvo ImGui frame state) salvo `kpi_card` y `sparkline` que escriben al draw list del frame.
- Altura del plot SIEMPRE explicita — evita feedback loops con contenedores `AutoResizeY`.
- Colores: usar `fn_tokens::colors::{primary,success,error,text_muted,...}` para coherencia con `@fn_library`.
- Y fijo (overloads v1.1+/v1.2+/v1.4+) es la forma correcta para metricas con dominio conocido: hosts en grid comparables sin truco visual.
+16
View File
@@ -0,0 +1,16 @@
# 2026-05-17
## 17:46 — issue 0105 + 0106 — bloque service: estandarizado + services_api Go + services_monitor C++ ImGui desplegado Windows
- Hecho: bloque `service:` (port, health_endpoint, systemd_unit, systemd_scope, restart_policy, runtime, pc_targets, is_local_only) anadido al frontmatter de las 11 apps con `tag: service`. Schema canonico en `.claude/rules/function_tags.md`.
- Hecho: migration `registry/migrations/014_service_metadata.sql` (8 cols en `apps` + tabla `service_targets`). `App.Service *ServiceSpec` + parser `rawService` + `InsertApp`/`scanApps`/`Purge`/`PurgeLocalOnly` cubriendo persistencia y limpieza. `db.GetServicePCTargets(appID)` API publica.
- Hecho: funcion `audit_services_spec_go_infra` + subcomando `fn doctor services-spec` (tabwriter + `--json`). Salida actual: 11/11 OK.
- Hecho: app `services_api` (`apps/services_api/`, Go) en `127.0.0.1:8485`. Loop paralelo 15s (max 8, timeout 20s/probe), local vs SSH segun selfPC (`~/.fn_pc`=`home-wsl`). Tablas `service_state` + `service_transition`. Endpoints `/api/health`, `/api/services`, `POST /api/check`, `/api/pcs`. systemd `~/.config/systemd/user/services_api.service` `Restart=always`.
- Hecho: app `services_monitor` (`apps/services_monitor/`, C++ ImGui). Scaffolded via `init_cpp_app_bash_pipelines`. Tabla 9-col agrupada por app, color por overall (`ok|degraded|down|no-route`), auto-refresh 5s, boton Force-check. JSON via `vendor/nlohmann/json.hpp` + HTTP socket TCP `http_client.{cpp,h}` (ambos copiados de data_factory). Build linux + windows (mingw + ws2_32). Deployado a `C:\Users\lucas\Desktop\apps\services_monitor\` via `redeploy_cpp_app_windows_bash_pipelines` y arrancado (PID=12864).
- Hecho: fix regresion `sqlite_api.service` muerta 20h sin alerta. Raiz: `Restart=on-failure` no reinicia tras `SIGTERM` (exit success). Fix: `Restart=always`. Mismo patron al unit nuevo `services_api.service`.
- Hecho: `sqlite_api/app.md` `health_endpoint` corregido `/api/status` (404) → `/api/databases` (200). Detectado por el propio services_api en el primer ciclo.
- Hecho: memoria persistente `feedback_ask_stack_first` (preguntar SIEMPRE stack antes de empezar app) + `project_services_monitor` (snapshot de la arquitectura issue 0105+0106).
- Hecho: issues `0105` + `0106` creados (`dev/issues/`), filas en `dev/issues/README.md`. `.claude/CLAUDE.md` y `.claude/rules/function_tags.md` actualizados con el bloque `service:` + nuevo subcomando.
- Pendiente: anadir `aurgi-pc` a `~/.ssh/config` para que sus 6 targets dejen `no-route` y reporten estado real.
- Pendiente: arrancar services locales muertos (`kanban`, `deploy_server`, `call_monitor`, `registry_mcp`) y verificar `element_matrix_chat` (endpoint health puede ser otro).
- Pendiente: pipeline `regenerate_app_icons` falla en SyntaxError al pasar args al heredoc — generar icon de `services_monitor` queda bloqueado hasta arreglar el wrapper.
+336
View File
@@ -0,0 +1,336 @@
package infra
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// AppDriftEntry describes an app and its module drift state relative to the registry.
type AppDriftEntry struct {
AppName string `json:"app_name"`
AppID string `json:"app_id"`
DirPath string `json:"dir_path"`
BuildKind string `json:"build_kind"` // "windows-deployed" | "windows-build" | "linux-build" | ""
BinaryPath string `json:"binary_path"` // path inspected (or modules_generated.cpp)
LinkedModules map[string]string `json:"linked_modules"` // module name -> embedded version
RegistryModules map[string]string `json:"registry_modules"` // module name -> registry version
Stale []string `json:"stale"` // modules with drift (linked != registry)
Status string `json:"status"` // "ok" | "drift" | "no-build"
}
// AuditAppDrift scans all C++ apps in the registry, reads the module versions
// embedded in their compiled artifacts, and compares them against the versions
// declared in modules/*/module.md.
//
// Binary inspection priority (first found wins):
// 1. windows-deployed: <windowsDeployRoot>/<name>/<name>.exe — PE binary search
// 2. windows-build: cpp/build/windows/apps/<name>/<name>_modules_generated.cpp
// 3. linux-build: cpp/build/linux/apps/<name>/<name>_modules_generated.cpp
//
// If windowsDeployRoot is empty, only cpp/build paths are checked.
// registryRoot is the absolute path to the fn_registry root.
func AuditAppDrift(registryRoot string, windowsDeployRoot string) ([]AppDriftEntry, error) {
// 1. Read registry module versions from modules/*/module.md
registryMods, err := readRegistryModules(registryRoot)
if err != nil {
return nil, fmt.Errorf("audit_app_drift: read registry modules: %w", err)
}
// 2. Find all C++ app.md files.
appDirs, err := findAppDirs(registryRoot)
if err != nil {
return nil, fmt.Errorf("audit_app_drift: find app dirs: %w", err)
}
var result []AppDriftEntry
for _, dir := range appDirs {
appMDPath := filepath.Join(dir, "app.md")
meta, err := readCppAppMeta(appMDPath)
if err != nil || meta.Lang != "cpp" {
continue
}
entry := AppDriftEntry{
AppName: meta.Name,
AppID: meta.ID,
DirPath: meta.DirPath,
LinkedModules: map[string]string{},
RegistryModules: registryMods,
}
// 3. Find artifact and extract linked module versions.
kind, artifactPath, linked := resolveLinkedVersions(
meta.Name, registryRoot, windowsDeployRoot,
)
entry.BuildKind = kind
entry.BinaryPath = artifactPath
entry.LinkedModules = linked
if kind == "" || linked == nil {
entry.Status = "no-build"
result = append(result, entry)
continue
}
// 4. Compute drift: only for modules that are both linked AND in registry.
var stale []string
for modName, linkedVer := range linked {
regVer, inRegistry := registryMods[modName]
if !inRegistry {
continue
}
if linkedVer != regVer {
stale = append(stale, modName)
}
}
sort.Strings(stale)
entry.Stale = stale
if len(stale) > 0 {
entry.Status = "drift"
} else {
entry.Status = "ok"
}
result = append(result, entry)
}
sort.Slice(result, func(i, j int) bool { return result[i].AppID < result[j].AppID })
return result, nil
}
// readRegistryModules reads modules/*/module.md and returns name -> version map.
// Uses stdlib only: reads files line by line, extracts name/version with regex.
func readRegistryModules(root string) (map[string]string, error) {
modulesDir := filepath.Join(root, "modules")
entries, err := os.ReadDir(modulesDir)
if err != nil {
if os.IsNotExist(err) {
return map[string]string{}, nil
}
return nil, err
}
out := map[string]string{}
for _, e := range entries {
if !e.IsDir() {
continue
}
mdPath := filepath.Join(modulesDir, e.Name(), "module.md")
name, version, err := parseModuleMD(mdPath)
if err != nil || name == "" || version == "" {
continue
}
out[name] = version
}
return out, nil
}
var (
moduleNameRE = regexp.MustCompile(`(?m)^name:\s*(.+?)\s*$`)
moduleVersionRE = regexp.MustCompile(`(?m)^version:\s*(.+?)\s*$`)
)
// parseModuleMD extracts name and version from a module.md frontmatter using
// simple line-by-line regex (no external YAML deps).
func parseModuleMD(path string) (name, version string, err error) {
data, err := os.ReadFile(path)
if err != nil {
return "", "", err
}
// Restrict to frontmatter only (between --- markers).
fm := extractFrontmatter(string(data))
if m := moduleNameRE.FindStringSubmatch(fm); len(m) > 1 {
name = strings.TrimSpace(strings.Trim(m[1], `"`))
}
if m := moduleVersionRE.FindStringSubmatch(fm); len(m) > 1 {
version = strings.TrimSpace(strings.Trim(m[1], `"`))
}
return name, version, nil
}
// extractFrontmatter returns the YAML block between the first --- pair, or the
// full content if no markers found.
func extractFrontmatter(content string) string {
if !strings.HasPrefix(content, "---") {
return content
}
rest := content[3:]
// Skip optional newline after opening ---
if strings.HasPrefix(rest, "\n") {
rest = rest[1:]
}
end := strings.Index(rest, "\n---")
if end < 0 {
return rest
}
return rest[:end]
}
// cppAppMeta holds the fields we need from an app.md for C++ apps.
type cppAppMeta struct {
Name string
ID string
Lang string
Domain string
DirPath string
}
var (
appLangRE = regexp.MustCompile(`(?m)^lang:\s*(.+?)\s*$`)
appNameRE = regexp.MustCompile(`(?m)^name:\s*(.+?)\s*$`)
appDomainRE = regexp.MustCompile(`(?m)^domain:\s*(.+?)\s*$`)
appDirPathRE = regexp.MustCompile(`(?m)^dir_path:\s*"?(.+?)"?\s*$`)
)
// readCppAppMeta extracts name, lang, domain and dir_path from an app.md.
func readCppAppMeta(path string) (cppAppMeta, error) {
data, err := os.ReadFile(path)
if err != nil {
return cppAppMeta{}, err
}
fm := extractFrontmatter(string(data))
var meta cppAppMeta
if m := appNameRE.FindStringSubmatch(fm); len(m) > 1 {
meta.Name = strings.TrimSpace(strings.Trim(m[1], `"`))
}
if m := appLangRE.FindStringSubmatch(fm); len(m) > 1 {
meta.Lang = strings.TrimSpace(strings.Trim(m[1], `"`))
}
if m := appDomainRE.FindStringSubmatch(fm); len(m) > 1 {
meta.Domain = strings.TrimSpace(strings.Trim(m[1], `"`))
}
if m := appDirPathRE.FindStringSubmatch(fm); len(m) > 1 {
meta.DirPath = strings.TrimSpace(strings.Trim(m[1], `"`))
}
// Derive ID: name_lang_domain
meta.ID = meta.Name
if meta.Lang != "" {
meta.ID += "_" + meta.Lang
}
if meta.Domain != "" {
meta.ID += "_" + meta.Domain
}
return meta, nil
}
// resolveLinkedVersions finds the best artifact for a given app and extracts
// module name->version from it.
//
// Priority: windows-deployed > windows-build > linux-build.
// Returns ("", "", nil) when no artifact is found.
func resolveLinkedVersions(
appName string,
registryRoot string,
windowsDeployRoot string,
) (kind string, artifactPath string, linked map[string]string) {
// 1. Windows deployed exe.
if windowsDeployRoot != "" {
exePath := filepath.Join(windowsDeployRoot, appName, appName+".exe")
if _, err := os.Stat(exePath); err == nil {
if mods := extractModulesFromBinary(exePath); len(mods) > 0 {
return "windows-deployed", exePath, mods
}
}
}
// 2. Windows build modules_generated.cpp.
winGenCPP := filepath.Join(
registryRoot, "cpp", "build", "windows", "apps", appName,
appName+"_modules_generated.cpp",
)
if _, err := os.Stat(winGenCPP); err == nil {
if mods := extractModulesFromGeneratedCPP(winGenCPP); len(mods) > 0 {
return "windows-build", winGenCPP, mods
}
}
// 3. Linux build modules_generated.cpp.
linuxGenCPP := filepath.Join(
registryRoot, "cpp", "build", "linux", "apps", appName,
appName+"_modules_generated.cpp",
)
if _, err := os.Stat(linuxGenCPP); err == nil {
if mods := extractModulesFromGeneratedCPP(linuxGenCPP); len(mods) > 0 {
return "linux-build", linuxGenCPP, mods
}
}
return "", "", nil
}
// binaryModuleRE matches the pattern <module_name>\x00<semver>\x00 as it
// appears in PE (Windows) binaries compiled from the generated ModuleInfo struct.
// Group 1 = module name, group 2 = version string.
var binaryModuleRE = regexp.MustCompile(`([a-z][a-z0-9_]{1,63})\x00(\d+\.\d+\.\d+)\x00`)
// extractModulesFromBinary scans a PE binary for the contiguous pattern
// <module_name>\x00<semver>\x00 that the MSVC/MinGW compiler emits for the
// ModuleInfo struct string literals. Returns nil when no matches are found.
//
// Note: ELF (Linux) binaries may not have this contiguous layout — use
// extractModulesFromGeneratedCPP instead for Linux builds.
func extractModulesFromBinary(path string) map[string]string {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
out := map[string]string{}
for _, m := range binaryModuleRE.FindAllSubmatch(data, -1) {
if len(m) < 3 {
continue
}
name := string(m[1])
ver := string(m[2])
if _, seen := out[name]; !seen {
out[name] = ver
}
}
if len(out) == 0 {
return nil
}
return out
}
// generatedModuleRE matches lines like: { "data_table", "1.5.0", "..." }
var generatedModuleRE = regexp.MustCompile(`\{\s*"([a-z][a-z0-9_]*)"\s*,\s*"(\d+\.\d+\.\d+)"`)
// extractModulesFromGeneratedCPP parses a *_modules_generated.cpp file and
// returns module name -> version. This is the authoritative source for Linux
// builds and a reliable fallback for Windows builds.
func extractModulesFromGeneratedCPP(path string) map[string]string {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
out := map[string]string{}
for _, m := range generatedModuleRE.FindAllSubmatch(data, -1) {
if len(m) < 3 {
continue
}
name := string(m[1])
ver := string(m[2])
if _, seen := out[name]; !seen {
out[name] = ver
}
}
if len(out) == 0 {
return nil
}
return out
}
+86
View File
@@ -0,0 +1,86 @@
---
name: audit_app_drift
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func AuditAppDrift(registryRoot string, windowsDeployRoot string) ([]AppDriftEntry, error)"
description: "Escanea todas las apps C++ del registry, extrae las versiones de modulos embebidas en sus artefactos compilados (exe Windows desplegado, modules_generated.cpp Windows/Linux) y las compara contra la version declarada en modules/*/module.md. Retorna una entrada por app con status ok|drift|no-build y la lista de modulos desactualizados."
tags: [audit, drift, cpp, modules, doctor, registry]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports:
- fmt
- os
- path/filepath
- regexp
- sort
- strings
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/audit_app_drift.go"
params:
- name: registryRoot
desc: "Ruta absoluta a la raiz del fn_registry (ej. /home/lucas/fn_registry). Se buscan modules/*/module.md, apps/*/app.md, projects/*/apps/*/app.md y cpp/build/{linux,windows}/apps/<name>/."
- name: windowsDeployRoot
desc: "Ruta absoluta donde se despliegan los .exe de Windows (ej. /mnt/c/Users/lucas/Desktop/apps). Si vacio, se omite la inspeccion del binario desplegado y solo se usan los artefactos bajo cpp/build."
output: "Slice de AppDriftEntry, uno por app C++ encontrada. Status: 'ok' (todas las versiones coinciden), 'drift' (al menos un modulo tiene version distinta), 'no-build' (no se encontro ningun artefacto compilado). Campo Stale lista los nombres de modulos con drift."
---
# audit_app_drift
Detecta apps C++ cuyos binarios/artefactos enlazan una version de modulo inferior a la que declara el registry.
## Cuando usarla
- Despues de bumpar la version de un modulo en `modules/*/module.md` — para saber que apps necesitan recompilarse.
- Como check en `fn doctor cpp-apps` para detectar drift antes de un deploy.
- En CI tras actualizar `fn_framework` o `fn_module_data_table` — lista apps con `status=drift`.
## Ejemplo
```go
entries, err := infra.AuditAppDrift("/home/lucas/fn_registry", "/mnt/c/Users/lucas/Desktop/apps")
if err != nil {
log.Fatal(err)
}
for _, e := range entries {
switch e.Status {
case "drift":
fmt.Printf("STALE %s: modules %v (build: %s)\n", e.AppName, e.Stale, e.BuildKind)
case "no-build":
fmt.Printf("NOBLD %s: no compiled artifact found\n", e.AppName)
case "ok":
fmt.Printf("OK %s (%s)\n", e.AppName, e.BuildKind)
}
}
```
Salida tipica:
```
STALE app_gestion: modules [data_table] (build: linux-build)
OK chart_demo (windows-deployed)
NOBLD primitives_gallery: no compiled artifact found
```
## Prioridad de artefactos (por app)
1. `<windowsDeployRoot>/<name>/<name>.exe` — PE binario: busca patron `<module>\x00<version>\x00` directo en bytes.
2. `cpp/build/windows/apps/<name>/<name>_modules_generated.cpp` — parsea literales `{ "module", "version", ... }`.
3. `cpp/build/linux/apps/<name>/<name>_modules_generated.cpp` — mismo parser.
Si ninguno existe → `status = "no-build"`, `BuildKind = ""`.
## Gotchas
- **PE vs ELF string layout**: en ELF (Linux) el compilador puede separar las cadenas del struct en distintas regiones del `.rodata`, por lo que la busqueda directa en el binario no es fiable. Por eso se prefiere el `_modules_generated.cpp` (generado por `codegen_app_modules.py` al compilar). El binario PE Windows si tiene el patron contiguo.
- **Sin registry.db**: la funcion lee `module.md` directamente — no necesita la BD. Si el `fn index` no se ha ejecutado tras un bump de version, esta funcion igual detecta el drift porque lee la fuente de verdad (el `.md`).
- **Apps sin `uses_modules`**: si una app C++ no declara `uses_modules` ni tiene `_modules_generated.cpp`, aparece con `status=no-build` aunque compile correctamente. Normal para apps que no usan modulos opcionales.
- **Solo compara modulos del registry**: si el binario enlaza un modulo desconocido (que no tiene `module.md`), se ignora silenciosamente en el calculo de drift.
- **windowsDeployRoot vacio**: si el usuario no tiene montado `/mnt/c/...` (no WSL2), pasar `""` para evitar `os.Stat` lento en rutas inexistentes.
+463
View File
@@ -0,0 +1,463 @@
package infra
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// DataTableUsageEntry reporta el estado de la integracion data_table en una app.
type DataTableUsageEntry struct {
AppName string `json:"app_name"`
AppID string `json:"app_id"`
DirPath string `json:"dir_path"`
DeclaresUse bool `json:"declares_use"` // uses_modules incluye data_table_cpp
Findings []Finding `json:"findings"`
Status string `json:"status"` // "ok" | "warn" | "n/a"
}
// Finding describe un anti-patron detectado en la integracion data_table de una app.
type Finding struct {
Kind string `json:"kind"` // inline_begintable | state_not_persistent | no_child_host | no_event_sink | cmake_missing_link
Severity string `json:"severity"` // "error" | "warn" | "info"
File string `json:"file"`
Line int `json:"line"` // 0 si no aplica
Snippet string `json:"snippet"` // contexto opcional (hasta 120 chars)
}
// regexes pre-compilados para los detectores
var (
// data_table::State declarada en una funcion (sin static ni thread_local).
// Captura lineas con "data_table::State <ident>;" que no tengan static/thread_local antes.
reStateDecl = regexp.MustCompile(`\bdata_table::State\s+\w+\s*;`)
// static o thread_local precediendo data_table::State en la misma linea.
reStaticState = regexp.MustCompile(`(?:static|thread_local)\s+data_table::State`)
// Deteccion heuristica de inicio de funcion (cobertura parcial).
reFuncOpen = regexp.MustCompile(`(?:static\s+)?(?:void|bool|int|ImVec2|auto|std::\w+)\s+\w+\s*\(`)
// ImGui::BeginTable inline (fuera de data_table::render).
reBeginTable = regexp.MustCompile(`ImGui::BeginTable\s*\(`)
// data_table::render call.
reRender = regexp.MustCompile(`data_table::render\s*\(`)
// BeginChild o Begin inmediatamente antes del render (heuristica 30 lineas).
reBeginChildOrBegin = regexp.MustCompile(`ImGui::(?:BeginChild|Begin)\s*\(`)
// Presencia de event sink en el archivo.
reEventSink = regexp.MustCompile(`(?:events_out|TableEvent|TableEventKind)`)
)
// AuditDataTableUsage escanea todas las apps cpp del registry, detecta anti-patrones
// en su uso de data_table::render, y devuelve entries por app.
//
// Detecta:
// - inline_begintable: llamada directa a ImGui::BeginTable sin pasar por data_table::render.
// - state_not_persistent: data_table::State sin static/thread_local (stack local).
// - no_child_host: data_table::render sin BeginChild/Begin en las 30 lineas previas.
// - no_event_sink: usa data_table pero no captura TableEvent/events_out (severity info).
// - cmake_missing_link: uses_modules incluye data_table_cpp pero CMakeLists no enlaza fn_module_data_table.
func AuditDataTableUsage(registryRoot string) ([]DataTableUsageEntry, error) {
candidates, err := findAppDirs(registryRoot)
if err != nil {
return nil, fmt.Errorf("audit_data_table_usage: find app dirs: %w", err)
}
var results []DataTableUsageEntry
for _, dir := range candidates {
appMDPath := filepath.Join(dir, "app.md")
raw, err := os.ReadFile(appMDPath)
if err != nil {
continue
}
fm, err := parseFrontmatterRaw(string(raw))
if err != nil {
continue
}
if fm.Lang != "cpp" {
continue
}
declaresUse := dtSliceContains(fm.UsesModules, "data_table_cpp")
relDir, _ := filepath.Rel(registryRoot, dir)
entry := DataTableUsageEntry{
AppName: fm.Name,
AppID: buildID(fm),
DirPath: relDir,
DeclaresUse: declaresUse,
Status: "n/a",
}
if !declaresUse {
results = append(results, entry)
continue
}
absDir := dir
if !filepath.IsAbs(absDir) {
absDir = filepath.Join(registryRoot, dir)
}
if _, err := os.Stat(absDir); os.IsNotExist(err) {
entry.Status = "warn"
entry.Findings = append(entry.Findings, Finding{
Kind: "directory_missing",
Severity: "warn",
File: relDir,
})
results = append(results, entry)
continue
}
// 1. cmake_missing_link
cmakePath := filepath.Join(absDir, "CMakeLists.txt")
if cmakeFindings := auditCMakeLink(cmakePath, relDir); len(cmakeFindings) > 0 {
entry.Findings = append(entry.Findings, cmakeFindings...)
}
// Collect all .cpp and .h files (1 level + subdirs), excluding vendor/ build/ .git/
sources, err := collectSourceFiles(absDir)
if err != nil {
entry.Status = "warn"
results = append(results, entry)
continue
}
// 25. Per-file analysis
for _, srcPath := range sources {
data, err := os.ReadFile(srcPath)
if err != nil {
continue
}
src := string(data)
relSrc, _ := filepath.Rel(registryRoot, srcPath)
entry.Findings = append(entry.Findings, auditInlineBeginTable(src, relSrc)...)
entry.Findings = append(entry.Findings, auditStateNotPersistent(src, relSrc)...)
entry.Findings = append(entry.Findings, auditNoChildHost(src, relSrc)...)
}
// 4. no_event_sink: check across ALL source files for event sink presence
if noEventSinkFinding := auditNoEventSink(sources, relDir); noEventSinkFinding != nil {
entry.Findings = append(entry.Findings, *noEventSinkFinding)
}
// Derive status
hasError := false
hasWarn := false
for _, f := range entry.Findings {
switch f.Severity {
case "error":
hasError = true
case "warn":
hasWarn = true
}
}
switch {
case hasError || hasWarn:
entry.Status = "warn"
default:
entry.Status = "ok"
}
results = append(results, entry)
}
sort.Slice(results, func(i, j int) bool { return results[i].AppID < results[j].AppID })
return results, nil
}
// auditCMakeLink verifica que CMakeLists.txt enlaza fn_module_data_table.
func auditCMakeLink(cmakePath, relDir string) []Finding {
data, err := os.ReadFile(cmakePath)
if err != nil {
// No CMakeLists at all — report as error since app declares data_table_cpp
return []Finding{{
Kind: "cmake_missing_link",
Severity: "error",
File: relDir,
Snippet: "CMakeLists.txt not found",
}}
}
if !strings.Contains(string(data), "fn_module_data_table") {
return []Finding{{
Kind: "cmake_missing_link",
Severity: "error",
File: strings.TrimSuffix(relDir, "/") + "/CMakeLists.txt",
Snippet: "target_link_libraries missing fn_module_data_table",
}}
}
return nil
}
// auditInlineBeginTable detecta llamadas ImGui::BeginTable que no son parte de
// data_table::render. Heuristica: si el archivo llama ImGui::BeginTable en una
// linea que no es parte de la implementacion del modulo data_table.
func auditInlineBeginTable(src, relFile string) []Finding {
lines := strings.Split(src, "\n")
var findings []Finding
for i, line := range lines {
if !reBeginTable.MatchString(line) {
continue
}
// False-positive suppression: si el archivo ES parte del modulo data_table, ignorar
if strings.Contains(relFile, "data_table") && strings.Contains(relFile, "modules") {
continue
}
findings = append(findings, Finding{
Kind: "inline_begintable",
Severity: "warn",
File: relFile,
Line: i + 1,
Snippet: truncateSnippet(strings.TrimSpace(line), 120),
})
}
return findings
}
// auditStateNotPersistent detecta data_table::State declarada en el body de una
// funcion sin static ni thread_local. Heuristica: busca la linea con la declaracion
// y retrocede hasta encontrar si hay un reFuncOpen antes que un closing brace a
// nivel 0. Si la declaracion no tiene static/thread_local en la misma linea,
// y esta dentro de un bloque de funcion (heuristica de depth de llaves), reporta.
func auditStateNotPersistent(src, relFile string) []Finding {
lines := strings.Split(src, "\n")
var findings []Finding
for i, line := range lines {
if !reStateDecl.MatchString(line) {
continue
}
// Si la misma linea tiene static o thread_local -> OK
if reStaticState.MatchString(line) {
continue
}
// Check context: si pertenece a una struct/class member (no dentro de funcion)
// Heuristica: mirar hacia atras si antes de depth>0 hay un { de funcion
if isInsideFunctionBody(lines, i) {
findings = append(findings, Finding{
Kind: "state_not_persistent",
Severity: "warn",
File: relFile,
Line: i + 1,
Snippet: truncateSnippet(strings.TrimSpace(line), 120),
})
}
}
return findings
}
// isInsideFunctionBody retorna true si la linea en `lineIdx` esta dentro del
// cuerpo de una funcion (heuristica de balance de llaves retrocediendo).
func isInsideFunctionBody(lines []string, lineIdx int) bool {
depth := 0
for i := lineIdx - 1; i >= 0; i-- {
line := lines[i]
for _, ch := range line {
switch ch {
case '}':
depth++
case '{':
depth--
}
}
if depth < 0 {
// Encontramos la apertura del bloque que nos contiene
// Si esa linea parece un function header, es funcion body
return reFuncOpen.MatchString(line)
}
}
return false
}
// auditNoChildHost detecta llamadas a data_table::render sin BeginChild/Begin
// en las 30 lineas previas.
func auditNoChildHost(src, relFile string) []Finding {
lines := strings.Split(src, "\n")
var findings []Finding
for i, line := range lines {
if !reRender.MatchString(line) {
continue
}
// Skip comment lines (// ...) — they contain "data_table::render" as text, not calls.
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") {
continue
}
// Look back up to 30 lines for BeginChild or Begin
start := i - 30
if start < 0 {
start = 0
}
found := false
for j := start; j < i; j++ {
if reBeginChildOrBegin.MatchString(lines[j]) {
found = true
break
}
}
if !found {
findings = append(findings, Finding{
Kind: "no_child_host",
Severity: "warn",
File: relFile,
Line: i + 1,
Snippet: truncateSnippet(strings.TrimSpace(line), 120),
})
}
}
return findings
}
// auditNoEventSink verifica que al menos un archivo fuente use TableEvent/events_out.
// Si ninguno lo hace, retorna un finding de severidad info.
func auditNoEventSink(sources []string, relDir string) *Finding {
for _, srcPath := range sources {
data, err := os.ReadFile(srcPath)
if err != nil {
continue
}
if reEventSink.Match(data) {
return nil
}
}
return &Finding{
Kind: "no_event_sink",
Severity: "info",
File: relDir,
Snippet: "no TableEvent / events_out found in any source file",
}
}
// collectSourceFiles retorna todos los .cpp y .h dentro de absDir,
// excluyendo vendor/, build/, .git/, tests/, test/.
func collectSourceFiles(absDir string) ([]string, error) {
skipDirs := map[string]bool{
"vendor": true,
"build": true,
".git": true,
"tests": true,
"test": true,
}
var files []string
err := filepath.WalkDir(absDir, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return nil
}
if d.IsDir() {
if skipDirs[d.Name()] {
return filepath.SkipDir
}
return nil
}
name := d.Name()
if strings.HasSuffix(name, ".cpp") || strings.HasSuffix(name, ".h") {
files = append(files, path)
}
return nil
})
return files, err
}
// parseFrontmatterRaw extrae los campos minimos de un frontmatter YAML sin
// dependencias externas (regex linea a linea).
type rawFrontmatter struct {
Name string
Lang string
Domain string
UsesModules []string
}
func parseFrontmatterRaw(content string) (rawFrontmatter, error) {
if !strings.HasPrefix(content, "---") {
return rawFrontmatter{}, fmt.Errorf("no frontmatter")
}
rest := content[4:]
end := strings.Index(rest, "\n---")
if end < 0 {
return rawFrontmatter{}, fmt.Errorf("unclosed frontmatter")
}
body := rest[:end]
var fm rawFrontmatter
inModules := false
for _, line := range strings.Split(body, "\n") {
// uses_modules: [data_table_cpp, framework_cpp] (inline array)
if strings.HasPrefix(line, "uses_modules:") {
inModules = true
val := strings.TrimSpace(strings.TrimPrefix(line, "uses_modules:"))
if strings.HasPrefix(val, "[") {
val = strings.Trim(val, "[]")
for _, item := range strings.Split(val, ",") {
item = strings.TrimSpace(item)
if item != "" {
fm.UsesModules = append(fm.UsesModules, item)
}
}
inModules = false
}
continue
}
// Multi-line array entry: " - data_table_cpp"
if inModules {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "-") {
item := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
if item != "" {
fm.UsesModules = append(fm.UsesModules, item)
}
continue
}
// End of array
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
inModules = false
}
}
if strings.HasPrefix(line, "name:") {
fm.Name = strings.TrimSpace(strings.TrimPrefix(line, "name:"))
} else if strings.HasPrefix(line, "lang:") {
fm.Lang = strings.TrimSpace(strings.TrimPrefix(line, "lang:"))
fm.Lang = strings.Trim(fm.Lang, `"'`)
} else if strings.HasPrefix(line, "domain:") {
fm.Domain = strings.TrimSpace(strings.TrimPrefix(line, "domain:"))
fm.Domain = strings.Trim(fm.Domain, `"'`)
}
}
return fm, nil
}
// buildID construye el ID del registry a partir del frontmatter.
func buildID(fm rawFrontmatter) string {
id := fm.Name
if fm.Lang != "" {
id += "_" + fm.Lang
}
if fm.Domain != "" {
id += "_" + fm.Domain
}
return id
}
// dtSliceContains retorna true si s esta en slice.
func dtSliceContains(slice []string, s string) bool {
for _, v := range slice {
if v == s {
return true
}
}
return false
}
// truncateSnippet recorta s a maxLen caracteres para uso en snippets de Finding.
func truncateSnippet(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen]
}
+66
View File
@@ -0,0 +1,66 @@
---
name: audit_data_table_usage
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "AuditDataTableUsage(registryRoot string) ([]DataTableUsageEntry, error)"
description: "Escanea apps C++ del registry que declaran uses_modules: [data_table_cpp] y detecta anti-patrones en su uso de data_table::render: ImGui::BeginTable inline, State stack-local (no persistente), render sin BeginChild host, event sink ignorado, y enlace cmake faltante."
tags: [audit, data_table, cpp, lint, doctor, modules]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/audit_data_table_usage.go"
params:
- name: registryRoot
desc: "Raiz absoluta del repositorio fn_registry. Se escanean apps/*, projects/*/apps/*. Solo procesa apps con lang: cpp y uses_modules incluye data_table_cpp."
output: "Slice de DataTableUsageEntry, uno por app C++ con uses_modules: [data_table_cpp]. Status: ok (sin findings warn/error), warn (hay findings), n/a (no declara data_table_cpp)."
---
## Ejemplo
```go
import "fn-registry/functions/infra"
entries, err := infra.AuditDataTableUsage("/home/lucas/fn_registry")
if err != nil {
panic(err)
}
for _, e := range entries {
if e.Status == "warn" {
fmt.Printf("[%s] %s\n", e.AppID, e.DirPath)
for _, f := range e.Findings {
fmt.Printf(" %s (%s) %s:%d %s\n", f.Kind, f.Severity, f.File, f.Line, f.Snippet)
}
}
}
```
Expuesto via CLI (una vez integrado en `fn doctor`):
```bash
fn doctor data-table # tabla legible
fn doctor data-table --json # JSON para agentes
```
## Cuando usarla
- Tras anadir `data_table_cpp` a un nuevo app, verifica que el patron canonico se sigue.
- Como gate pre-merge cuando se toca código de integración de data_table.
- Periodicamente para detectar apps que usan `ImGui::BeginTable` directamente en lugar de `data_table::render`.
- Para auditar que el event sink (`TableEvent`/`events_out`) se consume donde se necesita interactividad.
## Gotchas
- `isInsideFunctionBody` es heuristica de balance de llaves: falsos positivos posibles con lambdas o structs anonimos. Ruido bajo en codigo real.
- `no_child_host` mira 30 lineas hacia atras del `data_table::render(`. Si el BeginChild esta mas lejos (extraido en helper), se reportara falso positivo. Severidad `warn`, no `error`.
- `inline_begintable` suprime findings cuando el archivo contiene `data_table` y `modules` en su path relativo (evita ruido del modulo mismo).
- Apps cuyo directorio no existe en el PC local (sub-repos no clonados) se marcan `warn: directory_missing` en vez de analizarse. Esperado en entorno multi-PC.
- `cmake_missing_link` usa `if(TARGET fn_module_data_table)` pattern (conditional link). La funcion detecta la presencia de la string `fn_module_data_table` en CMakeLists.txt, sea en `target_link_libraries` o en el guard.
+131
View File
@@ -0,0 +1,131 @@
package infra
import (
"database/sql"
"fmt"
"strings"
_ "github.com/mattn/go-sqlite3"
)
// ServiceSpecAudit reports drift between an app tagged `service` and the
// `service:` frontmatter block populated by the indexer (issue 0105).
type ServiceSpecAudit struct {
AppID string `json:"app_id"`
Name string `json:"name"`
HasBlock bool `json:"has_block"`
Runtime string `json:"runtime"`
Port int `json:"port"`
HealthPath string `json:"health_endpoint"`
SystemdUnit string `json:"systemd_unit"`
PCTargets []string `json:"pc_targets"`
IsLocalOnly bool `json:"is_local_only"`
RestartPolicy string `json:"restart_policy"`
Issues []string `json:"issues"`
OK bool `json:"ok"`
}
// AuditServicesSpec lists every app with tag `service` and reports whether its
// `service:` frontmatter is complete enough for downstream monitoring
// (services_monitor app, issue 0106).
//
// Rules:
// - block must exist (otherwise IsLocalOnly/runtime are all defaults).
// - runtime is required (one of: systemd-user, systemd-system, docker-compose, stdio, manual).
// - pc_targets must declare >= 1 pc_id.
// - if runtime starts with `systemd-`, systemd_unit is required.
// - if runtime in {systemd-*, docker-compose} and port > 0, health_endpoint is recommended (warning, not failure).
func AuditServicesSpec(registryRoot string) ([]ServiceSpecAudit, error) {
dbPath := registryRoot + "/registry.db"
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&mode=ro")
if err != nil {
return nil, fmt.Errorf("audit_services_spec: open db: %w", err)
}
defer db.Close()
rows, err := db.Query(`
SELECT id, name,
COALESCE(service_runtime,''),
COALESCE(service_port,0),
COALESCE(service_health_endpoint,''),
COALESCE(service_systemd_unit,''),
COALESCE(service_restart_policy,''),
COALESCE(service_is_local_only,0)
FROM apps
WHERE tags LIKE '%service%'
ORDER BY id
`)
if err != nil {
return nil, fmt.Errorf("audit_services_spec: query: %w", err)
}
defer rows.Close()
var out []ServiceSpecAudit
for rows.Next() {
var a ServiceSpecAudit
var localOnly int
if err := rows.Scan(
&a.AppID, &a.Name,
&a.Runtime, &a.Port, &a.HealthPath, &a.SystemdUnit, &a.RestartPolicy, &localOnly,
); err != nil {
return nil, fmt.Errorf("audit_services_spec: scan: %w", err)
}
a.IsLocalOnly = localOnly != 0
a.HasBlock = a.Runtime != "" || a.SystemdUnit != "" || a.Port != 0 || a.HealthPath != ""
// pc_targets from service_targets table.
tRows, err := db.Query(
"SELECT pc_id FROM service_targets WHERE app_id = ? ORDER BY pc_id",
a.AppID,
)
if err != nil {
return nil, fmt.Errorf("audit_services_spec: service_targets query: %w", err)
}
for tRows.Next() {
var pc string
if err := tRows.Scan(&pc); err != nil {
tRows.Close()
return nil, err
}
a.PCTargets = append(a.PCTargets, pc)
}
tRows.Close()
// Validate.
if !a.HasBlock {
a.Issues = append(a.Issues, "missing service: block in app.md")
}
if a.Runtime == "" {
a.Issues = append(a.Issues, "missing service.runtime")
} else if !validRuntimes[a.Runtime] {
a.Issues = append(a.Issues, "invalid service.runtime: "+a.Runtime)
}
if len(a.PCTargets) == 0 {
a.Issues = append(a.Issues, "missing service.pc_targets (>= 1 required)")
}
if strings.HasPrefix(a.Runtime, "systemd-") && a.SystemdUnit == "" {
a.Issues = append(a.Issues, "runtime systemd-* requires service.systemd_unit")
}
if a.RestartPolicy != "" && !validRestart[a.RestartPolicy] {
a.Issues = append(a.Issues, "invalid service.restart_policy: "+a.RestartPolicy)
}
a.OK = len(a.Issues) == 0
out = append(out, a)
}
return out, rows.Err()
}
var validRuntimes = map[string]bool{
"systemd-user": true,
"systemd-system": true,
"docker-compose": true,
"stdio": true,
"manual": true,
}
var validRestart = map[string]bool{
"always": true,
"on-failure": true,
"none": true,
}
+60
View File
@@ -0,0 +1,60 @@
---
name: audit_services_spec
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func AuditServicesSpec(registryRoot string) ([]ServiceSpecAudit, error)"
description: "Audita apps con tag 'service': reporta drift entre el bloque service: del app.md y los datos requeridos por el monitor (port, health_endpoint, systemd_unit, pc_targets). Lee registry.db read-only via sql.Open. Issue 0105."
tags: [audit, services, doctor, registry, sqlite, issue-0105]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports:
- database/sql
- github.com/mattn/go-sqlite3
tested: false
file_path: functions/infra/audit_services_spec.go
params:
- name: registryRoot
desc: "Ruta absoluta a la raiz del fn_registry (donde vive registry.db)."
output: "Slice de ServiceSpecAudit (uno por app con tag service). OK=false si Issues no esta vacio."
---
# audit_services_spec
Reporta apps con tag `service` cuya `service:` block esta incompleta.
## Cuando usarla
- Subcomando `fn doctor services-spec` (este es su unico consumer hoy).
- Antes de desplegar `services_monitor` (issue 0106) — si esta funcion devuelve `OK=false` para alguna app, el monitor no puede reconciliar estado.
- En CI/cron para detectar regresiones cuando alguien crea app `tag: service` sin bloque.
## Ejemplo
```go
audits, err := infra.AuditServicesSpec("/home/lucas/fn_registry")
for _, a := range audits {
if !a.OK {
fmt.Println(a.AppID, "issues:", a.Issues)
}
}
```
## Reglas que valida
- bloque presente (alguno de runtime/systemd_unit/port/health_endpoint != default).
- `runtime` declarado y en allowlist (`systemd-user`, `systemd-system`, `docker-compose`, `stdio`, `manual`).
- `pc_targets` con al menos 1 pc_id (cruzado contra tabla `service_targets`).
- `runtime` empieza con `systemd-``systemd_unit` obligatorio.
- `restart_policy` (si declarada) en `always`, `on-failure`, `none`.
## Gotchas
- Lee `registry.db` en modo `?mode=ro`; si la base no existe o esta locked retorna error.
- `service:` bloque parcial pasa el check `HasBlock=true` pero falla validaciones especificas — ver `Issues[]` para detalles.
- No valida que el `port` este libre o el `systemd_unit` exista en disco; eso lo hace `services_status_go_infra` (runtime check).
+58
View File
@@ -0,0 +1,58 @@
# Modulos C++ del registry
Bundles versionados de funciones del registry expuestos como static libs cmake. Apps opt-in via `app.md::uses_modules` + `target_link_libraries(... fn_module_<X>)`.
## Catalogo
| Modulo | Version | Static lib | Header | Entry | Linkage | Descripcion | Contrato |
|---|---|---|---|---|---|---|---|
| [framework_cpp](framework/module.md) | 1.1.0 | `fn_framework` | `framework/app_base.h` | `fn::run_app(cfg, render)` | transitivo via `add_imgui_app` | Shell ImGui (GLFW+OpenGL+ImGui+ImPlot+themas+layouts+logger) | [framework_cpp](../docs/MODULES_API.md#framework_cpp) |
| [data_table_cpp](data_table/module.md) | 2.1.0 | `fn_module_data_table` | `data_table/data_table.h` | `data_table::render(id, tables, state, events_out, show_chrome)` | explicito | Tabla completa TQL + 9 renderers declarativos + viz + drill + AI + button events | [data_table_cpp](../docs/MODULES_API.md#data_table_cpp) |
## Diferencia `members` vs `uses_functions` (post 0107d)
Cada `module.md` declara DOS listas:
- **`members`**: funciones del registry que el modulo POSEE. Viven en `cpp/functions/<dominio>/` y NO se usan fuera del modulo. Apps consumidoras del modulo NO listan estos miembros en su `uses_functions` (cobertura transitiva).
- **`uses_functions`**: funciones del registry que el modulo CONSUME pero NO posee. Utiles fuera del modulo. Si una app necesita estas STANDALONE, las declara en su propio `uses_functions` directamente (no es duplicacion — es uso independiente).
Ejemplo `data_table_cpp` v2.1.0:
- `members`: las 7 sub-funciones `data_table_*_cpp_viz` (renderizan dentro del modulo).
- `uses_functions`: `lua_engine`, `llm_anthropic`, `join_tables`, `auto_detect_type`, `tql_*`, `compute_*`, `viz_render` (consumidas por el modulo pero utiles solas).
## Como anadir un modulo
1. `mkdir modules/<name>/` con:
- `module.md` (frontmatter: name, version, description, members, uses_functions, dir_path).
- `CMakeLists.txt` definiendo `add_library(fn_module_<name> STATIC ...)`.
- `<name>.cpp` + `<name>.h` (entry function).
- `<name>_internal.h` si tiene >1 archivo + UiState compartido.
2. Anadir `add_subdirectory(${CMAKE_SOURCE_DIR}/../modules/<name> ${CMAKE_BINARY_DIR}/modules/<name>)` en `cpp/CMakeLists.txt`.
3. Anadir fila en este catalogo + seccion en `docs/MODULES_API.md` siguiendo el template.
4. `fn index` registra el modulo en `registry.db::modules`.
5. Cada bump de version: `/version modules/<name> <major|minor|patch> "<reason>"`. Edita `module.md::version` + `## Capability growth log`.
## Auditoria
- `fn doctor modules` (0107a): detecta drift `uses_modules` vs `uses_functions` en apps. Hoy: 0 drift en 8 apps consumidoras de `data_table_cpp` post-0107b.
- `audit_data_table_usage_go_infra` (capability del registry): audita patrones de uso del modulo `data_table` en apps. Detecta `inline_begintable`, `state_not_persistent`, `no_child_host`, `no_event_sink`, `cmake_missing_link`. Output en `dev/data_table_integration_audit.md`.
## Ciclo de vida + version policy
Ver `docs/MODULES_API.md::Ciclo de vida de un modulo` y `.claude/rules/cpp_apps.md`.
Semver estricto:
- **Major** = breaking ABI/API publica del modulo (entry function, State struct expuesto, eliminacion de members).
- **Minor** = additive backward-compatible (nuevo renderer, nuevo evento, refactor interno sin cambio de API).
- **Patch** = bugfix sin cambio de API.
Slash command para bumpear: `/version modules/<name> <bump-type> "<reason>"`.
## Modulos en roadmap (post 0107)
- `chat_ia_cpp` — sera el siguiente modulo. Bloqueado hasta cierre completo de 0107 (estandarizacion modulos). Issue 0109 cuando 0107 cierre.
- Otros candidatos detectados por uso repetido en apps: TBD (auditar con `audit_data_table_usage` pattern para otros bundles).

Some files were not shown because too many files have changed in this diff Show More