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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
```
|
||||||
@@ -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. |
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 ==="
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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..."
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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`.
|
||||||
@@ -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,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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2–5. 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]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
@@ -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
Reference in New Issue
Block a user