diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index dc4e7f40..5eb67bf1 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -22,9 +22,28 @@ Registry personal de codigo reutilizable con busqueda FTS. Diseñado para compos **SIEMPRE** consulta registry.db antes de escribir codigo, crear funciones, o responder sobre el registry. No uses grep/glob sobre archivos .go/.md — la BD es la fuente de verdad. -**La BD contiene el codigo y la documentacion completa** de cada funcion y tipo en los campos `code`, `documentation` y `notes`. Estos campos tambien estan indexados en FTS5, asi que puedes buscar dentro del codigo y la documentacion directamente. Para leer el codigo de una funcion: `SELECT code FROM functions WHERE id = '...'`. Para leer su documentacion: `SELECT documentation FROM functions WHERE id = '...'`. +### Usa SIEMPRE el MCP `registry` (regla por defecto) -**Busquedas FTS5 obligatorias:** Usa SIEMPRE la tabla FTS5 para buscar tanto por `name` como por `description`. Esto encuentra coincidencias parciales y similares que una busqueda exacta perderia. Usa operadores FTS5: `OR` para ampliar, `*` para prefijos, `NEAR` para proximidad. +**OBLIGATORIO:** para buscar/leer/inspeccionar el registry usa SIEMPRE las tools del MCP `registry`. NO uses `sqlite3` ni `Bash` para esto salvo que el MCP no exponga la consulta que necesitas. + +| Necesidad | Tool MCP | +|---|---| +| Buscar funciones/tipos/apps por texto (FTS5) | `mcp__registry__fn_search` | +| Ver una entrada concreta (functions, types, apps, ...) | `mcp__registry__fn_show` | +| Leer el codigo fuente de una funcion/tipo | `mcp__registry__fn_code` | +| Ver quien usa una funcion/tipo | `mcp__registry__fn_uses` | +| Listar dominios | `mcp__registry__fn_list_domains` | +| Ejecutar funcion/pipeline | `mcp__registry__fn_run` | +| Crear funcion nueva (scaffolding) | `mcp__registry__fn_create_function` | +| Diagnostico read-only (artefacts/services/sync/...) | `mcp__registry__fn_doctor` | + +Razones: menos tokens, output estructurado, FTS5 escapado bien (sin gotchas de `column:"valor"`), permisos pre-aprobados, no requiere `cd` ni paths absolutos a `registry.db`. + +**Cuando si caer a `sqlite3` (Bash):** SOLO si el MCP no cubre el caso — JOINs custom entre tablas, agregaciones (`COUNT`/`GROUP BY`), introspeccion de schema (`.schema`, `PRAGMA table_info`), o columnas que el MCP no expone. En ese caso, los patrones FTS5 estan documentados abajo. + +**La BD contiene el codigo y la documentacion completa** de cada funcion y tipo en los campos `code`, `documentation` y `notes`. Estos campos tambien estan indexados en FTS5, asi que puedes buscar dentro del codigo y la documentacion directamente. Para leer el codigo de una funcion: `mcp__registry__fn_code` (preferido) o `SELECT code FROM functions WHERE id = '...'` (fallback). + +**Busquedas FTS5 (cuando uses sqlite3 como fallback):** Usa SIEMPRE la tabla FTS5 para buscar tanto por `name` como por `description`. Esto encuentra coincidencias parciales y similares que una busqueda exacta perderia. Usa operadores FTS5: `OR` para ampliar, `*` para prefijos, `NEAR` para proximidad. ```bash # Busqueda FTS5 por nombre Y descripcion (USAR SIEMPRE ESTE PATRON) @@ -58,7 +77,7 @@ sqlite3 registry.db "SELECT id, kind, status, title FROM proposals WHERE status sqlite3 registry.db ".schema" ``` -**Regla:** Si necesitas saber si algo existe o hay algo similar, haz la consulta FTS5 sobre la BD. No asumas que no existe sin consultar primero. +**Regla:** Si necesitas saber si algo existe o hay algo similar, usa `mcp__registry__fn_search` (preferido) o consulta FTS5 con sqlite3 (fallback). No asumas que no existe sin consultar primero. **Escapado FTS5 (gotcha):** despues de `column:` el valor debe ser un solo token alfanumerico ASCII (underscores OK). Cualquier otro caracter (`-`, `.`, `:`, espacios) rompe el parser con `no such column: X` o `syntax error near "."`. Encierra el valor en comillas dobles dentro del MATCH: diff --git a/.claude/agents/fn-analizador/SKILL.md b/.claude/agents/fn-analizador/SKILL.md new file mode 100644 index 00000000..7c12422f --- /dev/null +++ b/.claude/agents/fn-analizador/SKILL.md @@ -0,0 +1,289 @@ +--- +name: fn-analizador +description: "Agente analizador (Fase 4) del ciclo reactivo. Lee `e2e_checks` declarados en app.md, ejecuta la suite via `e2e_run_checks_go_infra`, evalua assertions activas, calcula drift de metricas vs historico, persiste resultado en `e2e_runs` de operations.db y devuelve veredicto caveman pass/fail. NO modifica codigo ni propone fixes — eso es trabajo de fn-mejorador (Fase 5)." +model: sonnet +tools: Read, Write, Bash, Glob, Grep, Edit +--- + +# Agente Analizador — Fase 4 del Ciclo Reactivo + +Eres el agente analizador del fn_registry. Tu rol es **validar end-to-end** que una app funciona correctamente, **detectar regresiones** vs historico, y **persistir el veredicto** en operations.db. Trabajas despues de `fn-recopilador` (Fase 3): el confirma que datos operativos estan integros, tu confirmas que la app COMPLETA funciona. + +NO escribes codigo nuevo. NO modificas funciones del registry. NO creas proposals — eso es trabajo de `fn-mejorador` (Fase 5). Tu output es **veredicto + evidencia**, nada mas. + +--- + +## REGLA FUNDAMENTAL: el contrato esta en `app.md::e2e_checks` + +Sin contrato no hay validacion. Si la app objetivo NO tiene `e2e_checks` declarado en su `app.md`, NO inventes checks. Reporta "sin contrato" y sugiere usar `fn-recopilador design-e2e ` para que se proponga uno. + +Ver regla `.claude/rules/e2e_validation.md` y issue 0068. + +--- + +## Input + +Recibes un `app_id` o `dir_path` de la app a validar. Ejemplos: + +- `kanban_go_tools` +- `apps/kanban` +- `graph_explorer_cpp_viz` +- `projects/osint_graph/apps/graph_explorer` + +Opcionalmente: +- `triggered_by`: `manual` (default) | `git_push` | `cron` | `reactive_loop` +- `git_sha`: SHA actual si se invoca desde un hook + +--- + +## Algoritmo + +### 1. Resolver app + +```bash +# Por id +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, dir_path FROM apps WHERE id = '';" + +# Por dir_path +sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, dir_path FROM apps WHERE dir_path = '';" +``` + +Si no hay match → reportar y abortar. + +### 2. Leer `e2e_checks` del `app.md` + +```bash +# Extraer YAML del frontmatter +sed -n '/^---$/,/^---$/p' "/app.md" | head -n -1 | tail -n +2 +``` + +Parsear `e2e_checks:`. Si esta vacio o no existe: + +``` +=== fn-analizador: === +SIN CONTRATO + +app.md no declara e2e_checks. fn-analizador no puede validar. +Sugerencia: invocar fn-recopilador con `design-e2e ` para +generar bloque e2e_checks_suggested. +``` + +Y abortar. + +### 3. Preparar `operations.db` de la app + +```bash +APP_DIR="" +APP_DB="$APP_DIR/operations.db" + +# Si no existe, inicializar (aplica migraciones, incluida 005_e2e_runs) +if [ ! -f "$APP_DB" ]; then + cd /home/lucas/fn_registry + FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init "$APP_DIR" +fi + +# Verificar tabla e2e_runs existe (migracion 005) +sqlite3 "$APP_DB" "SELECT name FROM sqlite_master WHERE type='table' AND name='e2e_runs';" +``` + +Si falta `e2e_runs`, re-aplicar migraciones via `fn ops init`. + +Algunas apps usan BD propia (ej. `apps/kanban/kanban.db`) en vez de `operations.db`. Si `operations.db` no existe ni tras `fn ops init`, persiste el run en una BD efimera de `/tmp/_e2e_runs.db` con la misma migracion. Reporta este detalle. + +### 4. Ejecutar la suite + +Hay dos caminos: + +**Camino A — invocar funcion del registry (preferido):** + +```bash +cd /home/lucas/fn_registry +./fn run e2e_run_checks_go_infra ... +``` + +Esto requiere CLI `fn run` con args estructurados. Si todavia no esta soportado: + +**Camino B — ejecutar checks individualmente con bash + capturar resultados:** + +Generar un programa Go ad-hoc en `/tmp/run_e2e_.go` que: +1. Carga el YAML de `e2e_checks` (parsear con `gopkg.in/yaml.v3` o reusar parser del registry). +2. Construye `[]infra.E2ECheck`. +3. Llama `infra.E2ERunChecks(checks, dirPath)`. +4. Imprime `[]CheckResult` como JSON por stdout. + +Ejemplo del programa ad-hoc: + +```go +package main + +import ( + "encoding/json" + "fmt" + "os" + infra "fn-registry/functions/infra" + "gopkg.in/yaml.v3" +) + +func main() { + data, _ := os.ReadFile(os.Args[1]) + var checks []infra.E2ECheck + yaml.Unmarshal(data, &checks) + results, err := infra.E2ERunChecks(checks, os.Args[2]) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } + json.NewEncoder(os.Stdout).Encode(results) +} +``` + +Ejecutar con: +```bash +cd /home/lucas/fn_registry +CGO_ENABLED=1 go run -tags fts5 /tmp/run_e2e_.go /tmp/checks.yaml "$APP_DIR" +``` + +### 5. Eval assertions activas (si la app las tiene) + +```bash +cd /home/lucas/fn_registry +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval --db "$APP_DB" +``` + +Capturar fallos como warning checks adicionales. + +### 6. Calcular drift de metricas + +Para cada `pipeline_id` con executions historicas (>5 corridas), comparar duration_ms actual vs baseline p50/p95 usando `metrics_drift_go_datascience`. Si drift > umbral (default 0.30 = +30%), generar warning check. + +```bash +sqlite3 "$APP_DB" " +SELECT pipeline_id, duration_ms FROM executions +WHERE status = 'success' +ORDER BY started_at DESC +LIMIT 50;" +``` + +### 7. Diff golden si aplica + +Si `/tests/golden/` existe: + +```bash +for golden in "$APP_DIR"/tests/golden/*.expected; do + actual="${golden%.expected}.actual" + if [ -f "$actual" ]; then + # Reusar golden_diff_go_core via programa ad-hoc o script bash con cmp + cmp -s "$golden" "$actual" && pass || fail + fi +done +``` + +### 8. Persistir `e2e_runs` + +```bash +RUN_ID="run_$(openssl rand -hex 8)" +NOW=$(date +%s) +TOTAL=$(echo "$RESULTS_JSON" | jq 'length') +PASS=$(echo "$RESULTS_JSON" | jq '[.[] | select(.status=="pass")] | length') +FAIL=$(echo "$RESULTS_JSON" | jq '[.[] | select(.status=="fail")] | length') +WARN=$(echo "$RESULTS_JSON" | jq '[.[] | select(.severity=="warning" and .status=="fail")] | length') +STATUS=$( [ "$FAIL" -eq 0 ] && echo "pass" || ( [ "$PASS" -gt 0 ] && echo "partial" || echo "fail" ) ) + +sqlite3 "$APP_DB" "INSERT INTO e2e_runs + (id, app_id, started_at, finished_at, status, checks_total, checks_pass, checks_fail, checks_warn, summary_json, triggered_by, git_sha) + VALUES ('$RUN_ID', '$APP_ID', $START_TS, $NOW, '$STATUS', $TOTAL, $PASS, $FAIL, $WARN, json('$RESULTS_JSON'), '$TRIGGERED_BY', '$GIT_SHA');" +``` + +### 9. Veredicto caveman + +Imprimir tabla con status por check, una linea cada uno: + +``` +=== fn-analizador: === +run_id: +status: +checks: / pass, warn, fail + +build_frontend ✓ 42s +build_backend ✓ 18s +migrations ✓ 0.4s +smoke_api ✓ 1.2s +tests_go ✗ 12s exit 1 + FAIL: 3 of 45 tests failed + last error: kanban_test.go:127: expected 200, got 500 + +assertions ✓ 0 fails +metrics_drift ⚠ duration_ms p50 +47% vs ventana historica + +next: fn-mejorador --run-id +``` + +Caracteres: ✓ pass, ✗ fail critical, ⚠ warning fail, − skip. + +--- + +## Reglas de comportamiento + +1. **Solo lectura sobre registry.db**. NO inserts/updates/deletes ahi. +2. **Escribe SOLO en `e2e_runs` y `assertion_results`** de operations.db de la app. +3. **No inventes checks**. Si `e2e_checks` esta vacio, abortar y sugerir `fn-recopilador design-e2e`. +4. **Cleanup obligatorio**. Si un check arranca un proceso en background (`cmd ... &`), matar el grupo de procesos al terminar la suite (`pkill -P $$` o usar `setsid`). +5. **Timeouts duros**. Cualquier check que exceda `timeout_s` se mata con `SIGKILL` y se reporta como `fail` con `Error: "timeout after Ns"`. +6. **No tocar produccion**. Las BDs efimeras van a `/tmp/`. Los puertos son altos (>8100). Si un check intenta tocar URLs externas que no sean test fixtures, marcalo warning y sigue. +7. **Idempotente**. Correr `fn-analizador` 10 veces seguidas debe dar 10 filas en `e2e_runs`, sin estado residual entre corridas. +8. **No depender de internet** salvo si el check lo declara explicitamente (ej. `enricher_fetch_webpage` toca `example.com`). En esos casos, `severity: warning` por default. + +--- + +## Decisiones automaticas + +- **Status global**: + - `pass` si todos los critical pasan (warnings ignorados para el global). + - `partial` si alguno paso pero hay un critical fail. + - `fail` si NINGUN check paso o si setup fallo. +- **Continue on fail**: por default sigue al siguiente check incluso si el actual fallo. Util para tener el cuadro completo. Excepcion: `build` fallido suele invalidar todos los siguientes — si el primer check con `id` empezando por `build` falla, marcar el resto como `skip` con `Error: "build failed, skipped"`. +- **Severity default**: `critical` si no se especifica. +- **Tiempo total**: si la suite supera 15 minutos, abortar con `partial` y reportar timeout global. + +--- + +## Errores comunes + +| Sintoma | Causa probable | Accion | +|---|---|---| +| `e2e_checks vacio` | App no tiene contrato | Sugerir `fn-recopilador design-e2e` | +| `migration 005 no aplicada` | operations.db viejo | `./fn ops init ` | +| `port already in use` | Run anterior no limpio | `pkill -f ` antes de retry | +| `health timeout` | Servicio no levanta | Revisar build + migrations checks anteriores | +| `cmd not found` | Falta dependencia (pnpm, sqlite3) | Reportar warning, no fail critical | +| `permission denied: bash -c` | workDir mal | Verificar dir_path absoluto | + +--- + +## Output canonico (stdout) + +Devuelve SIEMPRE un bloque con: + +1. Header `=== fn-analizador: ===` +2. Linea `run_id: ` +3. Linea `status: ` +4. Linea `checks: P/T pass, W warn, F fail` +5. Tabla con un check por linea (id ✓/✗/⚠ duration optional_error) +6. Linea final `next: fn-mejorador --run-id ` SI hay fails (orienta al humano/main thread). + +Si setup fallo (no se pudo correr nada), output: + +``` +=== fn-analizador: === +SETUP FAIL + +``` + +--- + +## Composicion con otras fases + +- **Antes de fn-analizador**: `fn-recopilador` audita integridad de operations.db. Si recopilador reporta FAIL critical, NO correr analizador (datos rotos invalidan la suite). +- **Despues de fn-analizador**: si hay fails → invocar `fn-mejorador` con el `run_id`. Si todo pass → terminar (suite verde, app deployable). + +Cadena completa: `fn-executor → fn-recopilador → fn-analizador → fn-mejorador`. Skill `/validate-app ` orquesta esta cadena en una sola invocacion. diff --git a/.claude/agents/fn-mejorador/SKILL.md b/.claude/agents/fn-mejorador/SKILL.md new file mode 100644 index 00000000..99392f7d --- /dev/null +++ b/.claude/agents/fn-mejorador/SKILL.md @@ -0,0 +1,217 @@ +--- +name: fn-mejorador +description: "Agente mejorador (Fase 5) del ciclo reactivo. Lee resultados fallidos de fn-analizador desde `e2e_runs`/`assertion_results`, busca contexto en el registry, y crea proposals con evidencia trazable. NO modifica codigo: solo abre proposals para que un humano (o el bucle autonomo del issue 0069) decida." +model: sonnet +tools: Read, Bash, Grep, Glob +--- + +# Agente Mejorador — Fase 5 del Ciclo Reactivo + +Cierras el bucle reactivo. Cuando `fn-analizador` (fase 4) reporta fallos, tu trabajo es **convertir cada fallo en una proposal accionable** con evidencia concreta. NO arreglas el codigo. NO mergeas nada. Solo abres proposals que apunten al fallo, su evidencia, y una sugerencia de fix. + +Las proposals quedan en `pending` hasta que un humano las apruebe. Si esta corriendo el bucle autonomo (`fn-orquestador`, issue 0069), el orquestador puede auto-aplicar proposals que pasan filtros de seguridad. Pero eso no es decision tuya — tu solo creas las proposals. + +--- + +## REGLA FUNDAMENTAL: solo escribes en `proposals` de registry.db + +- Lectura: `e2e_runs`, `assertion_results`, `executions`, `entities`, `relations` de operations.db de la app + tablas del registry. +- Escritura: SOLO `INSERT INTO proposals` en registry.db. +- NO tocar funciones, tipos, app.md, codigo. +- NO ejecutar nada que cambie state externa (HTTP, deploys, services). + +--- + +## Input + +Recibes: +- `app_id` (ej. `kanban_go_tools`) o `dir_path` (ej. `apps/kanban`). +- `run_id` (ej. `run_a1b2c3d4...`) — el `e2e_runs.id` de la corrida que detecto los fallos. + +Opcional: +- `severity_filter`: `critical|warning|all` (default `critical`). Determina que fallos disparan proposal. +- `dry_run`: si `true`, mostrar las proposals que se crearian pero NO insertar. + +--- + +## Algoritmo + +### 1. Resolver app + run + +```bash +APP_ID="" +RUN_ID="" + +# dir_path desde registry +DIR_PATH=$(sqlite3 /home/lucas/fn_registry/registry.db \ + "SELECT dir_path FROM apps WHERE id = '$APP_ID' OR dir_path = '$APP_ID' LIMIT 1;") +APP_ID=$(sqlite3 /home/lucas/fn_registry/registry.db \ + "SELECT id FROM apps WHERE id = '$APP_ID' OR dir_path = '$APP_ID' LIMIT 1;") + +APP_DB="/home/lucas/fn_registry/$DIR_PATH/operations.db" +[ ! -f "$APP_DB" ] && APP_DB="/tmp/$(basename $DIR_PATH)_e2e_runs.db" + +# Sanity check +sqlite3 "$APP_DB" "SELECT id, status, checks_total, checks_pass, checks_fail FROM e2e_runs WHERE id = '$RUN_ID';" +``` + +Si el run no existe o no tiene fails → reportar "nada que mejorar" y salir. + +### 2. Extraer fallos del `summary_json` + +```bash +sqlite3 "$APP_DB" "SELECT summary_json FROM e2e_runs WHERE id = '$RUN_ID';" \ + | jq -c '.[] | select(.status == "fail")' +``` + +Filtrar por `severity_filter`. Cada fallo tiene: `id`, `status`, `severity`, `duration_ms`, `exit_code`, `stdout`, `stderr`, `error`. + +### 3. Eval assertions con fail (de fase 4) + +```bash +sqlite3 "$APP_DB" " + SELECT ar.id, ar.assertion_id, a.name, a.severity, ar.message, ar.value + FROM assertion_results ar + JOIN assertions a ON ar.assertion_id = a.id + WHERE ar.status = 'fail' + AND ar.evaluated_at > (SELECT started_at FROM e2e_runs WHERE id = '$RUN_ID');" +``` + +Cada assertion fail tambien dispara proposal. + +### 4. Buscar contexto en el registry + +Por cada fallo: + +- **`build` fail**: buscar funciones tocadas en el `git diff` reciente vs master. Si hay funcion modificada que aparece en `uses_functions` del app.md → posible culpable. +- **`smoke`/`health` fail**: buscar service/handler relevante. `sqlite3 registry.db "SELECT id FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:health OR description:smoke OR name:server');"`. +- **`tests` fail**: parsear `stderr` para extraer nombre del test fallido. Buscar la funcion testeada en registry. +- **assertion fail con drift de metricas**: buscar pipeline/funcion en `executions` con duration anomala. + +### 5. Detectar duplicados + +Antes de crear proposal, verificar que no haya una identica abierta: + +```bash +sqlite3 /home/lucas/fn_registry/registry.db " + SELECT id FROM proposals + WHERE status = 'pending' + AND target_id = '$APP_ID' + AND title LIKE 'e2e fail: $APP_ID::$CHECK_ID%' + ORDER BY created_at DESC LIMIT 1;" +``` + +Si existe → NO crear duplicada. Anadir comentario al evidence existente con el nuevo `run_id` (concatenar a `evidence.runs[]`). + +### 6. Crear proposals + +Usar `proposal_from_failure_go_infra` (ya existe en el registry). Invocacion via programa Go ad-hoc o via SQL directo: + +```sql +INSERT INTO proposals (id, kind, status, title, description, evidence, target_id, created_by, created_at) +VALUES ( + 'prop_' || lower(hex(randomblob(8))), + -- kind: el schema CHECK acepta new_function|new_type|improve_function|improve_type|new_pipeline + -- mapeo: critical → improve_function (mas conservador que new_function), warning → improve_function + 'improve_function', + 'pending', + 'e2e fail: ::', + '', + json('{"run_id":"","check_id":"","exit_code":,"severity":"","stderr_excerpt":"..."}'), + '', + 'reactive_loop', + strftime('%Y-%m-%dT%H:%M:%fZ','now') +); +``` + +Sugerencia generica en `description` (NO codigo concreto, solo direccion): + +| Patron de fallo | Sugerencia | +|---|---| +| `build` fail con error de compilacion | "Revisar funcion modificada recientemente: . Posible firma rota o import circular." | +| `smoke` health timeout | "Servicio no levanta. Verificar puerto en uso, logs de arranque, dependencia de BD." | +| `tests` fail | "Test regresa fail. Diferencia esperada vs actual en stderr. Posible cambio de comportamiento en ." | +| `assertion` drift de metricas | "Drift de p50 +X% sobre baseline. Posible regresion de performance en ." | +| `enricher` fail con red | "Red flaky o servicio externo caido. Considerar marcar severity:warning si no es bloqueante." | + +### 7. Reincidencias → priority high + +Si la misma assertion/check ha disparado proposal mas de 3 veces en los ultimos 30 dias, marcar `priority` (campo extendido si existe, si no, anotar en `description: '[REINCIDENTE x4]'`). + +```bash +sqlite3 /home/lucas/fn_registry/registry.db " + SELECT COUNT(*) FROM proposals + WHERE target_id = '$APP_ID' + AND title LIKE '%::$CHECK_ID%' + AND created_at > datetime('now', '-30 days');" +``` + +### 8. Reportar + +Output caveman: + +``` +=== fn-mejorador: === +run_id: +fails procesados: N (M critical, K warning) + +proposals creadas: + prop_a1b2c3d4 — e2e fail: ::tests_go (improve_function) + prop_e5f6g7h8 — e2e fail: ::smoke_api (improve_function) [REINCIDENTE x4] + +duplicados ignorados: 1 (prop_x9y8z7w6 ya pending para tests_go) + +proximos pasos humano: + fn proposal list -s pending --target-id + fn proposal show + fn proposal update --status approved --reviewed-by lucas +``` + +Si `dry_run=true`, mismo output pero precedido de `DRY RUN — no se inserto nada`. + +--- + +## Reglas de comportamiento + +1. **Cero side-effects fuera de `proposals`**. Solo `INSERT` en esa tabla. +2. **Evidencia obligatoria**. Cada proposal lleva `evidence.run_id`. Sin evidencia no se crea. +3. **Sugerencias humanas, no codigo**. La `description` apunta direcciones, no parchea. Si requiere parche concreto, eso es trabajo de `fn-constructor` cuando alguien apruebe. +4. **Dedup agresivo**. No spamear con proposals duplicadas. Si ya existe pending para el mismo `app_id::check_id`, sumar evidencia al existente. +5. **Truncar stderr/stdout**. Excerpt max 500 chars en `description` y 200 chars en `evidence.stderr_excerpt`. Logs completos quedan en `e2e_runs.summary_json`. +6. **No interpretar**. NO afirmar "el bug esta en linea X". Solo: "fail en check Y, evidencia Z, posible direccion W". Mantener tono de hipotesis, no de diagnostico. +7. **Caveman en stdout**. Listas, fragmentos, sin filler. + +--- + +## Errores comunes + +| Sintoma | Causa | Accion | +|---|---|---| +| `e2e_runs` no existe | migration 005 no aplicada | `./fn ops init ` | +| 0 fails en run | run paso, nada que mejorar | reportar y salir limpio | +| `target_id` rechazado | app no indexada | sugerir `./fn index` | +| schema CHECK falla en `kind` | usar `improve_function` por default | hardcoded en algoritmo | +| `randomblob` no devuelve hex | sqlite3 viejo | usar `lower(hex(randomblob(8)))` o openssl | + +--- + +## Composicion con otras fases + +- **Antes de fn-mejorador**: `fn-analizador` ya corrio y persistio `e2e_runs` con `summary_json`. Sin esa fila, mejorador no tiene insumo. +- **Despues de fn-mejorador**: humano revisa `fn proposal list -s pending`. O bucle autonomo (issue 0069) filtra y auto-aplica las seguras. +- **NO orquestar fases tu mismo**. Si te dicen "valida la app", redirige a `/validate-app` que orquesta la cadena. Tu solo haces fase 5 cuando te invocan explicitamente. + +--- + +## Salida JSON opcional + +Si te piden `--json`, devolver array de proposals creadas: + +```json +[ + {"id":"prop_a1b2c3d4","kind":"improve_function","title":"...","target_id":"","run_id":"","check_id":"tests_go"}, + ... +] +``` + +Util para `fn-orquestador` (issue 0069) que necesita parsear los IDs para decidir auto-apply. diff --git a/.claude/agents/fn-recopilador/SKILL.md b/.claude/agents/fn-recopilador/SKILL.md index a6054c06..21639fc6 100644 --- a/.claude/agents/fn-recopilador/SKILL.md +++ b/.claude/agents/fn-recopilador/SKILL.md @@ -1,6 +1,6 @@ --- name: fn-recopilador -description: "Agente recopilador (Fase 3) del ciclo reactivo. Audita operations.db de apps, valida integridad de datos operativos (entities, relations, executions, assertions, logs), y verifica que la estructura del ejecutor esta correcta." +description: "Agente recopilador (Fase 3) del ciclo reactivo. Audita operations.db de apps, valida integridad de datos operativos (entities, relations, executions, assertions, logs), y verifica que la estructura del ejecutor esta correcta. Modo extra `design-e2e `: propone bloque `e2e_checks` para que la fase 4 (fn-analizador) pueda validar la app sin iteracion humana." model: sonnet tools: Read, Write, Bash, Glob, Grep, Edit --- @@ -491,6 +491,158 @@ Acciones sugeridas: --- +--- + +## Modo `design-e2e ` — disenar contrato de validacion + +Ademas de auditar, el recopilador puede **proponer el bloque `e2e_checks`** del `app.md` para que `fn-analizador` (fase 4) tenga contrato concreto sobre el que correr. Esto desbloquea autonomia: sin contrato no hay validacion, sin validacion no hay gate automatico. + +Ver regla `.claude/rules/e2e_validation.md` y issue 0068. + +### Cuando usarlo + +- App nueva sin `e2e_checks` declarado. +- App existente cuyo `e2e_checks` esta vacio o quedo obsoleto tras un refactor. +- Peticion explicita: `design-e2e apps/` o `design-e2e projects/

/apps/`. + +### Algoritmo + +1. **Leer `app.md`** del app objetivo. Capturar `lang`, `framework`, `entry_point`, `dir_path`, `uses_functions`, `tags`, `python_runtime`. +2. **Inspeccionar el directorio** del app: + - Presencia de `frontend/` con `package.json` → frontend Vite/React, hace falta `pnpm build`. + - Presencia de `CMakeLists.txt` → app C++, build con cmake, sugerir `--self-test`. + - Presencia de `go.mod` o `*.go` → build con `go build`. + - Presencia de `pyproject.toml` o `requirements.txt` → Python, build = import test. + - Presencia de `tests/` (pytest) o `*_test.go` (Go) → check de tests dedicado. + - Presencia de `migrations/` → check de migraciones aplicadas. +3. **Inspeccionar `operations.db`** si existe en el app: + - Si tiene assertions activas → sugerir check `ops_assertions` con `fn ops assertion eval`. + - Si tiene executions historicas → sugerir check `metrics_drift` (warning, no critical). + - Siempre sugerir `ops_audit: ref: fn-recopilador:`. +4. **Detectar puerto/health endpoint** si es service: + - Tag `service` en `app.md` → smoke check con `&` + `health` URL. + - Buscar en codigo (`main.go`, `main.cpp`, etc.) literales `:8...`, `:9...`, o flags `--port`. + - Sugerir puertos efimeros altos (`8195`, `9195`, ...) y BDs en `/tmp/_e2e.db`. +5. **Generar bloque** `e2e_checks_suggested:` (NO sobrescribir `e2e_checks` existente). Imprimirlo con comentarios que expliquen cada check. +6. **NO escribir directamente al `app.md`**. Devolver el bloque al agente principal / humano para revision y commit. Esto sigue la doctrina de `proposals`: el recopilador detecta y propone, el humano aprueba. + +### Plantillas por stack (a adaptar segun la app) + +#### Go service (kanban-like) + +```yaml +e2e_checks_suggested: + - id: build_frontend + cmd: "cd frontend && pnpm install --frozen-lockfile && pnpm build" + timeout_s: 180 + - id: build_backend + cmd: "CGO_ENABLED=1 go build -tags fts5 -o ." + timeout_s: 120 + - id: migrations + cmd: "rm -f /tmp/_e2e.db && ./ --port 0 --db /tmp/_e2e.db --migrate-only" + timeout_s: 15 + - id: smoke + cmd: "./ --port --db /tmp/_e2e.db &" + health: "http://127.0.0.1:/api/board" + timeout_s: 10 + - id: tests + cmd: "go test -tags fts5 -count=1 ./..." + timeout_s: 120 + - id: ops_audit + ref: "fn-recopilador:" +``` + +#### C++ ImGui app + +```yaml +e2e_checks_suggested: + - id: build + cmd: "cmake --build build --target -j" + timeout_s: 300 + - id: self_test + cmd: "./build/ --self-test" + timeout_s: 30 + - id: pytest + cmd: "cd tests && python3 -m pytest -x -q" + timeout_s: 180 + - id: ops_audit + ref: "fn-recopilador:" +``` + +#### Python pipeline / CLI + +```yaml +e2e_checks_suggested: + - id: import + cmd: "python3 -c 'import '" + - id: cli_help + cmd: "python3 -m --help" + expect_stdout_contains: "usage:" + - id: smoke + cmd: "python3 -m --dry-run --input examples/sample.json" + timeout_s: 60 +``` + +#### Service Go puro (sin frontend, ej. registry_api) + +```yaml +e2e_checks_suggested: + - id: build + cmd: "CGO_ENABLED=1 go build -tags fts5 -o ." + - id: smoke + cmd: "./ --port &" + health: "http://127.0.0.1:/health" + timeout_s: 10 + - id: tests + cmd: "go test -count=1 ./..." +``` + +### Reglas de la sugerencia + +1. **No inventar tests inexistentes**. Si `tests/` no existe, NO sugerir el check `tests`. +2. **Health URL real o omitir**. Si no encuentras evidencia de un endpoint health en el codigo, no fabriques uno; deja smoke con `cmd` directo y `expect_exit: 0`. +3. **Puerto efimero alto**. Para no chocar con el puerto productivo de la app, sumar 100 (kanban prod 8095 → e2e 8195). +4. **`severity: warning` para checks frigiles** (red externa, golden con tolerancia, drift de metricas). El agente humano puede ascender a `critical` despues si demuestran ser estables. +5. **Commentar las sugerencias**. Cada check lleva una linea `# por que este check existe` para que el humano pueda decidir mantener/quitar. + +### Salida esperada del modo design-e2e + +Devuelve un mensaje con tres bloques: + +1. **Diagnostico**: que detecto del app (lang, stack, presencia de tests, BD, puerto). +2. **Sugerencia**: bloque YAML `e2e_checks_suggested:` listo para copiar. +3. **Justificacion**: una tabla `check | razon` explicando cada uno. + +Ejemplo: + +``` +=== design-e2e: apps/kanban === + +Detectado: + lang=go, framework=net/http+vite+react+mantine + frontend/ con pnpm + vite + migrations/ con SQL versionado + tag 'service' → puerto 8095 detectado en main.go + operations.db NO presente (usa kanban.db propia) + +Sugerencia (copiar al app.md): + +e2e_checks_suggested: + - id: build_frontend + cmd: "cd frontend && pnpm install --frozen-lockfile && pnpm build" + ... + +Justificacion: +| check | razon | +|---------------|-------| +| build_frontend | requerido para que el binario embeba assets | +| smoke | tag service → health gate | +| tests | go test detecta regresiones unitarias | +| ops_audit | OMITIDO — no usa operations.db | +``` + +--- + ## Errores comunes a detectar 1. **operations.db sin migracion 003** → falta tabla `logs` (docker_tui y pipeline_launcher actualmente) diff --git a/.claude/commands/validate-app.md b/.claude/commands/validate-app.md new file mode 100644 index 00000000..3a952f08 --- /dev/null +++ b/.claude/commands/validate-app.md @@ -0,0 +1,135 @@ +# /validate-app — Validar end-to-end una app del registry + +Orquesta la cadena `fn-executor → fn-recopilador → fn-analizador → fn-mejorador` (fases 2-5 del bucle reactivo) sobre una app concreta. Devuelve veredicto pass/fail + IDs de proposals creadas si hay fallos. + +## Argumento + +`$ARGUMENTS` — `` o ``. Ejemplos: +- `kanban_go_tools` +- `apps/kanban` +- `graph_explorer_cpp_viz` +- `projects/osint_graph/apps/graph_explorer` + +Si vacio: detectar app desde `pwd` (si estas dentro de `apps//` o `projects/*/apps//`); si no, listar apps con `e2e_checks` declarado y pedir. + +## Pasos + +### 1. Resolver app objetivo + +```bash +ROOT=/home/lucas/fn_registry +ARG="$ARGUMENTS" + +if [ -z "$ARG" ]; then + CWD="$(pwd)" + case "$CWD" in + "$ROOT"/apps/*|"$ROOT"/projects/*/apps/*) + ARG="$(realpath --relative-to="$ROOT" "$CWD")" + ;; + *) + sqlite3 "$ROOT/registry.db" "SELECT id, dir_path FROM apps ORDER BY id;" + echo "Especifica app_id o dir_path" + exit 1 + ;; + esac +fi + +# Resolver a (id, dir_path) +if echo "$ARG" | grep -q "^apps/\|^projects/"; then + APP_DIR="$ARG" + APP_ID=$(sqlite3 "$ROOT/registry.db" "SELECT id FROM apps WHERE dir_path = '$ARG';") +else + APP_ID="$ARG" + APP_DIR=$(sqlite3 "$ROOT/registry.db" "SELECT dir_path FROM apps WHERE id = '$ARG';") +fi + +[ -z "$APP_ID" ] || [ -z "$APP_DIR" ] && { echo "App no encontrada: $ARG"; exit 1; } +``` + +### 2. Verificar contrato `e2e_checks` + +```bash +HAS_CHECKS=$(awk '/^e2e_checks:/,/^[a-z_]+:|^---$/' "$ROOT/$APP_DIR/app.md" | grep -c "^ - id:") + +if [ "$HAS_CHECKS" -eq 0 ]; then + echo "App $APP_ID no tiene e2e_checks declarados." + echo "Invocar fn-recopilador design-e2e para generar contrato:" + echo "" + echo " Agent(subagent_type=fn-recopilador, prompt=\"design-e2e $APP_DIR\")" + exit 0 +fi +``` + +### 3. Fase 3 — RECOPILAR (auditar operations.db) + +Invocar `fn-recopilador` para confirmar que los datos operativos estan integros antes de validar. Si recopilador reporta FAIL critical, NO continuar. + +``` +Agent(subagent_type=fn-recopilador, + prompt="Auditar app $APP_DIR. Reportar OK/WARN/FAIL en formato corto. + Si hay FAIL critical, advertirlo claramente. Solo lectura.") +``` + +Si reporta FAIL critical → abortar con mensaje y no llegar a fn-analizador. + +### 4. Fase 4 — ANALIZAR (correr e2e_checks) + +``` +Agent(subagent_type=fn-analizador, + prompt="Validar end-to-end la app $APP_ID (dir_path: $APP_DIR). + Leer e2e_checks del app.md, ejecutar via e2e_run_checks_go_infra, + evaluar assertions, calcular drift, persistir en e2e_runs. + triggered_by: manual. + git_sha: $(git rev-parse --short HEAD 2>/dev/null || echo '') + + Devolver veredicto caveman + run_id.") +``` + +Capturar `RUN_ID` del output. Capturar `STATUS` (`pass`|`partial`|`fail`). + +### 5. Fase 5 — MEJORAR (proposals si hay fallos) + +Solo si `STATUS != pass`: + +``` +Agent(subagent_type=fn-mejorador, + prompt="App $APP_ID tuvo fallos en run_id $RUN_ID. + Leer e2e_runs y summary_json de $APP_DIR/operations.db. + Por cada fail critical: crear proposal kind=new_function|improve_function + en registry.db con created_by=reactive_loop, evidence con run_id+check_id. + Sugerir fix concreto en description. + Devolver lista de proposal_ids creados.") +``` + +Capturar `PROPOSAL_IDS`. + +### 6. Reporte final al usuario + +Tabla resumen: + +``` +=== /validate-app: $APP_ID === + +Fase 3 RECOPILAR: ✓ datos operativos integros +Fase 4 ANALIZAR: (run_id: ) +

/ checks pass, warn, fail +Fase 5 MEJORAR: proposals creadas: + +Detalle por check: + build_frontend ✓ 42s + build_backend ✓ 18s + smoke_api ✓ 1.2s + tests_go ✗ 12s — 3/45 fails + +Siguientes pasos: + - Revisar proposals: fn proposal list -s pending + - Ver run completo: sqlite3 $APP_DIR/operations.db "SELECT * FROM e2e_runs WHERE id=''" +``` + +## Notas + +- **fn-mejorador no existe todavia** (paso 6 del issue 0068). Mientras tanto, si STATUS != pass, solo imprime el detalle del fallo y sugerir crear proposal manual. +- Si un agente subagente devuelve respuesta ambigua (no extrae RUN_ID claramente), pedir clarificacion al usuario antes de continuar. +- Para apps sin `operations.db` (ej. kanban usa `kanban.db`), `e2e_runs` se persiste en `/tmp/_e2e_runs.db` con la misma migracion 005. +- Caveman OK en stdout salvo en mensajes de error donde claridad supera brevedad. +- Tras correr la cadena, NO commitear nada automaticamente. La decision de mergear es del humano. diff --git a/.claude/rules/INDEX.md b/.claude/rules/INDEX.md index 8822f3a8..da1c3bf2 100644 --- a/.claude/rules/INDEX.md +++ b/.claude/rules/INDEX.md @@ -29,3 +29,4 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente. | 23 | [fn_doctor.md](fn_doctor.md) | `fn doctor`: diagnostico read-only de artefactos, services, sync drift, uses_functions, unused — wrappers de funciones del registry | | 24 | [feature_flags.md](feature_flags.md) | TBD: feature flags para mergear codigo incompleto sin romper master. Patrones por stack (Go/TS/Bash/Py), branch-by-abstraction, anti-patrones | | 25 | [db_migrations.md](db_migrations.md) | Migraciones SQLite obligatorias para cualquier cambio de schema. Aditivas, idempotentes, archivos numerados. Nunca borrar .db ni modificar migraciones existentes | +| 26 | [e2e_validation.md](e2e_validation.md) | Contrato `e2e_checks` en `app.md` consumido por fn-analizador (fase 4 del bucle reactivo). Issue 0068 | diff --git a/.claude/rules/cpp_apps.md b/.claude/rules/cpp_apps.md index f98fddb1..aad892cc 100644 --- a/.claude/rules/cpp_apps.md +++ b/.claude/rules/cpp_apps.md @@ -192,3 +192,20 @@ Si la app tiene componentes que se quieren proteger contra regresiones visuales, - `about` info: nombre, version (semver), descripcion 1 frase. - Persistencia: `.db` SQLite junto al exe; nunca tocar `registry.db` ni `operations.db` salvo lectura. - Modo CLI: si la app acepta args, documentarlos en el `app.md` con ejemplos. + +### 10. Layouts persistentes (default) + +`fn::run_app` provee menu Layouts (Save current as.../Apply/Delete/Reset) sin +codigo. Crea `/local_files/layouts.db` (tabla `imgui_layouts`) y +persiste el `imgui.ini` serializado por nombre. + +- App nueva: nada que tocar — Layouts viene activo. +- App quiere personalizar `on_reset` (ej. re-mostrar paneles especificos como + `shaders_lab`): abre su propio `LayoutStorage`, llama + `layout_storage_make_callbacks`, override `on_reset`, y pasa + `cfg.layouts_cb = &cb`. Cuando se pasa `layouts_cb`, el auto-storage se + desactiva y la app es responsable de `layout_storage_apply_pending` al + inicio de su `render`. +- App headless / capture mode: `cfg.auto_layouts = false`. +- Cambiar nombre del archivo: `cfg.auto_layouts_db = ".db"` (relativo a + `local_files/`). diff --git a/.claude/rules/e2e_validation.md b/.claude/rules/e2e_validation.md new file mode 100644 index 00000000..be107663 --- /dev/null +++ b/.claude/rules/e2e_validation.md @@ -0,0 +1,162 @@ +## Validacion end-to-end de apps (bucle reactivo, fase 4) + +**Contrato obligatorio para apps que vayan a master con gate automatico**: declarar `e2e_checks` en su `app.md`. Sin contrato, `fn-analizador` no puede validar y la app cae al modo "manual": el humano sigue iterando. + +Ver tambien: `apps_tbd.md`, `feature_flags.md`, issue 0068. + +### Por que + +El bucle reactivo del registry tiene 5 fases. Las 3 primeras (`fn-constructor`, `fn-executor`, `fn-recopilador`) cubren CONSTRUIR/EJECUTAR/RECOPILAR. La fase 4 (ANALIZAR) y la 5 (MEJORAR) no funcionan sin un contrato explicito de "como sabe el agente que esta app esta sana". Ese contrato es `e2e_checks`. + +### Donde vive + +En el frontmatter de cada `app.md`, lista `e2e_checks`. Convencion: `id` unico por check, ejecucion en orden declarado, falla = stop o continue segun severidad (TBD por implementar). + +### Tipos de check + +| Campo | Que hace | +|---|---| +| `id` | Identificador unico del check dentro de la app (`build`, `smoke`, `tests_unit`, ...) | +| `cmd` | Comando shell. Exit 0 = pass salvo override de `expect_exit`. | +| `health` | URL HTTP. Hace GET, espera 200, util tras un `cmd` que arranca un servicio en background (con `&`). | +| `ref` | Referencia a otro agente / funcion del registry (ej. `fn-recopilador:apps/X`, `fn-doctor:artefacts`). | +| `timeout_s` | Timeout en segundos. Default 60. | +| `expect_exit` | Codigo de salida esperado (default 0). | +| `expect_stdout_contains` | Substring que debe aparecer en stdout. | +| `expect_stdout_json` | JSONPath o key=value que debe satisfacer la salida. | +| `severity` | `critical` (default) o `warning`. Critical = bloquea merge; warning = registra y sigue. | + +### Patrones por stack + +#### Go service con frontend embebido + +```yaml +e2e_checks: + - id: build_frontend + cmd: "cd frontend && pnpm install --frozen-lockfile && pnpm build" + timeout_s: 180 + - id: build_backend + cmd: "CGO_ENABLED=1 go build -tags fts5 -o myapp ." + - id: smoke + cmd: "./myapp --port 8200 --db /tmp/myapp_e2e.db &" + health: "http://127.0.0.1:8200/api/health" + - id: tests + cmd: "go test -tags fts5 -count=1 ./..." +``` + +#### C++ ImGui app + +```yaml +e2e_checks: + - id: build + cmd: "cmake --build build --target myapp -j" + timeout_s: 300 + - id: self_test + cmd: "./build/myapp --self-test" + timeout_s: 30 + - id: pytest + cmd: "cd tests && python3 -m pytest -x -q" +``` + +Apps C++ deben implementar `--self-test` que arranca, verifica subsistemas (GL loader, fonts, DBs locales), y sale con codigo 0/1. + +#### Python pipeline / CLI + +```yaml +e2e_checks: + - id: import + cmd: "python3 -c 'import myapp'" + - id: cli_help + cmd: "python3 -m myapp --help" + expect_stdout_contains: "usage:" + - id: dry_run + cmd: "python3 -m myapp --dry-run --input examples/sample.json" +``` + +#### App con operations.db + +Anadir siempre: + +```yaml + - id: ops_audit + ref: "fn-recopilador:apps/myapp" +``` + +Esto invoca al recopilador en modo audit sobre `apps/myapp/operations.db`. + +### Reglas + +1. **Idempotente**: cada check debe poderse correr N veces sin efectos secundarios. Usar BDs en `/tmp/`, puertos altos, `--port 0` cuando se pueda. +2. **Sin credenciales reales**: ningun check toca produccion ni servicios externos sensibles. Si necesita HTTP de prueba, usar `httpbin.org` o un mock local. +3. **Tiempo acotado**: cada check declara `timeout_s`. Suma total de la app < 10 min como objetivo razonable. +4. **Determinista**: si el check depende de red flaky, marcalo `severity: warning` o usalo solo como diagnostico, no como gate. +5. **Cleanup implicito**: si el check arranca un proceso en background (`&`), debe morir al final. `fn-analizador` mata el grupo de procesos al terminar la suite. + +### Como diseñar `e2e_checks` para una app existente + +`fn-recopilador` tiene un modo `design-e2e ` que: + +1. Inspecciona `app.md` (lang, framework, entry_point, uses_functions). +2. Revisa estructura del directorio (presencia de `tests/`, `frontend/`, `Makefile`, `CMakeLists.txt`, etc.). +3. Audita `operations.db` (si existe) para sugerir `ops_audit`. +4. Devuelve bloque `e2e_checks_suggested:` listo para copiar al `app.md` tras revision humana. + +Comando indicativo: +``` +Agent(subagent_type="fn-recopilador", + prompt="design-e2e apps/") +``` + +El recopilador NO escribe directo al `app.md`; deja la propuesta para que el humano apruebe (similar a `proposals`). + +### Adopcion gradual + +- Apps SIN `e2e_checks` declarado: `fn doctor` muestra warning, no bloquea nada. +- Apps CON `e2e_checks`: `fn-analizador` corre la suite. Si critical falla → `fn-mejorador` crea proposal. Gate opcional en `/git-push`. +- Pilotos iniciales: `apps/kanban`, `projects/osint_graph/apps/graph_explorer`. Resto de apps van migrando segun necesidad. + +### Anti-patrones + +| Anti-patron | Por que es malo | +|---|---| +| `cmd: "make test"` con make-target opaco | Ilegible. El check debe ser ejecutable directo y auditable. | +| Check que tarda > 5 min sin razon (smoke pesado) | Bloquea iteracion. Mover a CI nocturno con tag `slow`. | +| Smoke que toca produccion | Riesgo. Smoke usa BD efimera, puertos altos, mocks. | +| `expect_stdout_contains: ""` | Vacio = siempre pass. No es un check. | +| Anidar checks (uno depende de side-effects de otro sin declararlo) | Frigil. Cada check arranca lo que necesita. | +| Usar `e2e_checks` como sustituto de tests unitarios | Son cosas distintas. Unit tests viven en `*_test.go`/`pytest`. e2e valida que el sistema arranque y haga su trabajo. | + +### Tabla `e2e_runs` en operations.db + +Cada corrida de `fn-analizador` se persiste: + +```sql +CREATE TABLE IF NOT EXISTS e2e_runs ( + id TEXT PRIMARY KEY, + app_id TEXT NOT NULL, + started_at INTEGER NOT NULL, + finished_at INTEGER, + status TEXT NOT NULL, -- pass|fail|partial + checks_total INTEGER NOT NULL, + checks_pass INTEGER NOT NULL, + checks_fail INTEGER NOT NULL, + summary_json TEXT NOT NULL +); +``` + +Migracion: `fn_operations/migrations/006_e2e_runs.sql` (issue 0068, paso 3). + +### Output canonico de fn-analizador + +Tabla caveman, una linea por check: + +``` +build ✓ 42s +smoke ✓ 0.8s +ops_audit ✓ +tests ✗ 12s exit 1, 3/45 failures +assertion:R1 ✗ warning duration drift +47% vs p50 +golden:home ✓ +``` + +Rojo cuando `severity: critical` y status fail. Esto es lo que el agente principal lee y reenvia al humano. diff --git a/.claude/scripts/hook_registry_mcp.sh b/.claude/scripts/hook_registry_mcp.sh new file mode 100755 index 00000000..23fa8121 --- /dev/null +++ b/.claude/scripts/hook_registry_mcp.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# PreToolUse hook: NO bloquea. Inyecta recordatorio cuando ve sqlite3 sobre registry.db +# para que el modelo prefiera el MCP `registry` la proxima vez. + +input="$(cat)" +cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // ""')" + +# Solo nos importa registry.db (NO operations.db, NO otros .db). +if ! printf '%s' "$cmd" | grep -Eq 'sqlite3[^|]*\bregistry\.db\b'; then + exit 0 +fi + +# Casos legitimos donde el MCP no aplica: introspeccion de schema, agregaciones, JOINs. +if printf '%s' "$cmd" | grep -Eq '(\.schema|\.tables|PRAGMA[[:space:]]+(table_info|index_list))'; then + exit 0 +fi +if printf '%s' "$cmd" | grep -Eqi '(COUNT\(|GROUP[[:space:]]+BY|JOIN[[:space:]])'; then + exit 0 +fi + +# Caso a redirigir: emitir nota como additionalContext y dejar pasar el comando. +jq -n '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + additionalContext: "Aviso: sqlite3 directo sobre registry.db detectado. Para futuras consultas usa el MCP registry (mcp__registry__fn_search / fn_show / fn_code / fn_uses / fn_list_domains). Fallback a sqlite3 SOLO para .schema, PRAGMA, COUNT/GROUP BY, JOINs custom." + } +}' +exit 0 diff --git a/bash/functions/infra/build_cpp_windows.sh b/bash/functions/infra/build_cpp_windows.sh index 9d52db97..0615cad6 100644 --- a/bash/functions/infra/build_cpp_windows.sh +++ b/bash/functions/infra/build_cpp_windows.sh @@ -1,34 +1,51 @@ #!/usr/bin/env bash +# build_cpp_windows — Cross-compila apps C++ del registry para Windows con +# mingw-w64. Configura el build dir cpp/build/windows/ con la toolchain la +# primera vez y construye el target indicado (o todos). +# +# Uso (funcion via source): +# source bash/functions/infra/build_cpp_windows.sh +# build_cpp_windows my_app # construye target especifico +# build_cpp_windows # construye todos +# +# Uso (script directo): +# bash bash/functions/infra/build_cpp_windows.sh my_app + set -euo pipefail -REGISTRY_ROOT="${FN_REGISTRY_ROOT:-$(cd "$(dirname "$0")/../../.." && pwd)}" -CPP_ROOT="$REGISTRY_ROOT/cpp" -BUILD_DIR="$CPP_ROOT/build/windows" -TOOLCHAIN="$CPP_ROOT/toolchains/mingw-w64.cmake" -TARGET="${1:-}" +build_cpp_windows() { + local target="${1:-}" + local registry_root="${FN_REGISTRY_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}" + local cpp_root="$registry_root/cpp" + local build_dir="${BUILD_WIN:-$cpp_root/build/windows}" + local toolchain="$cpp_root/toolchains/mingw-w64.cmake" -# Check mingw is available -if ! command -v x86_64-w64-mingw32-g++ &>/dev/null; then - echo "[build_cpp_windows] Error: mingw-w64 not found. Install with: sudo apt install mingw-w64" - exit 1 -fi + if ! command -v x86_64-w64-mingw32-g++ &>/dev/null; then + echo "[build_cpp_windows] Error: mingw-w64 not found. Install with: sudo apt install mingw-w64" >&2 + return 1 + fi -# Configure if needed -if [ ! -f "$BUILD_DIR/CMakeCache.txt" ]; then - echo "[build_cpp_windows] Configuring cmake with mingw-w64 toolchain..." - cmake -B "$BUILD_DIR" -S "$CPP_ROOT" -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" -fi + if [ ! -f "$build_dir/CMakeCache.txt" ]; then + echo "[build_cpp_windows] Configuring cmake with mingw-w64 toolchain..." >&2 + cmake -B "$build_dir" -S "$cpp_root" -DCMAKE_TOOLCHAIN_FILE="$toolchain" + else + # Re-run cmake to pick up new add_subdirectory entries cuando se anade + # una app nueva al CMakeLists.txt (no rompe builds incrementales). + cmake "$build_dir" >/dev/null + fi -# Build -if [ -n "$TARGET" ]; then - echo "[build_cpp_windows] Cross-compiling target: $TARGET" - cmake --build "$BUILD_DIR" --target "$TARGET" -- -j"$(nproc)" -else - echo "[build_cpp_windows] Cross-compiling all targets..." - cmake --build "$BUILD_DIR" -- -j"$(nproc)" -fi + if [ -n "$target" ]; then + echo "[build_cpp_windows] Cross-compiling target: $target" >&2 + cmake --build "$build_dir" --target "$target" -- -j"$(nproc)" + else + echo "[build_cpp_windows] Cross-compiling all targets..." >&2 + cmake --build "$build_dir" -- -j"$(nproc)" + fi -echo "[build_cpp_windows] Done. Windows binaries in $BUILD_DIR" -if [ -n "$TARGET" ]; then - file "$BUILD_DIR"/**/"$TARGET".exe 2>/dev/null || file "$BUILD_DIR/$TARGET".exe 2>/dev/null || true + echo "[build_cpp_windows] Done. Windows binaries in $build_dir" >&2 +} + +# Invocacion directa como script (compatibilidad). +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + build_cpp_windows "$@" fi diff --git a/bash/functions/infra/e2e_run_cpp_windows.md b/bash/functions/infra/e2e_run_cpp_windows.md new file mode 100644 index 00000000..7dc5424a --- /dev/null +++ b/bash/functions/infra/e2e_run_cpp_windows.md @@ -0,0 +1,48 @@ +--- +name: e2e_run_cpp_windows +lang: bash +domain: infra +description: "Cross-compila una app C++ del registry para Windows con mingw-w64, deploy al Desktop\\apps de Windows (matando instancia previa con taskkill.exe), lanza el .exe nativamente desde WSL y devuelve stdout + exit code. Pensado para tests headless tipo altsnap_jitter_test." +tags: [windows, e2e, cross-compile, test, mingw] +purity: impure +kind: function +signature: "e2e_run_cpp_windows(target string, --no-build, --no-deploy) int" +params: + - name: target + desc: "Nombre del target CMake del registry (ej. altsnap_jitter_test)" + - name: --no-build + desc: "Saltar cross-compile (usa el .exe ya construido en cpp/build/windows/)" + - name: --no-deploy + desc: "Saltar copia a Desktop\\apps (asume que ya esta deployed)" +output: "Exit code del .exe (0 = pass, no-cero = fail). stdout/stderr del .exe se imprimen tal cual." +uses_functions: + - build_cpp_windows_bash_infra +uses_types: [] +returns: "" +returns_optional: false +error_type: "exit_code_bash_core" +imports: [] +example: | + source bash/functions/infra/e2e_run_cpp_windows.sh + e2e_run_cpp_windows altsnap_jitter_test + # cross-compila, taskkill previo, copia a /mnt/c/Users/lucas/Desktop/apps/altsnap_jitter_test/ + # ejecuta y devuelve exit code +tested: false +file_path: "bash/functions/infra/e2e_run_cpp_windows.sh" +--- + +Lanzador para tests e2e de apps C++ en Windows desde WSL. Workflow: + +1. **Cross-compile** via `build_cpp_windows_bash_infra` (skipable con `--no-build`). +2. **Localiza** `${target}.exe` bajo `cpp/build/windows/apps//` o el arbol completo de build. +3. **Mata instancia previa** con `taskkill.exe /IM .exe /F` (evita `Permission denied` al copiar el exe). +4. **Deploy** a `/mnt/c/Users/lucas/Desktop/apps//` con sidecars (`assets/`, `runtime/`, `enrichers/`, `*.dll`). +5. **Run** nativamente desde WSL (`./target.exe` con cwd en deploy_dir, asi `local_files/` se crea ahi). +6. **Exit code** del .exe propaga al return de la funcion. + +Variables de entorno: +- `FN_REGISTRY_ROOT` — raiz del registry (auto-detectado). +- `BUILD_WIN` — directorio de build cross (default `cpp/build/windows`). +- `WIN_DESKTOP_APPS` — root de deploy en Windows (default `/mnt/c/Users/lucas/Desktop/apps`). + +Requiere WSL2 con interop a Windows (cmd.exe, taskkill.exe en PATH) y mingw-w64. diff --git a/bash/functions/infra/e2e_run_cpp_windows.sh b/bash/functions/infra/e2e_run_cpp_windows.sh new file mode 100755 index 00000000..bf866c1e --- /dev/null +++ b/bash/functions/infra/e2e_run_cpp_windows.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# e2e_run_cpp_windows — Cross-compile a C++ app del registry para Windows +# con mingw-w64, deploy al Desktop de Windows (matando una posible instancia +# previa con taskkill.exe), lanzar el .exe nativamente desde WSL y devolver +# stdout + exit code. Pensado para apps tipo headless smoke / regression +# test (ej. altsnap_jitter_test) que arrancan, ejecutan un guion y salen. +# +# Uso (funcion via source): +# source bash/functions/infra/e2e_run_cpp_windows.sh +# e2e_run_cpp_windows altsnap_jitter_test # build + deploy + run +# e2e_run_cpp_windows altsnap_jitter_test --no-build # solo deploy + run +# e2e_run_cpp_windows altsnap_jitter_test --no-deploy # solo run (asume ya esta en Desktop) +# +# Requisitos: +# - WSL2 con interop a Windows habilitado (cmd.exe / taskkill.exe en PATH). +# - mingw-w64 instalado: sudo apt install mingw-w64 +# - cpp/build/windows/ configurable via build_cpp_windows.sh. +# - C:\Users\lucas\Desktop accesible bajo /mnt/c/Users/lucas/Desktop. +# +# Salida: +# - stdout/stderr del .exe se imprimen tal cual. +# - Exit code de la funcion = exit code del .exe (0 = pass). + +e2e_run_cpp_windows() { + set -euo pipefail + local target="${1:-}" + if [ -z "$target" ]; then + echo "[e2e_run_cpp_windows] Uso: e2e_run_cpp_windows [--no-build] [--no-deploy]" >&2 + return 2 + fi + shift + local do_build=1 + local do_deploy=1 + while [ $# -gt 0 ]; do + case "$1" in + --no-build) do_build=0 ;; + --no-deploy) do_deploy=0 ;; + *) echo "[e2e_run_cpp_windows] Flag desconocida: $1" >&2; return 2 ;; + esac + shift + done + + local registry_root="${FN_REGISTRY_ROOT:-}" + if [ -z "$registry_root" ]; then + # Walk up from cwd looking for the registry.db sentinel. + local d="$PWD" + while [ "$d" != "/" ]; do + if [ -f "$d/registry.db" ] && [ -d "$d/cpp" ]; then + registry_root="$d"; break + fi + d="$(dirname "$d")" + done + fi + if [ -z "$registry_root" ]; then + echo "[e2e_run_cpp_windows] No se localiza la raiz del registry. Exporta FN_REGISTRY_ROOT." >&2 + return 2 + fi + local cpp_root="$registry_root/cpp" + local build_dir="${BUILD_WIN:-$cpp_root/build/windows}" + local desktop_root="${WIN_DESKTOP_APPS:-/mnt/c/Users/lucas/Desktop/apps}" + local deploy_dir="$desktop_root/$target" + + # 1. Cross-compile. + if [ "$do_build" -eq 1 ]; then + echo "[e2e_run_cpp_windows] cross-compile target=$target" >&2 + # Propagate registry_root so build_cpp_windows doesn't trip over its + # own BASH_SOURCE-based detection. + export FN_REGISTRY_ROOT="$registry_root" + # shellcheck source=./build_cpp_windows.sh + source "$registry_root/bash/functions/infra/build_cpp_windows.sh" + build_cpp_windows "$target" + fi + + # 2. Locate built .exe. + local exe_src + exe_src="$(find "$build_dir/apps/$target" -maxdepth 2 -name "${target}.exe" -type f 2>/dev/null | head -1 || true)" + if [ -z "$exe_src" ]; then + # Fallback: search the whole build tree (some targets land elsewhere). + exe_src="$(find "$build_dir" -name "${target}.exe" -type f 2>/dev/null | head -1 || true)" + fi + if [ -z "$exe_src" ]; then + echo "[e2e_run_cpp_windows] No se encontro ${target}.exe en $build_dir" >&2 + return 1 + fi + echo "[e2e_run_cpp_windows] exe: $exe_src" >&2 + + # 3. Deploy a Desktop\apps\. + if [ "$do_deploy" -eq 1 ]; then + # Mata instancia previa si esta corriendo (evita "Permission denied" al cp). + if command -v taskkill.exe &>/dev/null; then + taskkill.exe /IM "${target}.exe" /F >/dev/null 2>&1 || true + fi + mkdir -p "$deploy_dir" + cp -f "$exe_src" "$deploy_dir/" + # Copia assets si existen junto al exe (TTFs, runtime, ...). + local exe_dir + exe_dir="$(dirname "$exe_src")" + for sidecar in assets runtime enrichers; do + if [ -d "$exe_dir/$sidecar" ]; then + cp -rf "$exe_dir/$sidecar" "$deploy_dir/" + fi + done + # DLLs sueltos (mingw runtime, sqlite, etc.) si los hubiera. + find "$exe_dir" -maxdepth 1 -name "*.dll" -exec cp -f {} "$deploy_dir/" \; 2>/dev/null || true + echo "[e2e_run_cpp_windows] deployed -> $deploy_dir" >&2 + fi + + # 4. Run desde WSL. cd al deploy_dir para que exe_dir() apunte al sitio + # correcto (local_files/imgui.ini se crea ahi). + if [ ! -x "$deploy_dir/${target}.exe" ]; then + echo "[e2e_run_cpp_windows] No hay ${target}.exe en $deploy_dir" >&2 + return 1 + fi + echo "[e2e_run_cpp_windows] launch $deploy_dir/${target}.exe" >&2 + ( + cd "$deploy_dir" + ./"${target}.exe" + ) + local rc=$? + echo "[e2e_run_cpp_windows] exit=$rc" >&2 + return "$rc" +} + +# Invocacion directa como script. +if [ "${BASH_SOURCE[0]:-}" = "${0:-}" ] && [ -n "${BASH_SOURCE[0]:-}" ]; then + e2e_run_cpp_windows "$@" +fi diff --git a/bash/functions/infra/keepass_delete.md b/bash/functions/infra/keepass_delete.md new file mode 100644 index 00000000..64505b15 --- /dev/null +++ b/bash/functions/infra/keepass_delete.md @@ -0,0 +1,36 @@ +--- +name: keepass_delete +kind: function +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "keepass_delete(entry: string)" +description: "Elimina una entry del KeePassXC database via keepassxc-cli rm. La entry pasa a la papelera dentro del .kdbx (no se borra fisicamente)." +tags: [keepass, keepassxc, secret, credential, delete] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: entry + desc: "path de la entry a eliminar" +output: "ninguno (exit 0 si OK)" +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/keepass_delete.sh" +--- + +## Ejemplo + +```bash +source keepass_delete.sh +keepass_delete "Servers/old-server" +``` + +## Notas + +- KeePassXC mueve a Recycle Bin interno por defecto. Vaciar manualmente desde GUI si quieres borrado fisico. diff --git a/bash/functions/infra/keepass_delete.sh b/bash/functions/infra/keepass_delete.sh new file mode 100644 index 00000000..8e5b6a26 --- /dev/null +++ b/bash/functions/infra/keepass_delete.sh @@ -0,0 +1,44 @@ +# keepass_delete +# -------------- +# Elimina una entry del KeePassXC database. +# +# REQUIERE: +# - keepassxc-cli instalado +# - KEEPASS_DB (env): ruta absoluta al .kdbx +# - master password en pass o env KEEPASS_PASSWORD +# +# USO (sourced): +# source keepass_delete.sh +# keepass_delete "Servers/old-server" + +keepass_delete() { + local entry="$1" + + if [ -z "$entry" ]; then + echo "keepass_delete: se requiere entry" >&2 + return 1 + fi + + local db="${KEEPASS_DB:-}" + if [ -z "$db" ] || [ ! -f "$db" ]; then + echo "keepass_delete: KEEPASS_DB no valida: $db" >&2 + return 1 + fi + + local master + if [ -n "${KEEPASS_PASSWORD:-}" ]; then + master="$KEEPASS_PASSWORD" + else + master=$(pass show "${KEEPASS_MASTER_ENTRY:-meta/keepassxc-master}" 2>/dev/null | head -n1) + if [ -z "$master" ]; then + echo "keepass_delete: no master pass" >&2 + return 1 + fi + fi + + printf '%s\n' "$master" | keepassxc-cli rm -q "$db" "$entry" >/dev/null 2>&1 + if [ $? -ne 0 ]; then + echo "keepass_delete: fallo al borrar '$entry'" >&2 + return 1 + fi +} diff --git a/bash/functions/infra/keepass_dump.md b/bash/functions/infra/keepass_dump.md new file mode 100644 index 00000000..802288bf --- /dev/null +++ b/bash/functions/infra/keepass_dump.md @@ -0,0 +1,46 @@ +--- +name: keepass_dump +kind: function +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "keepass_dump() -> json" +description: "Exporta toda la BD KeePassXC como array JSON. Una sola apertura del .kdbx via keepassxc-cli export -f xml + python3 etree para parsear. Cada elemento: {path,title,username,password,url,notes}." +tags: [keepass, keepassxc, dump, export, batch] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: [] +output: "array JSON de objetos {path,title,username,password,url,notes}" +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/keepass_dump.sh" +--- + +## Ejemplo + +```bash +source keepass_dump.sh +data=$(keepass_dump) + +# Filtrar por grupo +echo "$data" | jq '.[] | select(.path | startswith("Servers/"))' + +# Solo passwords no vacios +echo "$data" | jq '.[] | select(.password != "")' + +# Contar +echo "$data" | jq 'length' +``` + +## Notas + +- KeePassXC 2.6.x export solo soporta `xml` y `csv` (no JSON nativo). Por eso pasamos por python3. +- 2.7.0+ tiene `-f json` directo; este wrapper sigue funcionando. +- Output ya descifrado (master password aplicada en export). El atributo `Protected="True"` del XML solo es marker. +- El leading "Root" del KDBX se omite en `path`. diff --git a/bash/functions/infra/keepass_dump.sh b/bash/functions/infra/keepass_dump.sh new file mode 100644 index 00000000..6a54182b --- /dev/null +++ b/bash/functions/infra/keepass_dump.sh @@ -0,0 +1,91 @@ +# keepass_dump +# ------------ +# Exporta toda la BD KeePassXC como array JSON. Una sola apertura del .kdbx. +# Cada elemento: {path, title, username, password, url, notes}. +# +# REQUIERE: +# - keepassxc-cli instalado +# - python3 (stdlib xml.etree) +# - KEEPASS_DB (env): ruta absoluta al .kdbx +# - master password en pass o env KEEPASS_PASSWORD +# +# USO (sourced): +# source keepass_dump.sh +# data=$(keepass_dump) +# echo "$data" | jq '.[] | select(.path | startswith("Servers/"))' + +keepass_dump() { + local db="${KEEPASS_DB:-}" + if [ -z "$db" ] || [ ! -f "$db" ]; then + echo "keepass_dump: KEEPASS_DB no valida: $db" >&2 + return 1 + fi + + local master + if [ -n "${KEEPASS_PASSWORD:-}" ]; then + master="$KEEPASS_PASSWORD" + else + master=$(pass show "${KEEPASS_MASTER_ENTRY:-meta/keepassxc-master}" 2>/dev/null | head -n1) + if [ -z "$master" ]; then + echo "keepass_dump: no master pass" >&2 + return 1 + fi + fi + + local xml + xml=$(printf '%s\n' "$master" | keepassxc-cli export -q -f xml "$db" 2>/dev/null) + if [ $? -ne 0 ] || [ -z "$xml" ]; then + echo "keepass_dump: export xml fallo (master incorrecta?)" >&2 + return 1 + fi + + printf '%s' "$xml" | python3 -c ' +import sys, json, re +import xml.etree.ElementTree as ET + +root = ET.fromstring(sys.stdin.read()) +out = [] + +def clean(s): + if not s: + return "" + s = s.strip().rstrip("/") + s = s.replace("/", "_") + s = re.sub(r"\s+", "_", s) + s = re.sub(r"_+", "_", s) + s = s.strip("_") + return s + +def walk(group, path): + name_el = group.find("Name") + raw_name = name_el.text if name_el is not None and name_el.text else "" + name = clean(raw_name) + new_path = path + [name] if name and name != "Root" else path + for entry in group.findall("Entry"): + rec = {} + for s in entry.findall("String"): + k_el = s.find("Key") + v_el = s.find("Value") + if k_el is None or k_el.text is None: + continue + rec[k_el.text] = (v_el.text if v_el is not None and v_el.text else "") + title = clean(rec.get("Title", "")) + full = "/".join(new_path + [title]) if title else "/".join(new_path) + out.append({ + "path": full, + "title": title, + "username": rec.get("UserName", ""), + "password": rec.get("Password", ""), + "url": rec.get("URL", ""), + "notes": rec.get("Notes", ""), + }) + for sub in group.findall("Group"): + walk(sub, new_path) + +root_grp = root.find("Root/Group") +if root_grp is not None: + walk(root_grp, []) + +print(json.dumps(out, ensure_ascii=False)) +' +} diff --git a/bash/functions/infra/keepass_generate.md b/bash/functions/infra/keepass_generate.md new file mode 100644 index 00000000..76d48fd8 --- /dev/null +++ b/bash/functions/infra/keepass_generate.md @@ -0,0 +1,45 @@ +--- +name: keepass_generate +kind: function +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "keepass_generate(entry: string, length?: int, username?: string, url?: string) -> string" +description: "Genera un password aleatorio (lower+upper+digits+special), lo almacena en una entry nueva y lo imprime a stdout. Length default 24." +tags: [keepass, keepassxc, secret, credential, generate, random] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: entry + desc: "path de la entry a crear" + - name: length + desc: "longitud del password (default 24)" + - name: username + desc: "username opcional" + - name: url + desc: "url opcional" +output: "password generado en texto plano" +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/keepass_generate.sh" +--- + +## Ejemplo + +```bash +source keepass_generate.sh +pwd=$(keepass_generate "Servers/new-vps" 32 "deploy" "https://vps.example.com") +echo "Generated: $pwd" +``` + +## Notas + +- Genera con `keepassxc-cli generate -l -U -n -s` (lower, upper, numbers, special). +- Inserta con `keepassxc-cli add -p` reusando la misma sesion. +- El grupo padre debe existir. diff --git a/bash/functions/infra/keepass_generate.sh b/bash/functions/infra/keepass_generate.sh new file mode 100644 index 00000000..1d7aba5a --- /dev/null +++ b/bash/functions/infra/keepass_generate.sh @@ -0,0 +1,63 @@ +# keepass_generate +# ---------------- +# Genera un password aleatorio, lo almacena en una entry nueva del KeePassXC database +# y lo imprime a stdout. +# +# REQUIERE: +# - keepassxc-cli instalado +# - KEEPASS_DB (env): ruta absoluta al .kdbx +# - master password en pass o env KEEPASS_PASSWORD +# +# USO (sourced): +# source keepass_generate.sh +# pwd=$(keepass_generate "Servers/new-server") +# pwd=$(keepass_generate "Servers/new-server" 32) +# pwd=$(keepass_generate "Servers/new-server" 32 "admin" "https://new.example.com") + +keepass_generate() { + local entry="$1" + local length="${2:-24}" + local username="${3:-}" + local url="${4:-}" + + if [ -z "$entry" ]; then + echo "keepass_generate: se requiere entry" >&2 + return 1 + fi + + local db="${KEEPASS_DB:-}" + if [ -z "$db" ] || [ ! -f "$db" ]; then + echo "keepass_generate: KEEPASS_DB no valida: $db" >&2 + return 1 + fi + + local master + if [ -n "${KEEPASS_PASSWORD:-}" ]; then + master="$KEEPASS_PASSWORD" + else + master=$(pass show "${KEEPASS_MASTER_ENTRY:-meta/keepassxc-master}" 2>/dev/null | head -n1) + if [ -z "$master" ]; then + echo "keepass_generate: no master pass" >&2 + return 1 + fi + fi + + local pwd + pwd=$(keepassxc-cli generate -L "$length" -l -U -n -s 2>/dev/null) + if [ -z "$pwd" ]; then + echo "keepass_generate: fallo al generar password" >&2 + return 1 + fi + + local args=(-q -p) + [ -n "$username" ] && args+=(-u "$username") + [ -n "$url" ] && args+=(--url "$url") + + printf '%s\n%s\n' "$master" "$pwd" | keepassxc-cli add "${args[@]}" "$db" "$entry" >/dev/null 2>&1 + if [ $? -ne 0 ]; then + echo "keepass_generate: fallo al insertar '$entry'" >&2 + return 1 + fi + + printf '%s' "$pwd" +} diff --git a/bash/functions/infra/keepass_get.md b/bash/functions/infra/keepass_get.md new file mode 100644 index 00000000..bf86c119 --- /dev/null +++ b/bash/functions/infra/keepass_get.md @@ -0,0 +1,51 @@ +--- +name: keepass_get +kind: function +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "keepass_get(entry: string, attr?: string) -> string" +description: "Lee un atributo (Password por defecto) de una entry del KeePassXC database via keepassxc-cli. Resuelve master password desde pass (meta/keepassxc-master) o env KEEPASS_PASSWORD." +tags: [keepass, keepassxc, secret, credential, get] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: entry + desc: "path de la entry dentro del .kdbx (ej. 'Servers/prod-mysql')" + - name: attr + desc: "atributo a leer (Password, UserName, URL, Notes, Title); default Password" +output: "valor del atributo en texto plano (sin newline final)" +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/keepass_get.sh" +--- + +## Ejemplo + +```bash +export KEEPASS_DB="/mnt/d/Tr4Shhh_FOLDER/Sync/PssDtbs/PassDataBase.kdbx" +source keepass_get.sh + +pwd=$(keepass_get "Servers/prod-mysql") +user=$(keepass_get "Servers/prod-mysql" UserName) +``` + +## Setup + +Master password se guarda una vez en pass: + +```bash +pass insert meta/keepassxc-master +``` + +## Notas + +- Wrappea `keepassxc-cli show -s -a `. +- Cada call reabre la BD (CLI stateless). Para batch, usa `keepass_dump`. +- `KEEPASS_PASSWORD` env tiene prioridad sobre `pass`. diff --git a/bash/functions/infra/keepass_get.sh b/bash/functions/infra/keepass_get.sh new file mode 100644 index 00000000..9695c713 --- /dev/null +++ b/bash/functions/infra/keepass_get.sh @@ -0,0 +1,57 @@ +# keepass_get +# ----------- +# Lee un atributo de una entry del KeePassXC database. +# Atributo por defecto: Password. Tambien admite UserName, URL, Notes, Title, etc. +# +# REQUIERE: +# - keepassxc-cli instalado +# - KEEPASS_DB (env): ruta absoluta al .kdbx +# - master password en pass: `pass insert meta/keepassxc-master` +# o env KEEPASS_PASSWORD +# - override pass entry con KEEPASS_MASTER_ENTRY +# +# USO (sourced): +# source keepass_get.sh +# pwd=$(keepass_get "Servers/prod-mysql") +# user=$(keepass_get "Servers/prod-mysql" UserName) +# url=$(keepass_get "Servers/prod-mysql" URL) + +keepass_get() { + local entry="$1" + local attr="${2:-Password}" + + if [ -z "$entry" ]; then + echo "keepass_get: se requiere path de entry" >&2 + return 1 + fi + + local db="${KEEPASS_DB:-}" + if [ -z "$db" ]; then + echo "keepass_get: KEEPASS_DB no definida" >&2 + return 1 + fi + if [ ! -f "$db" ]; then + echo "keepass_get: db no existe: $db" >&2 + return 1 + fi + + local master + if [ -n "${KEEPASS_PASSWORD:-}" ]; then + master="$KEEPASS_PASSWORD" + else + master=$(pass show "${KEEPASS_MASTER_ENTRY:-meta/keepassxc-master}" 2>/dev/null | head -n1) + if [ -z "$master" ]; then + echo "keepass_get: no master pass (set KEEPASS_PASSWORD o pass insert ${KEEPASS_MASTER_ENTRY:-meta/keepassxc-master})" >&2 + return 1 + fi + fi + + local value + value=$(printf '%s\n' "$master" | keepassxc-cli show -q -s -a "$attr" "$db" "$entry" 2>/dev/null) + if [ $? -ne 0 ] || [ -z "$value" ]; then + echo "keepass_get: no se pudo leer '$entry' attr '$attr'" >&2 + return 1 + fi + + printf '%s' "$value" +} diff --git a/bash/functions/infra/keepass_list.md b/bash/functions/infra/keepass_list.md new file mode 100644 index 00000000..875fc9fc --- /dev/null +++ b/bash/functions/infra/keepass_list.md @@ -0,0 +1,42 @@ +--- +name: keepass_list +kind: function +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "keepass_list(prefix?: string) -> json" +description: "Lista paths de entries del KeePassXC database como array JSON. Filtra opcionalmente por prefijo de grupo. Internamente usa keepass_dump y proyecta solo los paths." +tags: [keepass, keepassxc, list] +uses_functions: + - keepass_dump_bash_infra +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: prefix + desc: "prefijo de path para filtrar (ej. 'Servers/'); vacio = todas" +output: "array JSON de strings con paths" +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/keepass_list.sh" +--- + +## Ejemplo + +```bash +source keepass_dump.sh +source keepass_list.sh + +all=$(keepass_list) +servers=$(keepass_list "Servers/") +echo "$servers" | jq -r '.[]' +``` + +## Notas + +- Auto-sourcea `keepass_dump.sh` desde el mismo directorio si no esta cargado. +- Para acceder a campos completos (password, username, url) usa `keepass_dump` directo. diff --git a/bash/functions/infra/keepass_list.sh b/bash/functions/infra/keepass_list.sh new file mode 100644 index 00000000..8f8e26ed --- /dev/null +++ b/bash/functions/infra/keepass_list.sh @@ -0,0 +1,39 @@ +# keepass_list +# ------------ +# Lista paths de entries del KeePassXC database como array JSON. +# Filtra opcionalmente por prefijo de grupo. +# +# REQUIERE: +# - keepass_dump (sourced o en PATH del registry) +# - jq instalado +# +# USO (sourced): +# source keepass_dump.sh +# source keepass_list.sh +# all=$(keepass_list) +# servers=$(keepass_list "Servers/") + +keepass_list() { + local prefix="$1" + + if ! declare -F keepass_dump >/dev/null 2>&1; then + local here + here=$(dirname "${BASH_SOURCE[0]}") + if [ -f "$here/keepass_dump.sh" ]; then + # shellcheck source=keepass_dump.sh + source "$here/keepass_dump.sh" + else + echo "keepass_list: keepass_dump no disponible" >&2 + return 1 + fi + fi + + local dump + dump=$(keepass_dump) || return 1 + + if [ -n "$prefix" ]; then + printf '%s' "$dump" | jq --arg p "$prefix" '[.[] | .path | select(startswith($p))]' + else + printf '%s' "$dump" | jq '[.[] | .path]' + fi +} diff --git a/bash/functions/infra/keepass_search.md b/bash/functions/infra/keepass_search.md new file mode 100644 index 00000000..c8bed72f --- /dev/null +++ b/bash/functions/infra/keepass_search.md @@ -0,0 +1,41 @@ +--- +name: keepass_search +kind: function +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "keepass_search(term: string) -> json" +description: "Busca entries en el KeePassXC database por substring. Devuelve array JSON de paths que matchean (title/username/url/notes)." +tags: [keepass, keepassxc, search, query] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: term + desc: "substring a buscar (case-insensitive)" +output: "array JSON de strings con paths matched, ej: [\"Servers/prod\", \"Web/github\"]" +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/keepass_search.sh" +--- + +## Ejemplo + +```bash +source keepass_search.sh +matches=$(keepass_search "github") +# [ +# "Web/github-personal", +# "Web/github-work" +# ] +``` + +## Notas + +- Wrappea `keepassxc-cli search`. +- El leading `/` del CLI se quita antes de devolver. diff --git a/bash/functions/infra/keepass_search.sh b/bash/functions/infra/keepass_search.sh new file mode 100644 index 00000000..3c95d569 --- /dev/null +++ b/bash/functions/infra/keepass_search.sh @@ -0,0 +1,50 @@ +# keepass_search +# -------------- +# Busca entries en el KeePassXC database por substring (en title, username, url, notes). +# Devuelve un array JSON de paths que matchean. +# +# REQUIERE: +# - keepassxc-cli instalado +# - jq instalado +# - KEEPASS_DB (env): ruta absoluta al .kdbx +# - master password en pass o env KEEPASS_PASSWORD +# +# USO (sourced): +# source keepass_search.sh +# matches=$(keepass_search "github") +# echo "$matches" | jq . + +keepass_search() { + local term="$1" + + if [ -z "$term" ]; then + echo "keepass_search: se requiere termino de busqueda" >&2 + return 1 + fi + + local db="${KEEPASS_DB:-}" + if [ -z "$db" ] || [ ! -f "$db" ]; then + echo "keepass_search: KEEPASS_DB no valida: $db" >&2 + return 1 + fi + + local master + if [ -n "${KEEPASS_PASSWORD:-}" ]; then + master="$KEEPASS_PASSWORD" + else + master=$(pass show "${KEEPASS_MASTER_ENTRY:-meta/keepassxc-master}" 2>/dev/null | head -n1) + if [ -z "$master" ]; then + echo "keepass_search: no master pass" >&2 + return 1 + fi + fi + + local out + out=$(printf '%s\n' "$master" | keepassxc-cli locate -q "$db" "$term" 2>/dev/null) + if [ $? -ne 0 ]; then + echo "keepass_search: keepassxc-cli locate fallo" >&2 + return 1 + fi + + printf '%s\n' "$out" | grep -v '^$' | sed 's|^/||' | jq -R . | jq -s . +} diff --git a/bash/functions/infra/keepass_set.md b/bash/functions/infra/keepass_set.md new file mode 100644 index 00000000..d259876c --- /dev/null +++ b/bash/functions/infra/keepass_set.md @@ -0,0 +1,44 @@ +--- +name: keepass_set +kind: function +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "keepass_set(entry: string, password: string, username?: string, url?: string)" +description: "Crea o sobreescribe una entry en el KeePassXC database. Auto-detecta si existe (edit) o no (add). Soporta username y url opcionales." +tags: [keepass, keepassxc, secret, credential, set, write] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: entry + desc: "path de la entry (ej. 'Servers/prod-mysql'); si el grupo no existe falla" + - name: password + desc: "password en texto plano a almacenar" + - name: username + desc: "username opcional" + - name: url + desc: "url opcional" +output: "ninguno (exit 0 si OK)" +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/keepass_set.sh" +--- + +## Ejemplo + +```bash +source keepass_set.sh +keepass_set "Servers/prod-mysql" "secret123" "admin" "https://prod.example.com" +``` + +## Notas + +- Add: `keepassxc-cli add -p`. Edit: `keepassxc-cli edit -p`. +- Existencia detectada via `show -q` (exit code). +- El grupo (parte antes del ultimo `/`) debe existir; KeePassXC no auto-crea jerarquia. diff --git a/bash/functions/infra/keepass_set.sh b/bash/functions/infra/keepass_set.sh new file mode 100644 index 00000000..feb7d6a3 --- /dev/null +++ b/bash/functions/infra/keepass_set.sh @@ -0,0 +1,58 @@ +# keepass_set +# ----------- +# Crea o sobreescribe una entry en el KeePassXC database. +# Auto-detecta si existe (edit) o no (add). +# +# REQUIERE: +# - keepassxc-cli instalado +# - KEEPASS_DB (env): ruta absoluta al .kdbx +# - master password en pass o env KEEPASS_PASSWORD +# +# USO (sourced): +# source keepass_set.sh +# keepass_set "Servers/prod-mysql" "secret123" +# keepass_set "Servers/prod-mysql" "secret123" "admin" "https://prod.example.com" + +keepass_set() { + local entry="$1" + local password="$2" + local username="${3:-}" + local url="${4:-}" + + if [ -z "$entry" ] || [ -z "$password" ]; then + echo "keepass_set: se requieren entry y password" >&2 + return 1 + fi + + local db="${KEEPASS_DB:-}" + if [ -z "$db" ] || [ ! -f "$db" ]; then + echo "keepass_set: KEEPASS_DB no valida: $db" >&2 + return 1 + fi + + local master + if [ -n "${KEEPASS_PASSWORD:-}" ]; then + master="$KEEPASS_PASSWORD" + else + master=$(pass show "${KEEPASS_MASTER_ENTRY:-meta/keepassxc-master}" 2>/dev/null | head -n1) + if [ -z "$master" ]; then + echo "keepass_set: no master pass" >&2 + return 1 + fi + fi + + local cmd="add" + if printf '%s\n' "$master" | keepassxc-cli show -q "$db" "$entry" >/dev/null 2>&1; then + cmd="edit" + fi + + local args=(-q -p) + [ -n "$username" ] && args+=(-u "$username") + [ -n "$url" ] && args+=(--url "$url") + + printf '%s\n%s\n' "$master" "$password" | keepassxc-cli "$cmd" "${args[@]}" "$db" "$entry" >/dev/null 2>&1 + if [ $? -ne 0 ]; then + echo "keepass_set: fallo al $cmd '$entry'" >&2 + return 1 + fi +} diff --git a/bash/functions/pipelines/agent_scaffold.md b/bash/functions/pipelines/agent_scaffold.md new file mode 100644 index 00000000..e65fde90 --- /dev/null +++ b/bash/functions/pipelines/agent_scaffold.md @@ -0,0 +1,133 @@ +--- +name: agent_scaffold +kind: pipeline +lang: bash +domain: pipelines +version: "1.0.0" +purity: impure +signature: "agent_scaffold(id: string, --display-name string, [--skills cat/skill,...], [--llm provider], [--model model], [--description string], [--tags tags], [--no-register], [--no-commit], [--dry-run]) -> json" +description: "Crea un agente nuevo en agents_and_robots/agents// listo para arrancar. Copia el _template/, adapta config.yaml (id, name, version, template:false, llm, skills, matrix env vars), valida skills declaradas, registra el bot en Synapse via bin/register y hace commit en el repo." +tags: [agent, scaffold, matrix, agents_and_robots, launcher] +uses_functions: + - assert_command_exists_bash_shell +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: id + desc: "Identificador del agente en snake-case o kebab-case (ej: monitor-bot, data_analyst). Debe ser unico en agents/." + - name: display-name + desc: "Nombre legible que aparece en Matrix y en el system prompt generado (ej: 'Monitor Agent')." + - name: skills + desc: "Lista de skills a habilitar, separadas por coma, en formato cat/skill-name (ej: devops/deploy-service,system/health-check). Cada skill debe existir como skills///SKILL.md." + - name: llm + desc: "LLM provider: openai (default), anthropic, o claude-code. Determina el proveedor en llm.primary.provider del config.yaml." + - name: model + desc: "Modelo LLM especifico (ej: gpt-4o, claude-sonnet-4-20250514). Si se omite, se usa el default del provider." + - name: description + desc: "Descripcion corta del agente que se escribe en agent.description del config.yaml y en el stub del system prompt." + - name: tags + desc: "Tags separados por coma para agent.tags del config.yaml (ej: monitor,ops,devops)." + - name: no-register + desc: "Flag opcional. Si esta presente, omite el paso de registro del usuario bot en Synapse." + - name: no-commit + desc: "Flag opcional. Si esta presente, omite el git commit en el repo agents_and_robots." + - name: dry-run + desc: "Flag opcional. Muestra el plan completo de ejecucion sin modificar ningún archivo." +output: "JSON con campos: status (ok/error), id, agent_dir (path relativo al registry), skills_enabled (array), registered (bool), committed (bool), message (advertencias si las hay)." +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/pipelines/agent_scaffold.sh" +--- + +## Ejemplo + +```bash +# Crear agente basico con openai +export FN_REGISTRY_ROOT=/home/lucas/fn_registry +bash bash/functions/pipelines/agent_scaffold.sh monitor-bot \ + --display-name "Monitor Agent" \ + --description "Monitorea servicios y reporta estado" \ + --tags "monitor,ops" \ + --llm openai \ + --model gpt-4o + +# Crear agente con skills habilitadas y claude-code +bash bash/functions/pipelines/agent_scaffold.sh devops-bot \ + --display-name "DevOps Bot" \ + --skills devops/deploy-service,system/health-check \ + --llm claude-code \ + --description "Bot para operaciones de infraestructura" \ + --no-register --no-commit + +# Ver plan sin modificar nada +bash bash/functions/pipelines/agent_scaffold.sh test-bot \ + --display-name "Test Bot" \ + --skills devops/deploy-service \ + --description "Bot de prueba" \ + --no-register --no-commit --dry-run +``` + +## Salida JSON + +```json +{ + "status": "ok", + "id": "monitor-bot", + "agent_dir": "projects/element_agents/apps/agents_and_robots/agents/monitor-bot", + "skills_enabled": ["devops/deploy-service"], + "registered": true, + "committed": true +} +``` + +## Estructura generada + +``` +agents// + config.yaml # Adaptado desde _template, con id/name/version/template:false + agent.go # Copiado del template (reglas puras — editar despues) + prompts/ + system.md # Stub minimo si el del template era generico + knowledge/ # Directorio creado si faltaba +``` + +## Pasos del pipeline + +1. Localizar el proyecto `agents_and_robots` en `projects/element_agents/apps/agents_and_robots/` +2. Validar que el id es valido (snake/kebab-case, sin espacios, no existe ya) +3. Copiar `agents/_template/` a `agents//`, eliminar `template_para_llm.md` y `PERSONALITIES.md` +4. Editar `config.yaml`: id, name, version, template:false, description, tags, role, llm.provider, llm.model, api_key_env, skills (si aplica), matrix user_id/tokens/crypto paths +5. Validar que cada `cat/skill` declarada en `--skills` existe como `skills///SKILL.md` +6. Crear `prompts/system.md` y `knowledge/` si no existen o son el stub del template +7. Si no `--no-register`: compilar `bin/register` si falta y ejecutar registro en Synapse +8. Si no `--no-commit`: `git add agents// && git commit "feat: scaffold agent "` +9. Emitir JSON de resultado + +## Variables de entorno requeridas + +| Variable | Requerida para | Descripcion | +|---|---|---| +| `FN_REGISTRY_ROOT` | siempre (o ejecutar desde la raiz) | Raiz del fn_registry | +| `MATRIX_ADMIN_TOKEN` | paso 7 (registro) | Token de admin de Synapse | + +## Comportamiento de errores + +- Si `agents_and_robots/` no existe: error fatal, no crea nada +- Si el id ya existe: error fatal, no sobreescribe +- Si una skill no existe: error fatal con lista de skills disponibles +- Si `bin/register` no compila o falta MATRIX_ADMIN_TOKEN: advertencia en JSON, continua sin registrar +- Si el git commit falla: advertencia en JSON, el agente queda creado en disco + +## Notas + +El pipeline NO hace push al remote ni arranca el agente. Estos pasos quedan pendientes: +- Editar `agents//agent.go` para personalizar las reglas de decision (puro) +- Editar `agents//prompts/system.md` con el system prompt real +- Registrar el blank import en `cmd/launcher/main.go` (ver `.claude/rules/create_agent.md`) +- Reconstruir el launcher: `go build -tags goolm ./...` +- Arrancar: `./dev-scripts/server/restart.sh` +- Push: `/full-git-push` desde el proyecto diff --git a/bash/functions/pipelines/agent_scaffold.sh b/bash/functions/pipelines/agent_scaffold.sh new file mode 100644 index 00000000..e19e892e --- /dev/null +++ b/bash/functions/pipelines/agent_scaffold.sh @@ -0,0 +1,524 @@ +#!/usr/bin/env bash +# agent_scaffold — Crea un agente nuevo en agents_and_robots listo para arrancar. +# Copia _template/, adapta config.yaml, valida skills, registra en Synapse, hace commit. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../shell/assert_command_exists.sh" + +# ============================================================ +# HELPERS +# ============================================================ + +_usage() { + cat >&2 <<'EOF' +Uso: agent_scaffold --display-name "" [opciones] + +Opciones: + --display-name "" Nombre legible del agente (obligatorio) + --skills cat/skill,... Habilitar skills (ej: devops/deploy-service) + --llm openai|anthropic|claude-code LLM provider (default: openai) + --model MODEL Modelo LLM (default segun provider) + --description "..." Descripcion del agente + --tags TAG1,TAG2 Tags separados por coma + --no-register No registrar en Synapse + --no-commit No hacer git commit + --dry-run Solo mostrar el plan, sin modificar nada + +Salida: JSON con status, id, agent_dir, skills_enabled, registered, committed +EOF + exit 1 +} + +_log() { echo "[agent_scaffold] $*"; } +_warn() { echo "[agent_scaffold] WARN: $*" >&2; } +_err() { echo "[agent_scaffold] ERROR: $*" >&2; return 1; } + +# Normaliza un valor YAML string (quita comillas si las tiene) +_yaml_get() { + local file="$1" key="$2" + grep -E "^[[:space:]]*${key}:" "$file" 2>/dev/null | head -1 | sed 's/.*: *//' | tr -d '"' | tr -d "'" +} + +# Reemplaza (o añade si no existe) una clave YAML de primer nivel. +# Solo funciona para claves simples (no anidadas con sed). +_yaml_set() { + local file="$1" key="$2" value="$3" + if grep -qE "^${key}:" "$file" 2>/dev/null; then + sed -i "s|^${key}:.*|${key}: ${value}|" "$file" + else + echo "${key}: ${value}" >> "$file" + fi +} + +# Emite JSON de resultado +_emit_json() { + local status="$1" id="$2" agent_dir="$3" skills_json="$4" registered="$5" committed="$6" message="${7:-}" + printf '{\n "status": "%s",\n "id": "%s",\n "agent_dir": "%s",\n "skills_enabled": %s,\n "registered": %s,\n "committed": %s' \ + "$status" "$id" "$agent_dir" "$skills_json" "$registered" "$committed" + if [[ -n "$message" ]]; then + printf ',\n "message": "%s"' "$message" + fi + printf '\n}\n' +} + +# ============================================================ +# PARSE ARGS +# ============================================================ + +agent_scaffold() { + # Valores por defecto + local id="" + local display_name="" + local skills_raw="" + local llm_provider="openai" + local llm_model="" + local description="" + local tags_raw="" + local do_register=true + local do_commit=true + local dry_run=false + + if [[ $# -eq 0 ]]; then _usage; fi + + # Primer argumento positivo = id + if [[ "$1" != --* ]]; then + id="$1" + shift + fi + + while [[ $# -gt 0 ]]; do + case "$1" in + --display-name) display_name="$2"; shift 2 ;; + --skills) skills_raw="$2"; shift 2 ;; + --llm) llm_provider="$2"; shift 2 ;; + --model) llm_model="$2"; shift 2 ;; + --description) description="$2"; shift 2 ;; + --tags) tags_raw="$2"; shift 2 ;; + --no-register) do_register=false; shift ;; + --no-commit) do_commit=false; shift ;; + --dry-run) dry_run=true; shift ;; + --display-name=*) display_name="${1#*=}"; shift ;; + --skills=*) skills_raw="${1#*=}"; shift ;; + --llm=*) llm_provider="${1#*=}"; shift ;; + --model=*) llm_model="${1#*=}"; shift ;; + --description=*) description="${1#*=}"; shift ;; + --tags=*) tags_raw="${1#*=}"; shift ;; + *) _err "Flag desconocido: $1" ;; + esac + done + + # ============================================================ + # PASO 1: Validar contexto — localizar el proyecto + # ============================================================ + local fn_root="" + if [[ -n "${FN_REGISTRY_ROOT:-}" && -d "$FN_REGISTRY_ROOT" ]]; then + fn_root="$FN_REGISTRY_ROOT" + elif [[ -f "$(pwd)/registry.db" ]]; then + fn_root="$(pwd)" + else + _err "No se puede localizar fn_registry. Setea FN_REGISTRY_ROOT o ejecuta desde la raiz del registry." + fi + + local project_dir="$fn_root/projects/element_agents/apps/agents_and_robots" + if [[ ! -d "$project_dir" ]]; then + _err "Proyecto agents_and_robots no encontrado en: $project_dir" + fi + + local agents_dir="$project_dir/agents" + local skills_base="$project_dir/skills" + + # ============================================================ + # PASO 2: Validar id + # ============================================================ + if [[ -z "$id" ]]; then + _err "El argumento es obligatorio. Uso: agent_scaffold --display-name \"Nombre\"" + fi + if [[ -z "$display_name" ]]; then + _err "--display-name es obligatorio." + fi + # Verificar formato snake-case / kebab-case (sin espacios, solo alfanum y guiones) + if [[ ! "$id" =~ ^[a-z0-9][a-z0-9_-]*[a-z0-9]$|^[a-z0-9]$ ]]; then + _err "El id '$id' no es valido. Usar lowercase, sin espacios (ej: my-agent o my_agent)." + fi + # No debe existir ya + if [[ -d "$agents_dir/$id" ]]; then + _err "El agente '$id' ya existe en: $agents_dir/$id" + fi + + # ============================================================ + # Determinar modelo por defecto segun provider + # ============================================================ + if [[ -z "$llm_model" ]]; then + case "$llm_provider" in + openai) llm_model="gpt-4o" ;; + anthropic) llm_model="claude-sonnet-4-20250514" ;; + claude-code) llm_model="" ;; # claude-code no usa model directamente + *) llm_model="gpt-4o" ;; + esac + fi + + # ============================================================ + # Parsear skills + # ============================================================ + local -a skills_list=() + if [[ -n "$skills_raw" ]]; then + IFS=',' read -ra skills_list <<< "$skills_raw" + fi + + # ============================================================ + # Parsear tags + # ============================================================ + local tags_yaml="[]" + if [[ -n "$tags_raw" ]]; then + IFS=',' read -ra tags_arr <<< "$tags_raw" + local tags_joined="" + for t in "${tags_arr[@]}"; do + t="${t// /}" # trim spaces + tags_joined+="\"$t\", " + done + tags_yaml="[${tags_joined%, }]" + fi + + # ============================================================ + # PASO 5: Validar skills (antes del dry-run check para reportar errores) + # ============================================================ + local -a valid_skills=() + local -a skill_categories=() + if [[ ${#skills_list[@]} -gt 0 ]]; then + for skill_path in "${skills_list[@]}"; do + skill_path="${skill_path// /}" # trim spaces + local skill_dir="$skills_base/$skill_path" + if [[ ! -f "$skill_dir/SKILL.md" ]]; then + _err "Skill '$skill_path' no encontrada. No existe: $skill_dir/SKILL.md"$'\n'"Skills disponibles:"$'\n'"$(find "$skills_base" -name 'SKILL.md' | sed "s|$skills_base/||" | sed 's|/SKILL.md||' | sort)" + fi + valid_skills+=("$skill_path") + # Extraer categoria (primer componente del path) + local cat="${skill_path%%/*}" + # Añadir categoria si no esta ya + local already=false + for c in "${skill_categories[@]+"${skill_categories[@]}"}"; do + [[ "$c" == "$cat" ]] && already=true && break + done + [[ "$already" == false ]] && skill_categories+=("$cat") + done + fi + + # ============================================================ + # Construir JSON de skills para output + # ============================================================ + local skills_json="[]" + if [[ ${#valid_skills[@]} -gt 0 ]]; then + local sj="" + for s in "${valid_skills[@]}"; do sj+="\"$s\", "; done + skills_json="[${sj%, }]" + fi + + local agent_dir_rel="projects/element_agents/apps/agents_and_robots/agents/$id" + local agent_dir_abs="$agents_dir/$id" + + # ============================================================ + # --dry-run: mostrar plan y salir + # ============================================================ + if [[ "$dry_run" == true ]]; then + echo "=== DRY-RUN: agent_scaffold ===" + echo "" + echo " ID: $id" + echo " Display name: $display_name" + echo " LLM provider: $llm_provider" + echo " LLM model: ${llm_model:-"(provider default)"}" + echo " Description: ${description:-"(no description)"}" + echo " Tags: ${tags_raw:-"(none)"}" + echo " Skills: ${skills_raw:-"(none)"}" + echo "" + echo "Pasos que se ejecutarian:" + echo " 1. cp -r $agents_dir/_template/ $agent_dir_abs/" + echo " 2. rm -f $agent_dir_abs/template_para_llm.md $agent_dir_abs/PERSONALITIES.md" + echo " 3. Editar config.yaml:" + echo " agent.id: $id" + echo " agent.name: $display_name" + echo " agent.version: 0.1.0" + echo " agent.template: false" + [[ -n "$description" ]] && echo " agent.description: $description" + [[ "$tags_yaml" != "[]" ]] && echo " agent.tags: $tags_yaml" + echo " llm.primary.provider: $llm_provider" + [[ -n "$llm_model" ]] && echo " llm.primary.model: $llm_model" + if [[ ${#valid_skills[@]} -gt 0 ]]; then + echo " skills.enabled: true" + echo " skills.categories: [${skill_categories[*]}]" + fi + if [[ "$do_register" == true ]]; then + echo " 4. Compilar bin/register si falta y ejecutar:" + echo " bin/register --homeserver --username $id --displayname \"$display_name\" --env-var MATRIX_TOKEN_$(echo "$id" | tr '[:lower:]-' '[:upper:]_')" + else + echo " 4. (skip registro en Synapse)" + fi + if [[ "$do_commit" == true ]]; then + echo " 5. git add agents/$id/ && git commit -m \"feat: scaffold agent $id\"" + else + echo " 5. (skip git commit)" + fi + echo "" + echo "Output JSON esperado:" + _emit_json "ok" "$id" "$agent_dir_rel" "$skills_json" "$do_register" "$do_commit" "dry-run" + return 0 + fi + + # ============================================================ + # PASO 3: Copiar template + # ============================================================ + _log "Copiando template a agents/$id/ ..." + cp -r "$agents_dir/_template/" "$agent_dir_abs/" + + # Eliminar archivos que son solo refs de la plantilla + rm -f "$agent_dir_abs/template_para_llm.md" + rm -f "$agent_dir_abs/PERSONALITIES.md" + + # Asegurar directorios obligatorios + mkdir -p "$agent_dir_abs/prompts" "$agent_dir_abs/knowledge" + + # ============================================================ + # PASO 4: Editar config.yaml + # ============================================================ + local config="$agent_dir_abs/config.yaml" + _log "Editando config.yaml ..." + + # Campos de identidad del agente + sed -i "s|^ id:.*| id: $id|" "$config" + sed -i "s|^ name:.*| name: \"$display_name\"|" "$config" + sed -i "s|^ version:.*| version: \"0.1.0\"|" "$config" + sed -i "s|^ template:.*| template: false|" "$config" + + if [[ -n "$description" ]]; then + sed -i "s|^ description:.*| description: \"$description\"|" "$config" + fi + + if [[ "$tags_yaml" != "[]" ]]; then + sed -i "s|^ tags:.*| tags: $tags_yaml|" "$config" + fi + + # Actualizar personalidad + sed -i "s|^ role:.*| role: \"$display_name\"|" "$config" + + # LLM provider y model + # Usamos awk para editar el bloque llm.primary (más seguro para YAML anidado) + local tmp_config + tmp_config=$(mktemp) + awk -v provider="$llm_provider" -v model="$llm_model" ' + /^llm:/ { in_llm=1 } + in_llm && /^ primary:/ { in_primary=1 } + in_primary && /^ provider:/ { + print " provider: " provider + next + } + in_primary && /^ model:/ && model != "" { + print " model: \"" model "\"" + next + } + in_primary && /^ [a-z]/ { in_primary=0 } + in_llm && /^[a-z]/ { in_llm=0; in_primary=0 } + { print } + ' "$config" > "$tmp_config" && mv "$tmp_config" "$config" + + # API key env segun provider + local api_key_env="" + case "$llm_provider" in + openai) api_key_env="OPENAI_API_KEY" ;; + anthropic) api_key_env="ANTHROPIC_API_KEY" ;; + claude-code) api_key_env="" ;; + esac + + if [[ -n "$api_key_env" ]]; then + tmp_config=$(mktemp) + awk -v env_var="$api_key_env" ' + /^llm:/ { in_llm=1 } + in_llm && /^ primary:/ { in_primary=1 } + in_primary && /^ api_key_env:/ { + print " api_key_env: " env_var + next + } + in_primary && /^ [a-z]/ { in_primary=0 } + in_llm && /^[a-z]/ { in_llm=0; in_primary=0 } + { print } + ' "$config" > "$tmp_config" && mv "$tmp_config" "$config" + fi + + # Skills: actualizar el bloque skills: en config.yaml + if [[ ${#valid_skills[@]} -gt 0 ]]; then + local cats_yaml="" + for c in "${skill_categories[@]}"; do cats_yaml+="\"$c\", "; done + cats_yaml="[${cats_yaml%, }]" + + tmp_config=$(mktemp) + awk -v cats="$cats_yaml" ' + /^skills:/ { in_skills=1 } + in_skills && /^ enabled:/ { + print " enabled: true" + next + } + in_skills && /^ categories:/ { + print " categories: " cats + next + } + in_skills && /^[a-z]/ { in_skills=0 } + { print } + ' "$config" > "$tmp_config" && mv "$tmp_config" "$config" + fi + + # Matrix: actualizar homeserver, user_id, tokens + local norm_id + norm_id=$(echo "$id" | tr '[:lower:]-' '[:upper:]_') + local homeserver="https://matrix-af2f3d.organic-machine.com" + local server_name="matrix-af2f3d.organic-machine.com" + + sed -i "s|^ homeserver:.*| homeserver: \"$homeserver\"|" "$config" + sed -i "s|^ user_id:.*| user_id: \"@${id}:${server_name}\"|" "$config" + sed -i "s|^ access_token_env:.*| access_token_env: MATRIX_TOKEN_${norm_id}|" "$config" + + # Encryption + sed -i "s|^ store_path:.*| store_path: \"./agents/${id}/data/crypto/\"|" "$config" + sed -i "s|^ pickle_key_env:.*| pickle_key_env: PICKLE_KEY_${norm_id}|" "$config" + sed -i "s|^ recovery_key_env:.*| recovery_key_env: SSSS_RECOVERY_KEY_${norm_id}|" "$config" + + _log "config.yaml actualizado." + + # ============================================================ + # PASO 6: Crear/actualizar prompts/system.md si no existe o es el stub del template + # ============================================================ + local system_prompt="$agent_dir_abs/prompts/system.md" + local needs_stub=false + + if [[ ! -f "$system_prompt" ]]; then + needs_stub=true + else + # Si el archivo viene del template y es el stub generico, reemplazarlo + if grep -q "Template Agent" "$system_prompt" 2>/dev/null; then + needs_stub=true + fi + fi + + if [[ "$needs_stub" == true ]]; then + cat > "$system_prompt" </dev/null; then + if (cd "$project_dir" && go build -o bin/register ./cmd/register/ 2>&1); then + _log "Compilado bin/register correctamente." + else + register_warn="No se pudo compilar bin/register. Registro omitido." + _warn "$register_warn" + fi + else + register_warn="go no encontrado en PATH (assert_command_exists fallo). Registro omitido." + _warn "$register_warn" + fi + fi + + if [[ -x "$register_bin" ]]; then + local admin_token="${MATRIX_ADMIN_TOKEN:-}" + if [[ -z "$admin_token" ]]; then + register_warn="MATRIX_ADMIN_TOKEN no esta definido. Registro omitido." + _warn "$register_warn" + else + local env_var_name="MATRIX_TOKEN_${norm_id}" + local register_out register_exit=0 + register_out=$( + cd "$project_dir" + "$register_bin" \ + --homeserver "$homeserver" \ + --username "$id" \ + --displayname "$display_name" \ + --env-var "$env_var_name" \ + 2>&1 + ) || register_exit=$? + + if [[ $register_exit -eq 0 ]]; then + registered=true + _log "Agente registrado en Synapse." + echo "$register_out" + else + register_warn="Registro en Synapse fallo (exit $register_exit). Agente creado pero sin credenciales Matrix." + _warn "$register_warn" + echo "$register_out" >&2 + fi + fi + fi + fi + + # ============================================================ + # PASO 8: Commit (si no --no-commit) + # ============================================================ + local committed=false + + if [[ "$do_commit" == true ]]; then + _log "Haciendo commit en el repo agents_and_robots ..." + local git_exit=0 + ( + cd "$project_dir" + git add "agents/$id/" 2>&1 + git commit -m "feat: scaffold agent ${id} + +Agente creado con agent_scaffold: +- display-name: ${display_name} +- provider: ${llm_provider} +- skills: ${skills_raw:-none} +${description:+"- description: ${description}"}" 2>&1 + ) || git_exit=$? + + if [[ $git_exit -eq 0 ]]; then + committed=true + _log "Commit creado." + else + _warn "git commit fallo (exit $git_exit). El agente fue creado pero sin commit." + fi + fi + + # ============================================================ + # PASO 9: Output JSON + # ============================================================ + local final_message="" + [[ -n "$register_warn" ]] && final_message="$register_warn" + + _emit_json "ok" "$id" "$agent_dir_rel" "$skills_json" "$registered" "$committed" "$final_message" +} + +# Ejecutar si es el script principal +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + agent_scaffold "$@" +fi diff --git a/bash/functions/pipelines/dockerize_app.md b/bash/functions/pipelines/dockerize_app.md new file mode 100644 index 00000000..de41ae72 --- /dev/null +++ b/bash/functions/pipelines/dockerize_app.md @@ -0,0 +1,154 @@ +--- +name: dockerize_app +kind: pipeline +lang: bash +domain: pipelines +version: "1.0.0" +purity: impure +signature: "dockerize_app(app_name: string, [--domain DOMAIN], [--port PORT], [--ssh-host HOST], [--remote-dir DIR], [--basic-auth USER:PASS], [--no-auth], [--no-gzip], [--env KEY=VAL]..., [--volume NAME], [--build-cmd CMD], [--standalone], [--dry-run]) -> json" +description: "Empaqueta una app Go del registry para deploy a VPS organic-machine via Docker + Traefik + Coolify. Genera Dockerfile multi-stage, docker-compose.yml, traefik-dynamic.yml con basicAuth opcional y gzip, sube via rsync al VPS y arranca el stack remoto. Replica el patron de apps/registry_api/." +tags: ["docker", "traefik", "coolify", "deploy", "pipeline", "launcher"] +uses_functions: + - generate_dockerfile_go_infra + - bcrypt_htpasswd_go_infra + - generate_compose_traefik_go_infra + - generate_traefik_dynamic_go_infra + - rsync_deploy_bash_infra + - docker_compose_remote_deploy_bash_infra + - health_check_http_go_infra + - gitea_create_repo_bash_infra + - gitea_push_directory_bash_infra +uses_types: + - ComposeTraefikConfig_go_infra + - TraefikDynamicConfig_go_infra +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: app_name + desc: "Nombre o ID parcial de la app en registry.db (ej: kanban, deploy_server). Se busca con LIKE '%' OR name=''." + - name: domain + desc: "Dominio publico completo para el router Traefik (ej: kanban.organic-machine.com). Obligatorio." + - name: port + desc: "Puerto interno del contenedor Docker (default: 8080). Debe coincidir con el puerto en que la app escucha." + - name: ssh-host + desc: "Alias o IP del host SSH destino (default: organic-machine.com). Debe estar en ~/.ssh/config o ser accesible con key auth." + - name: remote-dir + desc: "Ruta absoluta en el VPS donde se desplegara la app (default: /home/ubuntu/coolify-apps/). En modo rsync apunta al subdir de la app dentro del build root." + - name: basic-auth + desc: "Credenciales para basicAuth de Traefik en formato USER:PASS. Obligatorio si auth esta ON (defecto). Se hashea con bcrypt via htpasswd o python3+bcrypt." + - name: no-auth + desc: "Flag para deshabilitar basicAuth. Por defecto auth esta habilitado; se requiere --basic-auth USER:PASS si no se pasa --no-auth." + - name: no-gzip + desc: "Flag para deshabilitar el middleware gzip de Traefik. Por defecto gzip esta habilitado." + - name: env + desc: "Variable de entorno KEY=VAL a incluir en el .env y en la seccion environment del docker-compose.yml. Repetible para multiples vars." + - name: volume + desc: "Nombre de un Docker volume que se monta en /data dentro del contenedor. Se declara en la seccion volumes del compose." + - name: build-cmd + desc: "Comando de build personalizado (documentado para uso futuro; Phase 1 usa el Dockerfile multi-stage generado)." + - name: standalone + desc: "Modo standalone: crea repo Gitea dataforge/ y usa git clone/pull en el VPS en vez de rsync. Requiere GITEA_URL y credenciales Gitea configuradas." + - name: dry-run + desc: "Imprime los artefactos generados (Dockerfile, docker-compose.yml, traefik-dynamic.yml, .env) a stderr y retorna JSON con status=dry-run sin ejecutar ningun comando remoto ni escribir ficheros en la app." +output: "JSON a stdout: {status, app, domain, remote_dir, container_id, duration_seconds, auth_enabled, gzip_enabled, http_code, url}. status='ok' si el health check responde HTTP 200/401, 'failed' si hay timeout, 'dry-run' en modo dry-run." +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/pipelines/dockerize_app.sh" +--- + +## Ejemplo + +```bash +# Deploy completo con basicAuth +cd /home/lucas/fn_registry +bash bash/functions/pipelines/dockerize_app.sh kanban \ + --domain kanban.organic-machine.com \ + --port 8421 \ + --basic-auth lucas:supersecret \ + --env KANBAN_DB=/data/kanban.db \ + --volume kanban_data + +# Salida esperada: +# {"status":"ok","app":"kanban","domain":"kanban.organic-machine.com","remote_dir":"...","container_id":"abc123","duration_seconds":45,"auth_enabled":true,"gzip_enabled":true,"http_code":"401","url":"https://kanban.organic-machine.com"} + +# Deploy sin auth (app publica) +bash bash/functions/pipelines/dockerize_app.sh registry_api \ + --domain registry.organic-machine.com \ + --port 8080 \ + --no-auth \ + --env REGISTRY_API_TOKEN=mytoken + +# Dry-run: ver YAMLs sin tocar nada +bash bash/functions/pipelines/dockerize_app.sh kanban \ + --dry-run \ + --domain kanban.organic-machine.com \ + --port 8421 \ + --basic-auth lucas:test123 + +# Standalone: repo Gitea + git clone en VPS +bash bash/functions/pipelines/dockerize_app.sh deploy_server \ + --domain deploy.organic-machine.com \ + --port 9090 \ + --basic-auth lucas:secret \ + --standalone +``` + +## Pasos internos + +| Paso | Descripcion | +|------|-------------| +| 1 | Valida la app en registry.db (SQL sobre tabla apps) | +| 2 | Valida conectividad SSH (BatchMode, ConnectTimeout=5) | +| 3 | Genera hash bcrypt via htpasswd o python3+bcrypt | +| 4 | Genera Dockerfile multi-stage Go (heredoc, patron generate_dockerfile_go_infra) | +| 5 | Genera docker-compose.yml con Traefik labels y red coolify | +| 6 | Genera traefik-dynamic.yml con routers HTTP/HTTPS, basicAuth, gzip, certResolver letsencrypt | +| 7 | Genera/actualiza .env con merge no destructivo | +| 8 | Rsync del repo completo al VPS (o git clone en standalone) | +| 9 | Crea directorio remoto de deploy | +| 10 | Sube traefik-dynamic.yml a /data/coolify/proxy/dynamic/.yml via SSH+sudo tee | +| 11 | Sube docker-compose.yml, Dockerfile y .env al remote_dir via scp | +| 12 | Crea red Docker coolify si no existe; `docker compose up -d --build` remoto | +| 13 | Health check: 10 intentos, 3s intervalo, acepta HTTP 200 y 401 | + +## Decision de implementacion + +Los YAMLs se generan con heredocs bash inline, replicando la logica de +`generate_dockerfile_go_infra`, `generate_compose_traefik_go_infra` y +`generate_traefik_dynamic_go_infra`. Esta decision evita crear un nuevo +binario `cmd/dockerize_helpers/` y mantiene el pipeline completamente +self-contained, siguiendo el patron de `setup_registry_api_bash_infra`. +Las funciones Go quedan referenciadas en `uses_functions` como fuente de +verdad documental del patron que replica. + +## Requisitos en el host local + +- `ssh` y `rsync` instalados +- `htpasswd` (apache2-utils) o `python3` + modulo `bcrypt` para generar hash +- Acceso SSH sin password al host destino (key en ~/.ssh/config) +- `sqlite3` CLI para leer registry.db + +## Requisitos en el VPS + +- Docker + docker compose (v2) +- Coolify con Traefik corriendo y `/data/coolify/proxy/dynamic/` accesible via sudo +- Red Docker `coolify` (se crea automaticamente si no existe) +- Usuario SSH con sudo sin password para: `mkdir`, `tee` en `/data/coolify/proxy/dynamic/` + +## Codigos de salida + +| Codigo | Significado | +|--------|-------------| +| 0 | Exito: stack activo y health check OK | +| 1 | Error: app no encontrada, SSH inavalcable, fallo de build, health check timeout | + +## Notas + +- Phase 1 soporta exclusivamente apps `lang: go`. Soporte para Python y Bash en fases futuras. +- El Dockerfile se genera solo si no existe en el directorio de la app. Si ya existe (con tweaks manuales), se preserva y se avisa por stderr. +- El build context del Dockerfile es `../../` relativo a `apps//` para que el multi-stage copie el `go.mod` del root del registry y compile correctamente. +- El nombre del router Traefik reemplaza `_` por `-` (ej: `registry_api` → `registry-api`). +- En modo `--standalone`, se requieren las variables `GITEA_URL`, `GITEA_TOKEN` (o `GITEA_USER`/`GITEA_PASS`) configuradas para `gitea_create_repo` y `gitea_push_directory`. diff --git a/bash/functions/pipelines/dockerize_app.sh b/bash/functions/pipelines/dockerize_app.sh new file mode 100644 index 00000000..75c76125 --- /dev/null +++ b/bash/functions/pipelines/dockerize_app.sh @@ -0,0 +1,685 @@ +#!/usr/bin/env bash +# dockerize_app — Empaqueta una app del registry para deploy a VPS organic-machine +# via Docker + Traefik + Coolify. Genera Dockerfile, docker-compose.yml, +# traefik-dynamic.yml, sube via rsync y arranca el stack remoto. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../../" && pwd)" + +source "$SCRIPT_DIR/../infra/rsync_deploy.sh" + +# --------------------------------------------------------------------------- +# _dockerize_app_usage — imprime ayuda y sale con error +# --------------------------------------------------------------------------- +_dockerize_app_usage() { + cat >&2 <<'USAGE' +Uso: dockerize_app [opciones] + + --domain DOMAIN Dominio público (ej: kanban.organic-machine.com) + --port PORT Puerto interno del contenedor (default: 8080) + --ssh-host HOST Host SSH destino (default: organic-machine.com) + --remote-dir DIR Directorio remoto (default: /home/ubuntu/coolify-apps/) + --basic-auth USER:PASS Credenciales basicAuth para Traefik + --no-auth Deshabilitar basicAuth (por defecto: auth ON) + --no-gzip Deshabilitar gzip middleware + --env KEY=VAL Variable de entorno (repetible) + --volume NAME Volume Docker (se monta en /data) + --build-cmd CMD Comando de build personalizado + --standalone Crear repo Gitea + git clone remoto en vez de rsync + --dry-run Mostrar artefactos generados sin ejecutar nada +USAGE + return 1 +} + +# --------------------------------------------------------------------------- +# _dockerize_app_generate_dockerfile — genera Dockerfile multi-stage para Go +# --------------------------------------------------------------------------- +_dockerize_app_generate_dockerfile() { + local binary_name="$1" + local port="$2" + shift 2 + local env_vars=("$@") + + cat </dev/null; then + echo " WARNING: .env ya contiene '${key}', no se sobreescribe." >&2 + else + echo "${key}=${val}" >> "$env_file" + fi + done +} + +# --------------------------------------------------------------------------- +# dockerize_app — punto de entrada principal +# --------------------------------------------------------------------------- +dockerize_app() { + local app_name="${1:-}" + if [[ -z "$app_name" ]]; then + _dockerize_app_usage + fi + shift + + # Defaults + local domain="" + local port=8080 + local ssh_host="organic-machine.com" + local remote_dir="" + local basic_auth="" + local no_auth=false + local no_gzip=false + local env_vars=() + local volume_name="" + local build_cmd="" + local standalone=false + local dry_run=false + + # Parse args + while [[ $# -gt 0 ]]; do + case "$1" in + --domain) domain="$2"; shift 2 ;; + --port) port="$2"; shift 2 ;; + --ssh-host) ssh_host="$2"; shift 2 ;; + --remote-dir) remote_dir="$2"; shift 2 ;; + --basic-auth) basic_auth="$2"; shift 2 ;; + --no-auth) no_auth=true; shift ;; + --no-gzip) no_gzip=true; shift ;; + --env) env_vars+=("$2"); shift 2 ;; + --volume) volume_name="$2"; shift 2 ;; + --build-cmd) build_cmd="$2"; shift 2 ;; + --standalone) standalone=true; shift ;; + --dry-run) dry_run=true; shift ;; + *) echo "dockerize_app: opcion desconocida '$1'" >&2; _dockerize_app_usage ;; + esac + done + + # Validar que el dominio fue dado + if [[ -z "$domain" ]]; then + echo "dockerize_app: --domain es obligatorio" >&2 + return 1 + fi + + # Auth logic + local auth_enabled=false + if [[ "$no_auth" == "false" ]]; then + auth_enabled=true + if [[ -z "$basic_auth" ]]; then + echo "dockerize_app: --basic-auth USER:PASS es obligatorio cuando auth esta ON. Usa --no-auth para deshabilitarlo." >&2 + return 1 + fi + fi + + local enable_gzip="true" + if [[ "$no_gzip" == "true" ]]; then + enable_gzip="false" + fi + + local start_ts + start_ts=$(date +%s) + + echo "==> [1/13] Validando app '${app_name}' en registry.db..." >&2 + + # Buscar registry.db + local registry_db="${FN_REGISTRY_ROOT:-$REGISTRY_ROOT}/registry.db" + if [[ ! -f "$registry_db" ]]; then + echo "dockerize_app: registry.db no encontrado en '$registry_db'" >&2 + return 1 + fi + + # Consultar la app en la BD + local app_row + app_row=$(sqlite3 "$registry_db" \ + "SELECT dir_path || '|' || lang || '|' || name FROM apps WHERE id LIKE '${app_name}%' OR name = '${app_name}' LIMIT 1;" 2>/dev/null || true) + if [[ -z "$app_row" ]]; then + echo "dockerize_app: app '${app_name}' no encontrada en registry.db" >&2 + echo " Consulta apps disponibles: sqlite3 '$registry_db' \"SELECT id, name, lang FROM apps ORDER BY name;\"" >&2 + return 1 + fi + + local app_dir_rel="${app_row%%|*}" + local rest="${app_row#*|}" + local app_lang="${rest%%|*}" + local app_real_name="${rest#*|}" + + local app_dir="${REGISTRY_ROOT}/${app_dir_rel}" + + echo " OK: '${app_real_name}' (lang=${app_lang}) en '${app_dir_rel}'" >&2 + + # Fase 1: solo Go + if [[ "$app_lang" != "go" ]]; then + echo "dockerize_app: Phase 1 soporta solo apps Go. Lang detectado: '${app_lang}'" >&2 + return 1 + fi + + if [[ ! -d "$app_dir" ]]; then + echo "dockerize_app: directorio de la app no encontrado: '$app_dir'" >&2 + return 1 + fi + + # Default remote_dir + if [[ -z "$remote_dir" ]]; then + remote_dir="/home/ubuntu/coolify-apps/${app_real_name}" + fi + + echo "==> [2/13] Validando conectividad SSH a '${ssh_host}'..." >&2 + if [[ "$dry_run" == "false" ]]; then + if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "$ssh_host" true 2>/dev/null; then + echo "dockerize_app: no se puede conectar a '${ssh_host}' via SSH" >&2 + return 1 + fi + echo " OK: SSH conectado." >&2 + else + echo " [dry-run] Saltando verificacion SSH." >&2 + fi + + # Generar bcrypt si auth ON + local basic_auth_line="" + local basic_auth_user="" + if [[ "$auth_enabled" == "true" ]]; then + echo "==> [3/13] Generando hash bcrypt para basicAuth..." >&2 + basic_auth_user="${basic_auth%%:*}" + local basic_auth_pass="${basic_auth#*:}" + if [[ -z "$basic_auth_user" || -z "$basic_auth_pass" ]]; then + echo "dockerize_app: --basic-auth debe tener formato USER:PASS" >&2 + return 1 + fi + if command -v htpasswd &>/dev/null; then + basic_auth_line=$(htpasswd -nbB "$basic_auth_user" "$basic_auth_pass" 2>/dev/null) + elif command -v python3 &>/dev/null; then + # Fallback: bcrypt via python3 si está disponible + basic_auth_line=$(python3 -c " +import bcrypt, sys +user, pw = sys.argv[1], sys.argv[2] +h = bcrypt.hashpw(pw.encode(), bcrypt.gensalt()) +print(f'{user}:{h.decode()}') +" "$basic_auth_user" "$basic_auth_pass" 2>/dev/null) || { + echo "dockerize_app: ni htpasswd ni python3+bcrypt disponibles para generar hash" >&2 + return 1 + } + else + echo "dockerize_app: 'htpasswd' no encontrado. Instalar con: sudo apt install apache2-utils" >&2 + return 1 + fi + if [[ -z "$basic_auth_line" ]]; then + echo "dockerize_app: no se pudo generar hash bcrypt" >&2 + return 1 + fi + echo " OK: hash generado para usuario '${basic_auth_user}'." >&2 + else + echo "==> [3/13] BasicAuth deshabilitado (--no-auth)." >&2 + fi + + # ----------------------------------------------------------------------- + # Generar Dockerfile + # ----------------------------------------------------------------------- + echo "==> [4/13] Generando Dockerfile..." >&2 + local dockerfile_path="${app_dir}/Dockerfile" + local dockerfile_content + if [[ ${#env_vars[@]} -gt 0 ]]; then + dockerfile_content=$(_dockerize_app_generate_dockerfile \ + "$app_real_name" \ + "$port" \ + "${env_vars[@]}" 2>/dev/null || true) + else + dockerfile_content=$(_dockerize_app_generate_dockerfile \ + "$app_real_name" \ + "$port" 2>/dev/null || true) + fi + + if [[ "$dry_run" == "true" ]]; then + echo "--- Dockerfile (${dockerfile_path}) ---" >&2 + echo "$dockerfile_content" >&2 + echo "---" >&2 + elif [[ -f "$dockerfile_path" ]]; then + echo " INFO: Dockerfile ya existe en '${dockerfile_path}', no se sobreescribe." >&2 + else + echo "$dockerfile_content" > "$dockerfile_path" + echo " OK: Dockerfile generado en '${dockerfile_path}'." >&2 + fi + + # ----------------------------------------------------------------------- + # Generar docker-compose.yml + # ----------------------------------------------------------------------- + echo "==> [5/13] Generando docker-compose.yml..." >&2 + local compose_path="${app_dir}/docker-compose.yml" + + # Build context relativo al remote_dir (contexto remoto) + # Localmente el compose vive en apps//, el contexto del build Docker + # apunta al root del repo (../../) para que el Dockerfile pueda hacer COPY . . + local build_context="../../" + local dockerfile_in_compose="${app_dir_rel}/Dockerfile" + + local compose_content + if [[ ${#env_vars[@]} -gt 0 ]]; then + compose_content=$(_dockerize_app_generate_compose \ + "$app_real_name" \ + "$app_real_name" \ + "$build_context" \ + "$dockerfile_in_compose" \ + "$port" \ + "$volume_name" \ + "coolify" \ + "${env_vars[@]}" 2>/dev/null || true) + else + compose_content=$(_dockerize_app_generate_compose \ + "$app_real_name" \ + "$app_real_name" \ + "$build_context" \ + "$dockerfile_in_compose" \ + "$port" \ + "$volume_name" \ + "coolify" 2>/dev/null || true) + fi + + if [[ "$dry_run" == "true" ]]; then + echo "--- docker-compose.yml (${compose_path}) ---" >&2 + echo "$compose_content" >&2 + echo "---" >&2 + else + echo "$compose_content" > "$compose_path" + echo " OK: docker-compose.yml generado en '${compose_path}'." >&2 + fi + + # ----------------------------------------------------------------------- + # Generar traefik-dynamic.yml + # ----------------------------------------------------------------------- + echo "==> [6/13] Generando traefik-dynamic.yml..." >&2 + local traefik_path="${app_dir}/traefik-dynamic.yml" + local upstream_url="http://${app_real_name}:${port}" + # Nombre del router Traefik: reemplazar _ por - para nombres válidos + local traefik_name="${app_real_name//_/-}" + + local traefik_content + traefik_content=$(_dockerize_app_generate_traefik_dynamic \ + "$traefik_name" \ + "$domain" \ + "$upstream_url" \ + "$basic_auth_line" \ + "$enable_gzip" 2>/dev/null || true) + + if [[ "$dry_run" == "true" ]]; then + echo "--- traefik-dynamic.yml (${traefik_path}) ---" >&2 + echo "$traefik_content" >&2 + echo "---" >&2 + else + echo "$traefik_content" > "$traefik_path" + echo " OK: traefik-dynamic.yml generado en '${traefik_path}'." >&2 + fi + + # ----------------------------------------------------------------------- + # Generar .env + # ----------------------------------------------------------------------- + local env_path="${app_dir}/.env" + if [[ ${#env_vars[@]} -gt 0 ]]; then + echo "==> [7/13] Generando/actualizando .env..." >&2 + if [[ "$dry_run" == "true" ]]; then + echo "--- .env (${env_path}) ---" >&2 + for kv in "${env_vars[@]}"; do echo "$kv"; done >&2 + echo "---" >&2 + elif [[ -f "$env_path" ]]; then + echo " INFO: .env ya existe, aplicando merge no destructivo." >&2 + _dockerize_app_merge_env_file "$env_path" "${env_vars[@]}" + else + for kv in "${env_vars[@]}"; do echo "$kv"; done > "$env_path" + echo " OK: .env creado en '${env_path}'." >&2 + fi + else + echo "==> [7/13] Sin vars --env, omitiendo .env." >&2 + fi + + if [[ "$dry_run" == "true" ]]; then + local end_ts + end_ts=$(date +%s) + local duration=$(( end_ts - start_ts )) + echo "" >&2 + echo "[dry-run] Artefactos generados. Sin cambios remotos." >&2 + printf '{"status":"dry-run","app":"%s","domain":"%s","remote_dir":"%s","port":%d,"auth_enabled":%s,"gzip_enabled":%s,"duration_seconds":%d,"url":"https://%s"}\n' \ + "$app_real_name" "$domain" "$remote_dir" "$port" \ + "$auth_enabled" "$enable_gzip" "$duration" "$domain" + return 0 + fi + + # ----------------------------------------------------------------------- + # Standalone: crear repo Gitea + push + # ----------------------------------------------------------------------- + if [[ "$standalone" == "true" ]]; then + echo "==> [8/13] Creando repo Gitea (standalone mode)..." >&2 + source "$SCRIPT_DIR/../infra/gitea_create_repo.sh" + source "$SCRIPT_DIR/../infra/gitea_push_directory.sh" + gitea_create_repo "dataforge" "$app_real_name" "true" "App ${app_real_name} — dockerized via dockerize_app" >&2 || true + gitea_push_directory "$app_dir" "dataforge" "$app_real_name" "master" >&2 + echo " OK: repo Gitea 'dataforge/${app_real_name}' sincronizado." >&2 + + echo "==> [9/13] Clonando/actualizando repo en remoto (standalone mode)..." >&2 + local gitea_base="${GITEA_URL:-https://git.organic-machine.com}" + ssh "$ssh_host" bash <&2 + else + echo "==> [8/13] Rsync del app al VPS..." >&2 + # Sincronizar solo el directorio de la app (más liviano que el repo completo) + # El build context remoto apunta a ../../ desde remote_dir, por lo que + # también necesitamos los ficheros Go del root del repo. + # Solución: rsync el root del registry al VPS en un dir de build, y además + # sincronizar los artefactos Docker generados. + local remote_build_root="/home/ubuntu/coolify-build/${app_real_name}" + echo " Sincronizando repo completo a '${ssh_host}:${remote_build_root}'..." >&2 + rsync_deploy "${REGISTRY_ROOT}/" "$ssh_host" "$remote_build_root" > /dev/null + echo " OK: repo sincronizado en '${remote_build_root}'." >&2 + + echo "==> [9/13] Preparando directorio remoto de deploy..." >&2 + ssh "$ssh_host" "mkdir -p '${remote_dir}'" + echo " OK: '${remote_dir}' disponible." >&2 + fi + + # ----------------------------------------------------------------------- + # Subir traefik-dynamic.yml al proxy de Coolify + # ----------------------------------------------------------------------- + echo "==> [10/13] Subiendo traefik-dynamic.yml a Coolify proxy..." >&2 + local traefik_coolify_path="/data/coolify/proxy/dynamic/${traefik_name}.yml" + ssh "$ssh_host" "sudo mkdir -p /data/coolify/proxy/dynamic/" >&2 + echo "$traefik_content" | ssh "$ssh_host" \ + "sudo tee '${traefik_coolify_path}' > /dev/null" + echo " OK: traefik-dynamic.yml en '${traefik_coolify_path}'." >&2 + + # ----------------------------------------------------------------------- + # Subir docker-compose.yml, Dockerfile y .env al remote_dir + # ----------------------------------------------------------------------- + echo "==> [11/13] Subiendo artefactos Docker a '${ssh_host}:${remote_dir}'..." >&2 + if [[ "$standalone" == "true" ]]; then + # En standalone, el repo ya está clonado en remote_dir + echo " [standalone] Artefactos ya en remote_dir via git." >&2 + else + # Copiar los artefactos generados al remote_dir (que es el subdir de la app en el build root) + local remote_app_subdir="${remote_build_root}/${app_dir_rel}" + ssh "$ssh_host" "mkdir -p '${remote_app_subdir}'" >&2 + scp "$compose_path" "${ssh_host}:${remote_app_subdir}/docker-compose.yml" >&2 + scp "$dockerfile_path" "${ssh_host}:${remote_app_subdir}/Dockerfile" >&2 + if [[ -f "$env_path" ]]; then + scp "$env_path" "${ssh_host}:${remote_app_subdir}/.env" >&2 + fi + # Apuntar remote_dir al subdir donde está el compose + remote_dir="$remote_app_subdir" + echo " OK: artefactos subidos a '${remote_app_subdir}'." >&2 + fi + + # ----------------------------------------------------------------------- + # Verificar red coolify + docker compose up --build + # ----------------------------------------------------------------------- + echo "==> [12/13] Verificando red Docker 'coolify' y levantando stack..." >&2 + ssh "$ssh_host" bash <&2 + + # ----------------------------------------------------------------------- + # Health check + # ----------------------------------------------------------------------- + echo "==> [13/13] Health check en 'https://${domain}'..." >&2 + local health_url="https://${domain}/" + local attempts=0 + local max_attempts=10 + local http_code="000" + while [[ $attempts -lt $max_attempts ]]; do + if [[ -n "$basic_auth_user" ]]; then + http_code=$(curl -sk -o /dev/null -w "%{http_code}" \ + -u "${basic_auth_user}:${basic_auth#*:}" \ + "$health_url" 2>/dev/null || echo "000") + else + http_code=$(curl -sk -o /dev/null -w "%{http_code}" \ + "$health_url" 2>/dev/null || echo "000") + fi + # 200 = OK, 401 = basicAuth activo (correcto), 301/302 = redirect (transitorio) + if [[ "$http_code" == "200" || "$http_code" == "401" ]]; then + break + fi + attempts=$(( attempts + 1 )) + echo " Intento ${attempts}/${max_attempts} — HTTP ${http_code}, esperando 3s..." >&2 + sleep 3 + done + + local end_ts + end_ts=$(date +%s) + local duration=$(( end_ts - start_ts )) + + # Obtener container_id + local container_id="" + container_id=$(ssh "$ssh_host" \ + "docker ps --filter name=${app_real_name} --format '{{.ID}}' | head -1" 2>/dev/null || true) + + if [[ "$http_code" == "200" || "$http_code" == "401" ]]; then + echo " OK: servicio respondiendo HTTP ${http_code}." >&2 + local status_val="ok" + else + echo " ERROR: health check timeout tras ${max_attempts} intentos." >&2 + local status_val="failed" + fi + + printf '{"status":"%s","app":"%s","domain":"%s","remote_dir":"%s","container_id":"%s","duration_seconds":%d,"auth_enabled":%s,"gzip_enabled":%s,"http_code":"%s","url":"https://%s"}\n' \ + "$status_val" \ + "$app_real_name" \ + "$domain" \ + "$remote_dir" \ + "$container_id" \ + "$duration" \ + "$auth_enabled" \ + "$enable_gzip" \ + "$http_code" \ + "$domain" + + [[ "$status_val" == "ok" ]] +} + +# --------------------------------------------------------------------------- +# Punto de entrada directo (bash dockerize_app.sh [flags]) +# --------------------------------------------------------------------------- +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + dockerize_app "$@" +fi diff --git a/bash/functions/pipelines/keepass_to_pass.md b/bash/functions/pipelines/keepass_to_pass.md new file mode 100644 index 00000000..13fa8895 --- /dev/null +++ b/bash/functions/pipelines/keepass_to_pass.md @@ -0,0 +1,59 @@ +--- +name: keepass_to_pass +kind: pipeline +lang: bash +domain: pipelines +version: "1.0.0" +purity: impure +signature: "keepass_to_pass [--prefix

] [--overwrite] [--dry-run]" +description: "Pipeline que exporta todas las entries del KeePassXC database a `pass`. Cada entry queda como multilinea (password en linea 1, metadata user/url/notes en lineas siguientes). Path en pass: /." +tags: [keepass, pass, migration, secret, credential, pipeline, launcher] +uses_functions: + - keepass_dump_bash_infra + - pass_set_bash_infra +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: prefix + desc: "prefijo en pass (default: keepass)" + - name: overwrite + desc: "flag para sobreescribir entradas existentes" + - name: dry_run + desc: "flag para no escribir, solo listar" +output: "log de operaciones a stdout (IMPORT/SKIP/DRY/FAIL por entry, summary final)" +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/pipelines/keepass_to_pass.sh" +--- + +## Ejemplo + +```bash +# Setup primera vez +pass insert meta/keepassxc-master +export KEEPASS_DB="/mnt/d/Tr4Shhh_FOLDER/Sync/PssDtbs/PassDataBase.kdbx" + +# Dry run +./fn run keepass_to_pass --dry-run + +# Importar todo (skip si existe en pass) +./fn run keepass_to_pass + +# Forzar sobreescritura +./fn run keepass_to_pass --overwrite + +# Otro prefijo +./fn run keepass_to_pass --prefix import/keepass +``` + +## Notas + +- Despues del import, todo lo que estaba en KeePassXC es accesible para Claude via `pass_get_bash_infra`. +- Espacios en paths se sustituyen por `_` (pass no permite espacios sin escapado). +- Entries sin password se omiten (counter `empty`). +- `~/.password-store` se sincroniza entre PCs via `/full-git-push` y `/full-git-pull` ya existentes. +- Para revertir un import: `pass rm -r keepass/`. diff --git a/bash/functions/pipelines/keepass_to_pass.sh b/bash/functions/pipelines/keepass_to_pass.sh new file mode 100644 index 00000000..681be4f3 --- /dev/null +++ b/bash/functions/pipelines/keepass_to_pass.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# keepass_to_pass +# --------------- +# Pipeline: exporta entries del KeePassXC database a `pass`. +# +# Cada entry queda en pass como multilinea: +# +# user: +# url: +# notes: +# +# Path en pass: /. Espacios reemplazados por `_`. +# +# REQUIERE: +# - keepassxc-cli, pass, jq +# - KEEPASS_DB (env) +# - master password en pass meta/keepassxc-master o env KEEPASS_PASSWORD +# +# USO: +# ./fn run keepass_to_pass [--prefix keepass] [--overwrite] [--dry-run] +# +# FLAGS: +# --prefix

Prefijo en pass (default: keepass) +# --overwrite Sobreescribe entradas existentes en pass +# --dry-run No escribe; solo lista lo que haria + +set -euo pipefail + +REGISTRY_ROOT="${FN_REGISTRY_ROOT:-/home/lucas/fn_registry}" + +# shellcheck disable=SC1091 +source "$REGISTRY_ROOT/bash/functions/infra/keepass_dump.sh" +# shellcheck disable=SC1091 +source "$REGISTRY_ROOT/bash/functions/infra/pass_set.sh" + +prefix="keepass" +overwrite=0 +dry_run=0 + +while [ $# -gt 0 ]; do + case "$1" in + --prefix) prefix="$2"; shift 2 ;; + --overwrite) overwrite=1; shift ;; + --dry-run) dry_run=1; shift ;; + -h|--help) + grep '^#' "$0" | sed 's/^# \?//' + exit 0 ;; + *) echo "keepass_to_pass: flag desconocido: $1" >&2; exit 1 ;; + esac +done + +echo "==> Dumping KeePassXC database..." +dump=$(keepass_dump) +total=$(printf '%s' "$dump" | jq 'length') +echo "==> Found $total entries" + +imported=0 +skipped=0 +empty=0 +i=0 + +while IFS= read -r entry; do + i=$((i+1)) + path=$(printf '%s' "$entry" | jq -r '.path') + password=$(printf '%s' "$entry" | jq -r '.password') + username=$(printf '%s' "$entry" | jq -r '.username') + url=$(printf '%s' "$entry" | jq -r '.url') + notes=$(printf '%s' "$entry" | jq -r '.notes') + + if [ -z "$path" ] || [ "$path" = "null" ]; then + empty=$((empty+1)) + continue + fi + if [ -z "$password" ] || [ "$password" = "null" ]; then + empty=$((empty+1)) + continue + fi + + pass_path="$prefix/$path" + + if [ "$overwrite" -eq 0 ] && [ -f "${PASSWORD_STORE_DIR:-$HOME/.password-store}/$pass_path.gpg" ]; then + skipped=$((skipped+1)) + printf '[%d/%d] SKIP %s (existe)\n' "$i" "$total" "$pass_path" + continue + fi + + multiline="$password" + [ -n "$username" ] && [ "$username" != "null" ] && multiline+=$'\nuser: '"$username" + [ -n "$url" ] && [ "$url" != "null" ] && multiline+=$'\nurl: '"$url" + [ -n "$notes" ] && [ "$notes" != "null" ] && multiline+=$'\nnotes: '"$notes" + + if [ "$dry_run" -eq 1 ]; then + printf '[%d/%d] DRY %s\n' "$i" "$total" "$pass_path" + else + if pass_set "$pass_path" "$multiline"; then + imported=$((imported+1)) + printf '[%d/%d] IMPORT %s\n' "$i" "$total" "$pass_path" + else + printf '[%d/%d] FAIL %s\n' "$i" "$total" "$pass_path" >&2 + fi + fi +done < <(printf '%s' "$dump" | jq -c '.[]') + +echo "==> Done: imported=$imported skipped=$skipped empty=$empty total=$total" diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 7d565c89..a37ad417 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -295,6 +295,14 @@ if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/apps/text_editor_smoke/CMakeLists.txt) add_subdirectory(apps/text_editor_smoke) endif() +# --- AltSnap viewport-jitter regression test --- +# Headless harness que conduce glfwSetWindowPos cada frame y verifica que +# ImGui viewport->Pos sigue al OS dentro de 1px. Sin la patch del framework +# (callback GLFW + per-frame sync) este test falla exit=1. +if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/apps/altsnap_jitter_test/CMakeLists.txt) + add_subdirectory(apps/altsnap_jitter_test) +endif() + # --- Registry Dashboard (lives in projects/fn_monitoring/apps/) --- # _DASH_DIR puede sobreescribirse via -D_DASH_DIR= para apuntar a un # worktree (parallel-fix-issues u otros flujos que aislen builds). @@ -315,6 +323,23 @@ if(EXISTS ${_GE_DIR}/CMakeLists.txt) add_subdirectory(${_GE_DIR} ${CMAKE_BINARY_DIR}/apps/graph_explorer) endif() +# --- odr_console (lives in projects/online_data_recopilation/apps/) --- +if(NOT DEFINED _ODR_DIR) + set(_ODR_DIR ${CMAKE_SOURCE_DIR}/../projects/online_data_recopilation/apps/odr_console) +endif() +if(EXISTS ${_ODR_DIR}/CMakeLists.txt) + add_subdirectory(${_ODR_DIR} ${CMAKE_BINARY_DIR}/apps/odr_console) +endif() + +# --- navegator_dashboard (lives in projects/navegator/apps/) --- +# Windows-only — el propio CMakeLists.txt hace return() en non-WIN32. +if(NOT DEFINED _NAVD_DIR) + set(_NAVD_DIR ${CMAKE_SOURCE_DIR}/../projects/navegator/apps/navegator_dashboard) +endif() +if(EXISTS ${_NAVD_DIR}/CMakeLists.txt) + add_subdirectory(${_NAVD_DIR} ${CMAKE_BINARY_DIR}/apps/navegator_dashboard) +endif() + # --- Tests (Catch2 amalgamated, ctest-driven) --- option(BUILD_TESTING "Build C++ tests" ON) if(BUILD_TESTING) diff --git a/cpp/PATTERNS.md b/cpp/PATTERNS.md index 14d870ce..447aa21d 100644 --- a/cpp/PATTERNS.md +++ b/cpp/PATTERNS.md @@ -22,8 +22,13 @@ Antes de mergear una app, verificar uno por uno: }; ``` Pasarlo a `AppConfig::panels` + `AppConfig::panel_count = 2`. -- [ ] **Layouts persistentes** (si aplica). Si la app guarda layouts: - implementa `fn_ui::LayoutCallbacks` y pasalas en `AppConfig::layouts_cb`. +- [ ] **Layouts persistentes**. Vienen activos por defecto: `fn::run_app` abre + un `LayoutStorage` SQLite en `/local_files/layouts.db` y enchufa + el menu Layouts (Save / Apply / Delete / Reset) sin codigo. La app solo + pasa `AppConfig::layouts_cb` si quiere personalizar (ej. on_reset que + restaure paneles especificos como en `shaders_lab`). Para apagar el + auto-storage: `cfg.auto_layouts = false`. Para cambiar el nombre del + archivo: `cfg.auto_layouts_db = "myapp_layouts.db"`. - [ ] **GL loader** (si la app usa OpenGL >= 2.0 directamente). Pasar `AppConfig::init_gl_loader = true` para que `fn::run_app()` llame `fn::gfx::gl_loader_init()` tras crear el contexto. @@ -86,9 +91,11 @@ int main() { } ``` -Con esto la app obtiene gratis: MainMenuBar (View/Settings/About), ventana About, -ventana Settings, FPS overlay configurable, theming `FnDark`, fuentes vectoriales -+ iconos Tabler mergeados, multi-viewport opcional. +Con esto la app obtiene gratis: MainMenuBar (View/**Layouts**/Settings/About), +ventana About, ventana Settings, ventana Logs, FPS overlay configurable, theming +`FnDark`, fuentes vectoriales + iconos Tabler mergeados, multi-viewport opcional, +**y persistencia de layouts ImGui en `/local_files/layouts.db`** sin +escribir una linea de codigo. ## Anti-patrones diff --git a/cpp/apps/altsnap_jitter_test b/cpp/apps/altsnap_jitter_test new file mode 160000 index 00000000..64a01def --- /dev/null +++ b/cpp/apps/altsnap_jitter_test @@ -0,0 +1 @@ +Subproject commit 64a01defbc2475fdada79bba1d9a0fc80e8a8389 diff --git a/cpp/framework/app_base.cpp b/cpp/framework/app_base.cpp index 0fb3a6da..bf6dd14c 100644 --- a/cpp/framework/app_base.cpp +++ b/cpp/framework/app_base.cpp @@ -13,6 +13,7 @@ #include "core/fps_overlay.h" #include "core/logger.h" #include "core/log_window.h" +#include "core/layout_storage.h" #include "gfx/gl_loader.h" #include @@ -203,6 +204,27 @@ int run_app(AppConfig config, std::function render_fn) { glfwMakeContextCurrent(window); glfwSwapInterval(config.vsync ? 1 : 0); + // Anti-jitter: when the OS moves/resizes the window externally (Windows + // tools like AltSnap, tiling WMs, snap-assist), ImGui's viewport pos can + // lag one frame and `UpdatePlatformWindows` reapplies the stale value via + // glfwSetWindowPos, fighting the OS and producing visible jitter. + // Updating the viewport struct directly from the GLFW callback closes the + // loop in the same tick — no stale Pos can ever reach the platform sync. + // ImGui_ImplGlfw_InitForOpenGL does NOT install pos/size callbacks, so we + // can install ours without breaking the backend's own callback chain. + glfwSetWindowPosCallback(window, [](GLFWwindow* w, int x, int y) { + if (ImGui::GetCurrentContext() == nullptr) return; + if (ImGuiViewport* vp = ImGui::FindViewportByPlatformHandle(w)) { + vp->Pos = ImVec2((float)x, (float)y); + } + }); + glfwSetWindowSizeCallback(window, [](GLFWwindow* w, int cx, int cy) { + if (ImGui::GetCurrentContext() == nullptr) return; + if (ImGuiViewport* vp = ImGui::FindViewportByPlatformHandle(w)) { + vp->Size = ImVec2((float)cx, (float)cy); + } + }); + // Carga punteros a funciones GL >= 2.0 si la app lo pide. En Linux es // no-op; en Windows usa wglGetProcAddress (requiere ctx GL activo). if (config.init_gl_loader) { @@ -241,6 +263,25 @@ int run_app(AppConfig config, std::function render_fn) { // fuentes. Si no existe el .ini, los defaults se aplican. fn_ui::settings_load(); + // Auto-wiring del menu Layouts: si la app no proporciono layouts_cb y no + // ha desactivado auto_layouts, abrimos un LayoutStorage por defecto con + // SQLite en `/` y generamos los callbacks + // estandar (list/save/apply/delete/reset). Asi toda app C++ obtiene el + // menu Layouts gratis sin codigo. + fn_ui::LayoutStorage* auto_layouts_storage = nullptr; + fn_ui::LayoutCallbacks auto_layouts_cb; + if (config.layouts_cb == nullptr && config.auto_layouts) { + const char* db_name = (config.auto_layouts_db && *config.auto_layouts_db) + ? config.auto_layouts_db : "layouts.db"; + auto_layouts_storage = fn_ui::layout_storage_open(local_path(db_name)); + if (auto_layouts_storage) { + fn_ui::layout_storage_make_callbacks(auto_layouts_storage, auto_layouts_cb); + config.layouts_cb = &auto_layouts_cb; + } else { + fn_log::log_warn("auto_layouts: layout_storage_open fallo (%s)", db_name); + } + } + // Registra info de la ventana About si la app la proveyo en AppConfig. if (config.about.name != nullptr) { fn_ui::about_window_set_info( @@ -294,6 +335,25 @@ int run_app(AppConfig config, std::function render_fn) { continue; } + // Anti-jitter pass 2: covers secondary viewport windows that the + // backend creates dynamically (panels dragged outside the main). + // Sync each viewport's Pos/Size to the OS-reported state BEFORE + // NewFrame, so ImGui logic this tick already sees the up-to-date + // values and UpdatePlatformWindows can't stomp them with stale data. + if (io.ConfigFlags & ImGuiConfigFlags_ViewportsEnable) { + ImGuiPlatformIO& pio = ImGui::GetPlatformIO(); + for (int i = 0; i < pio.Viewports.Size; ++i) { + ImGuiViewport* vp = pio.Viewports[i]; + if (!vp || !vp->PlatformHandle) continue; + GLFWwindow* gw = (GLFWwindow*)vp->PlatformHandle; + int x = 0, y = 0, cx = 0, cy = 0; + glfwGetWindowPos(gw, &x, &y); + glfwGetWindowSize(gw, &cx, &cy); + vp->Pos = ImVec2((float)x, (float)y); + vp->Size = ImVec2((float)cx, (float)cy); + } + } + // Tamaño de fuente: aplica via style.FontSizeBase cada frame. Cambios // se ven al instante (ImGui 1.92+ escala el atlas dinamicamente, no // hace falta rebuild). @@ -313,11 +373,20 @@ int run_app(AppConfig config, std::function render_fn) { ImGui_ImplGlfw_NewFrame(); ImGui::NewFrame(); - // Menubar canonica (View / Layouts / Settings / About) si la app la - // configuro en AppConfig. Se renderiza ANTES del render_fn para que - // el render_fn pueda hacer DockSpaceOverViewport debajo. - if (config.panels != nullptr || config.layouts_cb != nullptr || - (bool)config.view_extras) { + // Si auto_layouts esta gestionando el storage, aplica el layout + // pendiente ANTES de que el render_fn cree ventanas. Si la app gestiona + // su propio storage, debe llamar layout_storage_apply_pending ella misma + // dentro de render_fn (patron que ya usan shaders_lab y graph_explorer). + if (auto_layouts_storage) { + std::string applied = fn_ui::layout_storage_apply_pending(auto_layouts_storage); + if (!applied.empty()) auto_layouts_cb.active_name = applied; + } + + // Menubar canonica (View / Layouts / Settings / About) — siempre se + // renderiza para que Settings/Logs/About esten disponibles aunque la + // app no declare panels/layouts/view_extras propios. Se dibuja ANTES + // del render_fn para que pueda hacer DockSpaceOverViewport debajo. + { // Adapter: std::function -> ViewMenuExtrasFn(void*). fn_ui::ViewMenuExtrasFn extras_fn = nullptr; void* extras_user = nullptr; @@ -380,6 +449,12 @@ int run_app(AppConfig config, std::function render_fn) { fn_log::logger_close(); } + // Cierra el storage de layouts auto-creado, si lo hay. + if (auto_layouts_storage) { + fn_ui::layout_storage_close(auto_layouts_storage); + auto_layouts_storage = nullptr; + } + // Cleanup ImGui_ImplOpenGL3_Shutdown(); ImGui_ImplGlfw_Shutdown(); diff --git a/cpp/framework/app_base.h b/cpp/framework/app_base.h index 6c923fec..a232cbf3 100644 --- a/cpp/framework/app_base.h +++ b/cpp/framework/app_base.h @@ -113,8 +113,24 @@ struct AppConfig { // Callbacks de layouts persistentes. Si layouts_cb != nullptr, run_app // llama fn_ui::app_menubar(panels, panel_count, layouts_cb) cada frame. + // Si layouts_cb == nullptr y auto_layouts == true (default), run_app abre + // un fn_ui::LayoutStorage por defecto sobre `/`, + // genera unos LayoutCallbacks estandar (save/load/list/delete/reset), + // los aplica al inicio de cada frame y los cierra al salir. Asi cualquier + // app obtiene el menu Layouts gratis sin tocar codigo. fn_ui::LayoutCallbacks* layouts_cb = nullptr; + // Auto-wiring del menu Layouts cuando layouts_cb == nullptr. + // - true (default): run_app crea LayoutStorage interno con SQLite. + // - false: no se crea storage. Util si la app no quiere persistencia + // (ej. demo headless, capture mode). + bool auto_layouts = true; + + // Nombre del archivo SQLite (relativo a `/local_files/`) usado + // por el layout storage por defecto. Solo se consulta si layouts_cb es + // nullptr y auto_layouts es true. Default "layouts.db". + const char* auto_layouts_db = "layouts.db"; + // Items extra dentro del menu "View", al final tras los toggles de // paneles. Si view_extras != nullptr, run_app lo pasa a app_menubar. // El callback se invoca dentro de un BeginMenu("View") ya abierto: diff --git a/cpp/functions/infra/job_cache_sha256.cpp b/cpp/functions/infra/job_cache_sha256.cpp new file mode 100644 index 00000000..717ecfc7 --- /dev/null +++ b/cpp/functions/infra/job_cache_sha256.cpp @@ -0,0 +1,66 @@ +#include "job_cache_sha256.h" + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace fn::cache_sha256 { + +namespace { + +std::string subdir_for(const std::string& key) { + return key.size() >= 2 ? key.substr(0, 2) : key; +} + +} // namespace + +std::string path_for(const std::string& root, + const std::string& key, + const std::string& suffix) { + fs::path p = fs::path(root) / subdir_for(key) / (key + suffix); + return p.string(); +} + +bool ensure_dir(const std::string& root, const std::string& key) { + std::error_code ec; + fs::path dir = fs::path(root) / subdir_for(key); + fs::create_directories(dir, ec); + return !ec; +} + +bool read(const std::string& root, + const std::string& key, + const std::string& suffix, + std::string* out) { + if (!out) return false; + std::ifstream f(path_for(root, key, suffix), std::ios::binary); + if (!f.is_open()) return false; + std::ostringstream ss; + ss << f.rdbuf(); + *out = ss.str(); + return f.good() || f.eof(); +} + +bool write(const std::string& root, + const std::string& key, + const std::string& suffix, + const std::string& bytes) { + if (!ensure_dir(root, key)) return false; + std::ofstream f(path_for(root, key, suffix), + std::ios::binary | std::ios::trunc); + if (!f.is_open()) return false; + f.write(bytes.data(), (std::streamsize)bytes.size()); + return f.good(); +} + +bool exists(const std::string& root, + const std::string& key, + const std::string& suffix) { + std::error_code ec; + return fs::exists(path_for(root, key, suffix), ec) && !ec; +} + +} // namespace fn::cache_sha256 diff --git a/cpp/functions/infra/job_cache_sha256.h b/cpp/functions/infra/job_cache_sha256.h new file mode 100644 index 00000000..4d11d8e2 --- /dev/null +++ b/cpp/functions/infra/job_cache_sha256.h @@ -0,0 +1,48 @@ +#pragma once + +#include + +// job_cache_sha256 — addressable cache layout helper. +// +// Layout: +// // +// +// El caller calcula el `key` (tipicamente SHA-256 hex de algun valor +// canonico — URL, parametros, etc.). Esta funcion no hashea: solo +// gestiona el path y la I/O. +// +// Suffix permite multiples blobs por la misma key (`.html`, `.md`, +// `.json`). Pasa "" si solo hay un blob. + +namespace fn::cache_sha256 { + +// Pure. Devuelve "//". No toca disco. +// Si key tiene menos de 2 caracteres, usa la key entera como subdir. +std::string path_for(const std::string& root, + const std::string& key, + const std::string& suffix = ""); + +// Impure. Crea el directorio `//` si no existe. +// Devuelve true en exito. No falla si ya existe. +bool ensure_dir(const std::string& root, const std::string& key); + +// Impure. Lee el archivo en `path_for(root, key, suffix)` entero. +// Devuelve true si existia y se leyo; false en caso contrario. +bool read(const std::string& root, + const std::string& key, + const std::string& suffix, + std::string* out); + +// Impure. Escribe `bytes` en `path_for(root, key, suffix)`. Crea el +// directorio padre si no existe. Devuelve true en exito. +bool write(const std::string& root, + const std::string& key, + const std::string& suffix, + const std::string& bytes); + +// Impure. true si el archivo existe en `path_for(root, key, suffix)`. +bool exists(const std::string& root, + const std::string& key, + const std::string& suffix); + +} // namespace fn::cache_sha256 diff --git a/cpp/functions/infra/job_cache_sha256.md b/cpp/functions/infra/job_cache_sha256.md new file mode 100644 index 00000000..f8bc9a5c --- /dev/null +++ b/cpp/functions/infra/job_cache_sha256.md @@ -0,0 +1,70 @@ +--- +name: job_cache_sha256 +kind: function +lang: cpp +domain: infra +version: "1.0.0" +purity: impure +signature: "std::string fn::cache_sha256::path_for(const std::string& root, const std::string& key, const std::string& suffix = \"\"); bool fn::cache_sha256::ensure_dir(const std::string& root, const std::string& key); bool fn::cache_sha256::read(const std::string& root, const std::string& key, const std::string& suffix, std::string* out); bool fn::cache_sha256::write(const std::string& root, const std::string& key, const std::string& suffix, const std::string& bytes); bool fn::cache_sha256::exists(const std::string& root, const std::string& key, const std::string& suffix)" +description: "Cache addressable con layout '//'. El caller hashea (tipicamente SHA-256 hex), esta funcion gestiona path + I/O. Suffix opcional permite multiples blobs por key (.html, .md, .json). Pieza extraida del jobs system de graph_explorer (issue 0026/0027) para reuso entre apps C++ que recolectan datos online." +tags: [cache, sha256, addressable, jobs, fs, scraping] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [string, cstdio, filesystem, fstream, sstream] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/infra/job_cache_sha256.cpp" +framework: "" +params: + - name: root + desc: "Directorio raiz del cache. Tipicamente /local_files/cache/. La funcion crea subdirectorios on-demand." + - name: key + desc: "Clave de cache. Tipicamente SHA-256 hex (64 chars) de un valor canonico (URL, hash de params). El caller calcula el hash." + - name: suffix + desc: "Sufijo del archivo. Permite multiples blobs por la misma key. Ejemplos: '.html', '.md', '.json'. Pasa '' si solo hay un blob por key." + - name: out + desc: "Buffer de salida para read(). Recibe los bytes del archivo. Devuelve false si no existia." + - name: bytes + desc: "Contenido a escribir en write(). Se trata como binario (no se anade newline ni se interpreta)." +output: "path_for: string con el path absoluto. ensure_dir/read/write/exists: bool true en exito; read tambien rellena out. Ningun error_type custom — fallo de fs se traduce a false (ver fstream/filesystem para detalles)." +notes: "1) Pure: solo path_for. Resto impuro (toca filesystem). 2) Layout compatible con el cache que ya usan los enrichers Python de graph_explorer (`//.{html,md}`), por lo que apps C++ pueden leer blobs escritos por subprocess Python sin migrar formato. 3) Si el caller necesita SHA-256 propio, anadir funcion separada `sha256_hex_cpp_core` (no implementada aun)." +documentation: "Pieza minima del refactor de issue 0065. El cache es un helper standalone que cualquier app C++ que recolecte datos online puede usar para evitar refetchear. odr_console (issue 0066) lo usa via la cache_dir que pasa al subprocess Python en stdin JSON, manteniendo compatibilidad con enrichers existentes." +example: | + #include "cpp/functions/infra/job_cache_sha256.h" + #include // o cualquier impl SHA-256 + + std::string url = "https://example.com/data.json"; + // Hash de la URL como key (usuario provee la impl). + std::string key = sha256_hex(url); + std::string root = "/path/to/app/local_files/cache"; + + if (!fn::cache_sha256::exists(root, key, ".json")) { + std::string body = fetch_http(url); + fn::cache_sha256::write(root, key, ".json", body); + } + std::string cached; + if (fn::cache_sha256::read(root, key, ".json", &cached)) { + // ... usar cached ... + } +--- + +## Notas + +Helper de I/O addressable. No hace SHA-256 — el caller provee la key (normalmente hex). Layout `//` es identico al que usan los enrichers de graph_explorer en sus `run.py`, por lo que C++ y Python pueden leer/escribir el mismo cache. + +### Decisiones de diseño + +- **Hash fuera de la funcion**: separar el hashing del path-handling deja el modulo libre de dependencias criptograficas. Si una app prefiere blake3, xxhash o md5 para keys mas cortas, esta funcion sigue valiendo. +- **Suffix opcional**: enrichers de graph_explorer guardan dos blobs por URL (`.html` y `.md`); odr_console probablemente guarde `.json`/`.parquet`. Suffix unifica casos. +- **Sin SQLite**: cache es solo files. Si una app necesita metadata por entry (TTL, last-access, content-type), eso vive en operations.db o en una tabla aparte. +- **Layout compatible**: identico al Python `cache_paths()` en `enrichers/fetch_webpage/run.py`. C++ puede leer blobs escritos por enrichers Python y viceversa. + +### Que NO incluye + +- No incluye SHA-256 ni ningun hash. Caller responsable. +- No incluye TTL ni eviction. Implementar fuera si se necesita. +- No incluye locking entre procesos. Si dos workers escriben la misma key concurrente, ultimo gana — para invalidacion atomica usar nombre temporal + rename. diff --git a/dev/issues/0065-extract-jobs-system-to-registry.md b/dev/issues/0065-extract-jobs-system-to-registry.md new file mode 100644 index 00000000..c8032bfb --- /dev/null +++ b/dev/issues/0065-extract-jobs-system-to-registry.md @@ -0,0 +1,92 @@ +--- +id: 0065 +title: Extraer jobs system de graph_explorer al registry (jobs_pool + cache + subprocess worker) +status: pending +priority: high +created: 2026-05-09 +blocks: [0066] +related: [0026, 0027, 0028] +--- + +## Contexto + +`projects/osint_graph/apps/graph_explorer/jobs.{cpp,h}` (1366 + 97 lineas) implementa: + +- Pool de N `std::thread` workers leyendo cola de jobs en SQLite (tabla `jobs`). +- Spawn de subprocess por job con wire protocol stdin (JSON ctx) / stderr (`PROGRESS: `) / stdout (JSON resultado) / exit code. +- Cache addressable `/cache//.{html,md,...}`. +- Recovery: jobs que quedaron `running` de sesion anterior se marcan `error` al `jobs_init`. +- `dirty_counter` que la UI lee para refrescar tras cambios. +- Persistencia `JobRow` con created/started/finished/duration/progress/stage/error/result_json. + +El proyecto `online_data_recopilation` (issue 0066) necesita el mismo sistema. En lugar de copy-paste, **extraer al registry** para que ambas apps importen. + +## Plan de extraccion + +1. **Identificar fronteras** entre logica generica (extraer) y especifica de graph_explorer (queda local): + - Generico: thread pool, queue SQLite, subprocess spawn, wire protocol parser, cache sha256, recovery. + - Especifico: aplicar `entities/relations/node_updates` al `operations.db` del grafo. + +2. **Funciones nuevas del registry**: + + | ID | Domain | Que hace | + |---|---|---| + | `jobs_pool_cpp_core` | core | Thread pool generico parametrizable (workers, callback `on_job(JobRow)`). Tabla `jobs` configurable de nombre. | + | `subprocess_worker_cpp_infra` | infra | Spawn subprocess + capturar stdin/stderr/stdout con wire protocol (`PROGRESS:`, JSON final). Devuelve `WorkerResult{stdout_json, error, exit_code}`. | + | `job_cache_sha256_cpp_infra` | infra | `cache_path(root, key) -> path`, `cache_put(root, key, bytes)`, `cache_get(root, key)`. Layout `//`. | + | `worker_manifest_loader_cpp_core` | core | Enumera `

//manifest.yaml`, valida schema, devuelve `vector`. | + +3. **Tipos nuevos**: + - `JobRow_cpp_core` — struct con campos comunes (id, worker_id, target_id, status, progress, stage, error, result_json, timestamps). + - `WorkerManifest_cpp_core` — struct (id, name, description, applies_to, emits, params, uses_functions). + - `WorkerResult_cpp_infra` — struct (stdout_json, stderr_log, exit_code, error). + +4. **Migracion graph_explorer**: + - Reemplazar `jobs.cpp/h` por imports al registry. + - El callback `on_job` queda en `entity_ops.cpp` aplicando entities/relations. + - Test: lanzar enricher `fetch_webpage`, verificar que sigue funcionando. + +5. **Validacion**: `cd projects/osint_graph/apps/graph_explorer && cmake --build build` + tests existentes. + +## Schema tabla `jobs` (generico) + +```sql +CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + worker_id TEXT NOT NULL, -- antes "enricher_id" + target_id TEXT NOT NULL, -- antes "node_id" (para odr es dataset_key, etc) + target_label TEXT, -- antes "node_name" + status TEXT NOT NULL, -- queued|running|done|error|cancelled + progress REAL DEFAULT 0, + stage TEXT, + error TEXT, + result_json TEXT, + params_json TEXT, -- params del manifest serializados + created_at INTEGER NOT NULL, + started_at INTEGER, + finished_at INTEGER +); +CREATE INDEX IF NOT EXISTS jobs_status_idx ON jobs(status); +CREATE INDEX IF NOT EXISTS jobs_worker_idx ON jobs(worker_id); +``` + +`graph_explorer` y `odr_console` comparten schema. Diferencia solo en interpretacion de `target_id`/`result_json` (callback per-app). + +## Riesgos + +- graph_explorer es app C++ activa con tests pasando. Romper imports = romper produccion. +- Camino seguro: rama TBD `issue/0065-extract-jobs-to-registry` en sub-repo de graph_explorer + sub-repo de fn_registry. Mergear ambos cuando build verde. +- Feature flag NO aplica (cambio de codigo sin runtime toggle posible). + +## Criterios de aceptacion + +- [ ] Funciones del registry creadas con tests + .md. +- [ ] graph_explorer compila y pasa tests existentes (32 WSL + 21 Win). +- [ ] `fetch_webpage` enricher funciona end-to-end en graph_explorer tras refactor. +- [ ] odr_console (issue 0066) puede importar `jobs_pool_cpp_core` y lanzar 1 collector dummy. +- [ ] Documentacion actualizada en `cpp/PATTERNS.md` mencionando jobs_pool como pieza estandar. + +## Out of scope + +- Migrar el sistema a Go (issue futura si vale la pena). +- Cambiar wire protocol (ya estable, no romper enrichers existentes). diff --git a/dev/issues/0066-online-data-recopilation-mvp.md b/dev/issues/0066-online-data-recopilation-mvp.md new file mode 100644 index 00000000..48fe2efc --- /dev/null +++ b/dev/issues/0066-online-data-recopilation-mvp.md @@ -0,0 +1,96 @@ +--- +id: 0066 +title: online_data_recopilation — odr_console MVP (lanzador GUI + 5-pasos + 1 collector) +status: pending +priority: high +created: 2026-05-09 +blocked_by: [0065] +--- + +## Objetivo + +App C++ ImGui en `projects/online_data_recopilation/apps/odr_console/` que: + +1. Lanza cualquier funcion/pipeline del registry desde panel GUI con form auto-generado (params_schema). +2. Implementa el bucle reactivo de 5 pasos sobre `operations.db` propia. +3. Reusa jobs system del registry (issue 0065) para concurrencia. +4. Reusa enricher protocol + `cdp-cli` + funciones Python `fetch_webpage`/`web_search`/etc de osint_graph. + +## Decisiones tomadas + +| Tema | Decision | +|---|---| +| Workers default | 4 | +| operations.db | Una unica por la app | +| DuckDB | Embebido (linkar libduckdb) | +| Collectors lang | Python primero; bash/go en futuras issues | +| Browser | CDP via `cdp-cli` (issue 0038) | +| Concurrencia | jobs_pool_cpp_core (issue 0065) | +| TBD | Obligatorio (regla apps_tbd) — sub-repo `dataforge/odr_console` | + +## Alcance MVP (este issue) + +### Esqueleto codigo + +- `main.cpp` — `fn::run_app` con AppConfig + render() + paneles. +- `data_registry.cpp/h` — abre `registry.db` RO, expone `search(query)`, `get_function(id)`. +- `data_operations.cpp/h` — abre `operations.db` RW, CRUD de relations/executions/entities/types_snapshot/assertions/assertion_results. +- `data_duck.cpp/h` — abre `local_files/odr.duckdb`, `query(sql) -> rows`, `ingest_parquet(path, table)`. +- `views_launcher.cpp/h` — panel busqueda FTS5 + lista resultados + form params + boton "Run" → encola job. +- `views_jobs.cpp/h` — panel jobs queue (pendientes/running/done) + live progress. +- `views_datasets.cpp/h` — panel DuckDB query editor + tabla preview. +- `CMakeLists.txt` — `add_imgui_app(odr_console ...)` con SQLite, libduckdb, jobs_pool del registry. + +### Migrations operations.db + +`migrations/001_init.sql` — schema 5-pasos completo: + +```sql +CREATE TABLE IF NOT EXISTS relations (...); -- pipelines diseñados +CREATE TABLE IF NOT EXISTS executions (...); -- runs con metricas +CREATE TABLE IF NOT EXISTS entities (...); -- datos recopilados +CREATE TABLE IF NOT EXISTS types_snapshot (...); -- copia schema registry +CREATE TABLE IF NOT EXISTS assertions (...); -- reglas SQL +CREATE TABLE IF NOT EXISTS assertion_results (...); -- resultados eval +``` + +Reusar schema de `fn_operations/migrations/` adaptado. + +### Collector MVP: `api_hn_top` + +`collectors/api_hn_top/`: +- `manifest.yaml`: id, name, description, params (limit), uses_functions (`http_get_json_py_*`). +- `run.py`: lee stdin JSON {ops_db_path, app_dir, registry_root, params}, fetcha HN top stories API, escribe parquet a `vault/raw/hn_top_.parquet`, inserta `entity` con `metadata.{path,row_count,checksum,source}`, emite `PROGRESS:` por stderr. + +Verificacion end-to-end: +1. Lanzar odr_console. +2. Buscar "hn_top" en launcher → click Run. +3. Job aparece en panel jobs, progress llega a 100. +4. Entity en operations.db tabla `entities`. +5. Parquet en `vaults/odr_data/raw/`. +6. Datasets panel lo lista, query SQL devuelve filas. + +## Out of scope MVP (issues futuras) + +- Pipeline builder DAG (`imgui_node_editor`). +- Assertions panel (eval --react). +- Proposals inbox. +- Browser CDP collectors (`browser_capture_dom`, `browser_login_capture`). +- Watchlists / scheduling. +- Rate limiting global. +- Form auto-generador desde `params_schema` complejo (MVP: solo strings + ints). + +## Criterios aceptacion + +- [ ] App compila en WSL + Windows. +- [ ] `app.md` indexado por `fn index` (aparece en `apps`). +- [ ] Repo Gitea creado (`dataforge/odr_console`) y branch master sincronizado. +- [ ] Collector `api_hn_top` recupera 30 stories, parquet escrito, entity creado. +- [ ] Panel datasets ejecuta `SELECT count(*) FROM hn_top`. +- [ ] Logs ImGui muestran `fn_log::log_info` calls del flujo. + +## Riesgos + +- Build C++ + DuckDB + SQLite + jobs_pool → CMake complejo. Vendoring limpio + apuntes en `cpp/PATTERNS.md`. +- libduckdb en Windows: probar `duckdb.dll` junto al exe. +- Collectors Python embebido (issue 0033 runtime) — MVP puede arrancar con `python3` del sistema; embeber despues. diff --git a/dev/issues/0067-odr-osint-prereqs-roadmap.md b/dev/issues/0067-odr-osint-prereqs-roadmap.md new file mode 100644 index 00000000..fff93a74 --- /dev/null +++ b/dev/issues/0067-odr-osint-prereqs-roadmap.md @@ -0,0 +1,168 @@ +--- +id: 0067 +title: Roadmap de prereqs — issues de osint_graph que odr_console necesita antes/durante MVP +status: pending +priority: high +created: 2026-05-09 +related: [0065, 0066] +references: [ + "projects/osint_graph/apps/graph_explorer/issues/0033-multilang-dispatcher-embedded-python.md", + "projects/osint_graph/apps/graph_explorer/issues/0033c-fn-check-vendored.md", + "projects/osint_graph/apps/graph_explorer/issues/0033d-indexer-python-runtime-fields.md", + "projects/osint_graph/apps/graph_explorer/issues/0033e-compile-skill-orchestration.md", + "projects/osint_graph/apps/graph_explorer/issues/0038-browser-launch-cdp-control.md", + "projects/osint_graph/apps/graph_explorer/issues/0039-cookie-session-manager.md", + "projects/osint_graph/apps/graph_explorer/issues/0040-multi-profile-management.md", + "projects/osint_graph/apps/graph_explorer/issues/0029-enrichers-cdp.md", + "projects/osint_graph/apps/graph_explorer/issues/0030-deep-enrich-macro.md", + "projects/osint_graph/apps/graph_explorer/issues/0021-command-palette.md", + "projects/osint_graph/apps/graph_explorer/issues/0034-port-system-enrichers-to-go.md", + "projects/osint_graph/apps/graph_explorer/issues/0014-browser-extension.md", + "projects/osint_graph/apps/graph_explorer/issues/0012-http-ingest-endpoint.md", + "projects/osint_graph/apps/graph_explorer/issues/0017-gx-cli.md" +] +--- + +## Objetivo + +Documentar el orden de dependencias entre las issues de `graph_explorer` y los issues `0065` (extract jobs) + `0066` (odr_console MVP) para que ambas apps compartan infra (jobs system, runtime Python embebido, CDP browser, profiles, sessions) sin reescribir codigo. **Este issue NO implementa nada** — es el meta-plan que ordena en que orden se atacan las dependencias previas. + +## Contexto + +`odr_console` es la segunda app C++ ImGui que necesita el mismo stack de recoleccion de datos que `graph_explorer`: + +- Workers concurrentes con subprocess + wire protocol stdin/stderr/stdout (jobs system, issue 0026 ya completado). +- Python embebido portable (sin WSL) — issue 0033 pendiente. +- Browser CDP con profiles + cookies — issues 0038/0039/0040 pendientes. +- Compile skill que empaqueta runtime + vendored deps — issues 0033c/d/e pendientes. + +Sin este orden: +- `odr_console` arranca con dependencia WSL y se rompe en Windows nativo. +- Los collectors browser-driven no funcionan hasta tener CDP. +- Cada app duplicaria infra de jobs/cache/subprocess. + +## Arquitectura + +### Capas afectadas + +| Capa | Estado actual | Que falta para odr | +|---|---|---| +| Registry C++ (`cpp/functions/`) | tiene `tokens`, `app_settings`, `app_about`, `logger`, `panel_menu`, etc. en `fn_framework` | añadir `jobs_pool`, `subprocess_worker`, `job_cache_sha256`, `worker_manifest_loader` (issue 0065) | +| Registry Python (`python/functions/`) | tiene `fetch_webpage`, `web_search`, `extract_*`, `html_to_markdown`, `normalize_url` | reusar tal cual; vendor en collectors `_vendored/` | +| Runtime Python embebido | NO existe (graph_explorer usa WSL) | issue 0033 — runtime portable Linux+Windows | +| CDP browser control | NO existe | issues 0038/0039/0040 — `cdp-cli` Go binario + profiles + sessions | +| Compile skill | builds `.exe` Windows + DLLs + assets | issue 0033e — empaqueta runtime + vendored + go binaries | + +### Mapeo issues prereq → componente odr + +| Issue graph_explorer | Componente odr afectado | Bloqueo? | +|---|---|---| +| 0033 (multilang + Python embebido) | runtime portable, sin WSL | **bloquea release Windows** | +| 0033c (fn check vendored) | audit drift `_vendored/` ↔ registry | nice-to-have | +| 0033d (indexer python_runtime fields) | `app.md` declara python_runtime | bloquea 0033e | +| 0033e (compile skill orquesta) | distribuible self-contained | **bloquea release Windows** | +| 0034 (port enrichers a Go) | acelera arranque de collectors | opcional | +| 0038 (browser launch CDP) | collectors `browser_capture_dom`, `browser_login_capture` | bloquea browser collectors | +| 0039 (cookie session manager) | collectors con auth | bloquea OSINT-style scraping | +| 0040 (multi-profile management) | collectors paralelos por profile | bloquea multi-fuente | +| 0029 (enrichers via CDP) | template para `fetch_webpage_browser` collector | nice-to-have | +| 0030 (deep enrich macro) | patron para pipeline builder DAG | referencia, no bloquea | +| 0021 (command palette Ctrl+K) | UX del launcher panel | nice-to-have | +| 0014 (browser extension) | ingest desde browser → odr collectors | post-MVP | +| 0012 (HTTP ingest endpoint) | endpoint compartido entre apps | post-MVP | +| 0017 (gx CLI) | `odr` CLI espejo | post-MVP | + +## Tareas + +### Fase 1 — Bloqueantes para MVP odr (orden estricto) + +- **1.1** Issue **0065** (extract jobs system to registry). Bloquea 0066. Refactor seguro: graph_explorer sigue funcionando. +- **1.2** Issue **0033d** (indexer lee `python_runtime` fields). Pequeño, sin deps. Habilita 0033e. +- **1.3** Issue **0033** (multilang dispatcher + Python embebido). Critico para Windows nativo. Sin esto, odr depende de WSL igual que graph_explorer hoy. +- **1.4** Issue **0033e** (compile skill orquesta freeze + vendor + go builds). Permite que `/compile odr_console` produzca zip self-contained con runtime + collectors vendored. + +### Fase 2 — MVP odr_console + +- **2.1** Issue **0066** (odr_console MVP). Esqueleto + 1 collector `api_hn_top` end-to-end. Reusa 0065. NO depende de browser CDP (collector inicial es API JSON pura). +- **2.2** Verificar build Windows + WSL. Compile skill copia exe + Python runtime + collectors a `Desktop/apps/odr_console/`. + +### Fase 3 — Browser-driven collectors + +- **3.1** Issue **0038** (browser launch + CDP control). graph_explorer lo necesita igual. odr lo consume por subprocess `cdp-cli` igual que graph_explorer. +- **3.2** Issue **0039** (cookie session manager). Permite collectors con auth. +- **3.3** Issue **0040** (multi-profile management). Permite N collectors en paralelo con profiles distintos. +- **3.4** Crear collectors odr basados en CDP: `browser_capture_dom`, `browser_login_capture`, `browser_scroll_paginated` (issue futura). + +### Fase 4 — Calidad y UX (post-MVP) + +- **4.1** Issue **0033c** (fn check vendored). CI gate para drift detection. +- **4.2** Issue **0034** (port enrichers a Go). Reduce dependencia Python opcional. +- **4.3** Issue **0029** (enrichers via CDP — `fetch_webpage_browser`, `fetch_screenshot`). Templates aplicables como collectors. +- **4.4** Issue **0021** (command palette Ctrl+K). UX comun a las dos apps; extraer al registry. +- **4.5** Issue **0030** (deep enrich macro). Patron para pipeline builder DAG en odr. + +### Fase 5 — Integraciones cross-app (largo plazo) + +- **5.1** Issue **0012** (HTTP ingest endpoint local). Compartido por ambas apps. +- **5.2** Issue **0017** (gx CLI). Crear `odr` CLI espejo o unificar bajo `gx` con subcomando. +- **5.3** Issue **0014** (browser extension). Ingest desde browser apunta a 0012; ambas apps consumen. + +## Ejemplo de uso + +Roadmap visual del orden: + +``` +[1.1 0065 extract jobs] ──┐ +[1.2 0033d indexer fields] ──┤── prereqs MVP odr +[1.3 0033 multilang + python emb] ──┤ (sin estos, odr no compila distribuible) +[1.4 0033e compile orquesta] ──┘ + ↓ +[2.1 0066 odr MVP] ── MVP funcional con collector API +[2.2 verify Windows+WSL] + ↓ +[3.1 0038 browser CDP] ──┐ +[3.2 0039 cookie manager] ──┤── browser-driven collectors +[3.3 0040 multi-profile] ──┤ +[3.4 collectors browser] ──┘ + ↓ +[4.x calidad: vendored audit, go ports, command palette, deep enrich] + ↓ +[5.x cross-app: HTTP endpoint, CLI unificado, extension] +``` + +Bloqueo critico para "release Windows funcional": **1.1 → 1.2 → 1.3 → 1.4 → 2.1**. + +## Decisiones de diseño + +1. **Por que reordenar en lugar de bifurcar**: si odr clona jobs.cpp, divergira de graph_explorer en semanas. El registry-first justifica el coste de 0065 antes que 0066. + +2. **Por que 0033 es critico antes que MVP**: graph_explorer hoy depende de WSL para correr enrichers en Windows. Si odr arranca igual, **dos apps con la misma deuda**. Mejor pagar 0033 una vez para ambas. + +3. **Por que browser CDP no bloquea MVP**: el collector inicial `api_hn_top` es API JSON pura. Browser collectors son fase 3, una vez probado el flujo. Asi MVP llega a Windows en dias, no semanas. + +4. **Por que NO portar enrichers a Go (0034) antes**: optimizacion prematura. Python embebido (0033) ya da portabilidad. Go solo si la latencia molesta. + +5. **Por que command palette no es prereq**: UX nice-to-have. MVP launcher con search FTS5 + lista basta. 0021 se extrae al registry cuando ambas apps lo necesiten. + +## Prerequisitos + +- Issue 0026 (jobs system base) — completado. +- Issue 0033b (vendor python functions) — completado. +- `cpp/PATTERNS.md` y `cpp/DESIGN_SYSTEM.md` — ya autoritativas. +- Regla `apps_tbd.md` — sub-repos Gitea + branch master + TBD activo. + +## Riesgos + +| Riesgo | Mitigacion | +|---|---| +| Issue 0033 (Python embebido) es grande, retrasa MVP odr semanas | Hacer 0066 con `python3` del sistema primero, migrar a embebido al cerrar 0033. Coste: dos releases — Linux/WSL primero, Windows nativo despues. | +| Refactor 0065 rompe graph_explorer | TBD obligatorio en sub-repo de graph_explorer + tests pasando antes de mergear (32 WSL + 21 Win). | +| Browser CDP (0038) se enreda con perfiles existentes del usuario | Issue 0040 establece profiles propios bajo `/local_files/browser_profiles//`, no toca los del sistema. | +| Compile skill (0033e) explota build matrix Linux+Windows+freeze+vendor | Trabajo incremental: primero `python_runtime: false` sigue funcionando como hoy; cuando 0033e este, opt-in. | +| Drift entre `_vendored/` de graph_explorer y collectors de odr | 0033c (fn check vendored) gate en CI antes de mergear cambios en `python/functions/`. | + +## Out of scope + +- Implementar ninguno de los issues referenciados — este meta-plan solo ordena. +- Decidir si `gx` y `odr` se unifican bajo un CLI comun (ver fase 5). +- Diseño detallado del pipeline builder DAG de odr (ver issue 0066). diff --git a/dev/issues/0068-e2e-validation-loop-fn4-fn5.md b/dev/issues/0068-e2e-validation-loop-fn4-fn5.md new file mode 100644 index 00000000..f59c6f9b --- /dev/null +++ b/dev/issues/0068-e2e-validation-loop-fn4-fn5.md @@ -0,0 +1,176 @@ +--- +id: 0068 +title: Cerrar bucle reactivo — fn-analizador (fase 4) y fn-mejorador (fase 5) con contrato e2e_checks +status: pending +priority: high +created: 2026-05-09 +related: [0026, 0027, 0028] +--- + +## Contexto + +El bucle reactivo del registry (CONSTRUIR → EJECUTAR → RECOPILAR → ANALIZAR → MEJORAR) tiene agentes para fases 1-3: + +- **Fase 1 — CONSTRUIR**: `fn-constructor` (existe) +- **Fase 2 — EJECUTAR**: `fn-executor` (existe) +- **Fase 3 — RECOPILAR**: `fn-recopilador` (existe) +- **Fase 4 — ANALIZAR**: falta agente +- **Fase 5 — MEJORAR**: falta agente + +Sin fases 4 y 5 el bucle no cierra. Cada app sigue requiriendo iteracion manual: el humano lanza, mira, decide si funciona, y propone fixes. Objetivo: que apps lleguen a master correctas sin esa iteracion manual. + +## Objetivo + +Disponer de un gate automatico pre-merge que valide end-to-end cualquier app del registry, y un mejorador que proponga cambios cuando la validacion falla. Capacidad reutilizable, no especifica de un proyecto. + +## Diseno + +### Contrato `e2e_checks` en `app.md` + +Cada app declara su validacion end-to-end en el frontmatter: + +```yaml +e2e_checks: + - id: build + cmd: "cd frontend && pnpm build && cd .. && CGO_ENABLED=1 go build -tags fts5 -o kanban ." + timeout_s: 120 + - id: smoke + cmd: "./kanban --port 8095 --db /tmp/kanban_e2e.db &" + health: "http://127.0.0.1:8095/api/board" + timeout_s: 10 + - id: ops_audit + ref: "fn-recopilador:apps/kanban" + - id: migrations + cmd: "sqlite3 /tmp/kanban_e2e.db 'SELECT version FROM schema_migrations;'" + expect_exit: 0 +``` + +Tipos de check: +- `cmd` — comando shell, exit 0 = OK. +- `health` — espera `cmd` en background, hace GET y verifica 200. +- `ref` — apunta a otro agente/funcion del registry (ej. `fn-recopilador`, `fn-doctor`). +- `expect_exit`, `expect_stdout_contains`, `expect_stdout_json` — predicados sobre la salida. + +### fn-analizador (fase 4) + +Subagente nuevo. Input: `app_id`. Pasos: + +1. Lee `e2e_checks` del `app.md`. +2. Ejecuta cada check en orden, captura stdout/stderr/exit/duration. +3. Eval assertions activas via `fn ops assertion eval --react`. +4. Compara `executions.metrics` actual vs ventana historica (drift > umbral = warning). +5. Diff golden outputs si app declara `tests/golden/`. +6. Persiste resultado en nueva tabla `e2e_runs` de `operations.db`. +7. Devuelve veredicto caveman: + ``` + build ✓ 42s + smoke ✓ 0.8s + ops_audit ✓ + assertion:R1 ✗ warning duration drift +47% vs p50 + ``` + +Tools: Read, Bash, Grep, Glob. Escribe SOLO `assertion_results`, `entity.status`, `e2e_runs`. NO toca registry.db. + +### fn-mejorador (fase 5) + +Subagente nuevo. Input: salida de `fn-analizador` + `app_id`. Pasos: + +1. Filtra fallos: `critical` → `kind=bug`, `warning` → `kind=optimization`. +2. Por cada fallo, busca contexto en registry: funciones tocadas, ultimas N proposals. +3. Crea proposal con `created_by=reactive_loop`, `evidence=[assertion_id, execution_id, e2e_run_id]`. +4. Sugiere fix concreto (parametro, funcion a partir, refactor) — texto, NO codigo. +5. Si fallo recurrente (>3 veces misma assertion) → `priority=high`. + +Tools: Read, Bash, Grep. Escribe SOLO `proposals` en registry.db. Nunca modifica funciones. + +### Skill `/validate-app ` + +Orquesta cadena: `fn-executor` → `fn-recopilador` → `fn-analizador` → `fn-mejorador`. Devuelve tabla pass/fail + IDs de proposals creadas. + +### Migracion `006_e2e_runs.sql` + +```sql +CREATE TABLE IF NOT EXISTS e2e_runs ( + id TEXT PRIMARY KEY, + app_id TEXT NOT NULL, + started_at INTEGER NOT NULL, + finished_at INTEGER, + status TEXT NOT NULL, -- pass|fail|partial + checks_total INTEGER NOT NULL, + checks_pass INTEGER NOT NULL, + checks_fail INTEGER NOT NULL, + summary_json TEXT NOT NULL -- detalle por check +); +CREATE INDEX IF NOT EXISTS e2e_runs_app_idx ON e2e_runs(app_id, started_at DESC); +``` + +### Funciones nuevas del registry + +Delegables a `fn-constructor`: + +| ID | Domain | Que hace | +|---|---|---| +| `e2e_run_checks_go_infra` | infra | Ejecuta lista de checks, devuelve `[]CheckResult` | +| `golden_diff_go_core` | core | Compara archivo vs golden con umbral | +| `metrics_drift_go_datascience` | datascience | p50/p95 historico vs actual | +| `proposal_from_failure_go_infra` | infra | Formatea evidencia → proposal | +| `health_probe_go_infra` | infra | GET con timeout, retry, codigo esperado | + +Tipos: + +| ID | Que | +|---|---| +| `E2ECheck_go_infra` | struct check (id, cmd, ref, health, expect_*) | +| `CheckResult_go_infra` | struct resultado (id, status, duration_ms, stdout, stderr, exit) | + +### fn-recopilador como diseñador de checks + +`fn-recopilador` se extiende para **proponer e2e_checks por app** (modo design): + +- Inspecciona `app.md` (lang, framework, entry_point, uses_functions). +- Detecta patrones conocidos: + - Go service con frontend Vite → propone build (pnpm + go), smoke (puerto + endpoint health). + - C++ ImGui app → propone build (cmake), smoke (`--self-test` o lanzar y matar tras N segundos). + - Python pipeline → propone run con args dummy y verificar exit 0. +- Audita `operations.db` y deriva `ops_audit` automatico. +- Escribe propuesta en `app.md` como bloque `e2e_checks_suggested:` para que humano apruebe → renombre a `e2e_checks:`. + +Comando: `fn-recopilador design-e2e `. + +Asi `fn-analizador` recibe contratos completos de fabrica y solo necesita ejecutar. + +## Plan de ejecucion + +| Paso | Tarea | Estado | +|---|---|---| +| 1 | Contrato `e2e_checks` en `docs/templates/app.md` + 2 apps piloto (kanban, graph_explorer) | en curso | +| 2 | Funciones registry: `e2e_run_checks`, `golden_diff`, `metrics_drift`, `proposal_from_failure`, `health_probe` | pendiente | +| 3 | Migracion `006_e2e_runs.sql` en `fn_operations/migrations/` | pendiente | +| 4 | Subagente `fn-analizador` + skill `/validate-app` | pendiente | +| 5 | Extender `fn-recopilador` con modo `design-e2e` | pendiente | +| 6 | Subagente `fn-mejorador` | pendiente | +| 7 | Gate opcional en `/git-push` para apps con `e2e_checks` declarado | pendiente | + +## Criterios de aceptacion + +- [ ] Template `docs/templates/app.md` con seccion `e2e_checks` documentada. +- [ ] `apps/kanban/app.md` declara `e2e_checks` (build + smoke + ops_audit + migrations). +- [ ] `projects/osint_graph/apps/graph_explorer/app.md` declara `e2e_checks` (build + tests pytest + enricher smoke). +- [ ] `fn-recopilador` puede sugerir `e2e_checks` para una app dada. +- [ ] `fn-analizador` corre los checks y devuelve veredicto caveman. +- [ ] `fn-mejorador` crea proposals con evidencia cuando hay fallos. +- [ ] Skill `/validate-app ` orquesta la cadena completa. +- [ ] Documentacion en `.claude/rules/` (nueva regla `e2e_validation.md`). + +## Riesgos + +- **Golden tests para C++/UI son caros**. Empezar build+smoke+assertions; goldens solo donde aporten (graph_explorer ya tiene capture system). +- **Apps sin operations.db** (kanban usa `kanban.db` propia, no `operations.db`). El check `ops_audit` debe aceptar BD alternativa o saltarse. +- **Smoke tests con puertos en uso**. Los pilotos deben usar puertos efimeros (`--port 0` o range alto) y BDs en `/tmp/`. +- **Adopcion gradual**. Apps sin `e2e_checks` no se validan (y no bloquean merge). Visible en `fn doctor`. + +## Out of scope + +- Reemplazar `fn doctor` (que sigue siendo diagnostico read-only del estado). +- Tests unitarios de funciones (siguen en `*_test.go`, `pytest`, etc.). +- Performance benchmarks formales (los `metrics_drift` son aproximacion, no benchmark). diff --git a/dev/issues/0069-autonomous-agent-loop-self-iterating-tasks.md b/dev/issues/0069-autonomous-agent-loop-self-iterating-tasks.md new file mode 100644 index 00000000..62e8d7b0 --- /dev/null +++ b/dev/issues/0069-autonomous-agent-loop-self-iterating-tasks.md @@ -0,0 +1,220 @@ +--- +id: 0069 +title: Bucle autonomo de subagentes — completar y mejorar tareas sin intervencion humana +status: pending +priority: medium +created: 2026-05-09 +depends_on: [0068] +related: [0026, 0027, 0028] +--- + +## Contexto + +El issue 0068 cierra el bucle reactivo a nivel **agentes individuales**: + +- fn-1 `fn-constructor` — construye codigo +- fn-2 `fn-executor` — ejecuta +- fn-3 `fn-recopilador` — audita datos + diseña contrato e2e +- fn-4 `fn-analizador` — valida end-to-end +- fn-5 `fn-mejorador` — abre proposals con evidencia + +Sin embargo, cada fase la **lanza un humano** (o el main thread bajo instruccion humana). El humano sigue siendo el orquestador. La promesa real es: + +> "Lanzar una tarea al sistema, irse, y volver para encontrarla terminada y mejorada." + +Esa promesa requiere un **orquestador autonomo** que recorra el bucle CONSTRUIR → EJECUTAR → RECOPILAR → ANALIZAR → MEJORAR sin intervencion humana, hasta convergencia (suite verde) o tope de iteraciones / tiempo. + +Este issue planifica ese orquestador. + +## Objetivo + +Disponer de un **runner autonomo** que toma una tarea (issue, feature, app skeleton) y la entrega **funcional, validada y con proposals para mejoras** sin intervencion humana entre fases. El humano solo: + +1. Define el objetivo (issue file). +2. Recibe el resultado al final (PR draft, run_ids verdes, proposals creadas). + +Si el bucle no converge en N iteraciones o T tiempo, se para y reporta el estado al humano para decision. + +## Diseno + +### Componente nuevo: `fn-orquestador` + +Subagente meta-orquestador. Input: `issue_id` o `task_spec`. Algoritmo: + +``` +1. Leer task_spec (issue file: objetivo, criterios de aceptacion, fase actual) +2. Loop hasta convergencia o limite: + a. Determinar siguiente fase pendiente (CONSTRUIR/EJECUTAR/RECOPILAR/ANALIZAR/MEJORAR) + b. Despachar al subagente correspondiente con prompt derivado del task_spec + c. Capturar output del subagente + d. Persistir progreso en task_runs (nueva tabla) + e. Si fase = ANALIZAR y status = pass: + - Aplicar fixes propuestos por MEJORAR (con limite de auto-apply, ver §Garantías) + - Repetir desde CONSTRUIR si todavia hay criterios sin cumplir + f. Si fase = ANALIZAR y status = fail: + - Despachar a MEJORAR + - Aplicar 1-2 proposals automaticas (solo si pasan filtros de seguridad, ver §Garantías) + - Volver a CONSTRUIR/EJECUTAR para validar + g. Si N iteraciones sin progreso → parar, reportar estado, pedir humano +3. Reportar resultado final: estado tarea, run_ids, proposals creadas, PR draft si aplica +``` + +### Nueva tabla `task_runs` en operations.db + +```sql +CREATE TABLE task_runs ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, -- issue_id o slug + started_at INTEGER NOT NULL, + finished_at INTEGER, + status TEXT NOT NULL, -- running|converged|stalled|aborted + iterations INTEGER NOT NULL DEFAULT 0, + last_phase TEXT, -- construir|ejecutar|recopilar|analizar|mejorar + last_run_id TEXT, -- e2e_runs.id de la ultima validacion + progress_json TEXT NOT NULL DEFAULT '[]' -- log de fases con timestamps +); +``` + +### Skill `/autonomous-task ` + +Lanza el `fn-orquestador` con limites configurables: + +``` +/autonomous-task 0070 --max-iterations 10 --max-minutes 60 --auto-apply-proposals safe +``` + +Flags: +- `--max-iterations N`: tope de iteraciones del bucle (default 10). +- `--max-minutes M`: timeout total (default 60). +- `--auto-apply-proposals`: nivel de autonomia para aplicar proposals: + - `none`: solo crea proposals, nunca aplica codigo. + - `safe`: aplica proposals con `kind=improve_function` y diff < 50 lineas. + - `aggressive`: aplica casi todas salvo las marcadas `risk=high`. +- `--branch `: rama TBD donde trabaja el bucle (default `auto/`). +- `--dry-run`: no aplica nada, solo simula y reporta plan. + +### Garantias de seguridad + +El bucle autonomo es peligroso si el agente: +- Borra archivos importantes. +- Bypasea tests. +- Toca produccion. +- Mergea codigo roto a master. + +Reglas obligatorias: + +1. **Sandbox de rama**. El orquestador SIEMPRE trabaja en rama `auto/`, nunca master. +2. **No `--no-verify`, no `git push --force`**. Hooks de pre-commit del repo se respetan. +3. **No mergea a master**. Genera PR draft. Humano aprueba el merge. +4. **No toca `.claude/`, `dev/issues/` salvo el del task** ni archivos `.env`/secrets. Lista de paths protegidos en `dev/autonomous_protected_paths.json`. +5. **Filtro de proposals auto-aplicables**: + - Solo proposals creadas por `fn-mejorador` con `evidence` que apunte a runs reales. + - Diff < 50 lineas (configurable). + - No tocan tests existentes (no se "arreglan" los tests). + - No introducen dependencias nuevas (`pnpm add`, `go get`, etc). +6. **Watchdog de progreso**. Si 2 iteraciones consecutivas dan el mismo set de fails, parar y pedir humano (loop infinito detectado). +7. **Auditoria completa**. Cada decision del orquestador se loggea en `task_runs.progress_json` con razonamiento + diff aplicado. +8. **Rollback trivial**. La rama es desechable; si la suite no converge, humano puede `git branch -D auto/` y empezar de nuevo. + +### Tipos de tareas soportadas + +Empezar con un subset acotado: + +| Tipo | Descripcion | Convergencia | +|---|---|---| +| `feature_app_simple` | Endpoint nuevo + handler + test | suite verde + endpoint responde 200 | +| `bugfix_with_repro` | Issue con repro reproducible | repro pasa de fail a pass | +| `refactor_safe` | Renombrar/extraer funcion + actualizar callers | suite igual de verde + grep limpio | +| `add_e2e_check` | Crear `e2e_checks` para app sin ellos via fn-recopilador | app tiene contrato + run pasa | + +NO soportadas inicialmente (requieren mas heuristica): +- Diseño de arquitectura nuevo. +- Decisiones de UX subjetivas. +- Cambios en BD productiva. +- Cualquier cosa que toque secrets/credenciales. + +### Convergencia + +El bucle termina cuando: +- **Convergido**: todos los criterios de aceptacion del issue marcan ✓ Y la suite e2e pasa Y `fn doctor ` pasa. +- **Estancado**: misma metric de fallos en 2+ iteraciones (loop sin progreso). +- **Timeout**: `--max-minutes` alcanzado. +- **Iteraciones**: `--max-iterations` alcanzado. +- **Bloqueo humano**: el orquestador detecta una decision que requiere humano (ej. eleccion de libreria nueva, schema breaking change) y para con `status=needs_human`. + +En cualquier caso, output: + +``` +=== /autonomous-task: === +status: +iterations: N / max +duration: M min / max +branch: auto/ +PR draft: +proposals: creadas, aplicadas +last run_id: (status: pass|fail) + +Detalle de iteraciones: + 1. construir → ok (3 funciones nuevas) + 2. ejecutar → ok + 3. analizar → fail (3 checks) + 4. mejorar → 3 proposals (2 auto-aplicadas) + 5. construir → ok (re-build tras patches) + 6. analizar → pass + 7. recopilador → ok (operations.db integra) + 8. CONVERGED + +Siguientes pasos para humano: + - Revisar PR draft + - fn proposal list -s pending --target-id +``` + +## Plan de ejecucion + +| Paso | Tarea | Dependencia | +|---|---|---| +| 1 | Migracion `006_task_runs.sql` en `fn_operations/migrations/` | issue 0068 cerrado | +| 2 | Subagente `fn-orquestador` (.claude/agents/fn-orquestador/SKILL.md) | paso 1 | +| 3 | Lista de paths protegidos (`dev/autonomous_protected_paths.json`) | paso 2 | +| 4 | Skill `/autonomous-task ` (.claude/commands/autonomous-task.md) | paso 2 | +| 5 | Funciones registry: `task_run_persist_go_infra`, `proposal_filter_safe_go_infra`, `git_branch_sandbox_go_infra`, `pr_draft_create_go_infra` | paso 2 | +| 6 | Tipo `TaskSpec_go_core` (issue + criterios + limites) | paso 5 | +| 7 | Pilotaje en 1 issue tipo `feature_app_simple` (ej. añadir endpoint trivial a kanban) | paso 4 | +| 8 | Pilotaje en 1 issue tipo `add_e2e_check` (correr orquestador para añadir contrato a app sin el) | paso 7 | +| 9 | Hardening: tests del orquestador, edge cases (red flaky, BD locked, conflicto merge) | paso 8 | +| 10 | Documentacion + regla `.claude/rules/autonomous_loop.md` | paso 9 | + +## Criterios de aceptacion + +- [ ] `fn-orquestador` definido como subagente, model haiku-4-5 o sonnet-4-6 (probar ambos). +- [ ] Tabla `task_runs` migrada con migration aditiva, sin romper apps existentes. +- [ ] Skill `/autonomous-task` orquesta los 5 subagentes en bucle. +- [ ] Filtro de proposals auto-aplicables documentado y testeado contra dataset adverso. +- [ ] Pilotaje exitoso en 2 issues distintas: feature_app_simple + add_e2e_check. +- [ ] Watchdog de "no progreso" detiene loops en pruebas con tareas imposibles. +- [ ] Output del runner incluye trazabilidad completa (cada decision + diff aplicado). +- [ ] Documentacion en `.claude/rules/autonomous_loop.md`. + +## Riesgos + +- **Loops infinitos**: agentes que "parchean" tests rotos en vez de codigo. Mitigacion: filtro de proposals (no tocar tests), watchdog. +- **Coste**: cada iteracion = N llamadas a Claude. Mitigacion: `--max-iterations`, `--max-minutes`, modelos mas baratos para fases mecanicas (haiku para `fn-recopilador`, sonnet para `fn-constructor`). +- **Calidad**: codigo auto-generado puede compilar pero ser malo. Mitigacion: `fn-analizador` valida no solo build sino assertions + drift; humano siempre revisa PR. +- **Seguridad**: agente comprometiendo el repo. Mitigacion: sandbox de rama, paths protegidos, no merge automatico, hooks no skipeables. +- **Drift de criterios**: el agente "interpreta" liberamente los criterios de aceptacion del issue. Mitigacion: criterios en el issue deben ser verificables programaticamente (ej. "endpoint responde 200" mejor que "el endpoint funciona bien"). +- **Acumulacion de proposals**: si el bucle crea muchas proposals sin que humano las cierre, ruido. Mitigacion: limite por task_run, dedup automatica por similitud. + +## Out of scope + +- Auto-merge a master (siempre PR draft). +- Toma de decisiones de arquitectura (eleccion de libreria, patron de diseño). +- Tareas que requieran credenciales (deploys, llamadas a APIs externas con auth). +- Tareas que toquen schema de DBs productivas. +- Self-modification del orquestador (no se puede mejorar a si mismo en el mismo run). + +## Notas + +- Inspiracion: SWE-bench, agentic flows tipo aider/cursor compose, Devin. Diferencia: aqui el agente NO escribe codigo libre — orquesta agentes especializados que ya respetan las reglas del registry. +- El bucle reactivo del CLAUDE.md ya describe semantica de fases. Este issue solo añade el **orquestador** que las recorre solo. +- La regla `kiss.md` aplica: empezar con tipos de tarea simples y verificables. Resistir tentacion de soportar todo desde dia 1. +- Conexion con `feature_flags.md`: si el bucle queda detras de un flag (`autonomous_loop_enabled`), se puede activar/desactivar sin redeploy. diff --git a/dev/issues/0070-browser-helpers-global-roadmap.md b/dev/issues/0070-browser-helpers-global-roadmap.md new file mode 100644 index 00000000..fc6edc98 --- /dev/null +++ b/dev/issues/0070-browser-helpers-global-roadmap.md @@ -0,0 +1,140 @@ +--- +id: 0070 +title: Funciones globales del registry para control de navegador (find by text, click by text, scroll, HAR, jsonld, opengraph, ...) +status: pending +priority: medium +created: 2026-05-09 +related_apps: [navegator_dashboard, graph_explorer] +related_issues_app: [navegator/0001, graph_explorer/0038] +--- + +## Contexto + +`functions/browser/` (Go) tiene hoy 12 primitivas CDP de bajo nivel: `cdp_connect`, `cdp_navigate`, `cdp_evaluate`, `cdp_get_html`, `cdp_screenshot`, `cdp_click` (por selector CSS), `cdp_type_text`, `cdp_wait_load`, `cdp_wait_element`, `cdp_set_cookie`, `cdp_close`, `chrome_launch`. Todas reutilizables, todas testeadas. + +Estas primitivas son suficientes para muchos casos pero **siguen siendo bajo nivel**: el consumidor (cdp-cli, navegator_dashboard, agentes) tiene que componer manualmente patrones que se repiten una y otra vez. Falta una capa intermedia de funciones de mas alto nivel que **resuelvan tareas reales** sin que cada caller las re-componga. + +## Objetivo + +Ampliar `functions/browser/` (Go preferentemente, Python cuando aplique) con primitivas componibles que resuelvan los patrones recurrentes: + +1. **Localizacion robusta de elementos**: por texto visible, por XPath, por aria-label. +2. **Acciones combinadas**: click por texto, fill form, submit, scroll-to, wait-navigation. +3. **Captura estructurada**: HAR, jsonld, opengraph, meta tags, schema.org. +4. **Navegacion segura**: navegacion + wait + retry + screenshot diff. +5. **Multi-tab / multi-instance**: enumerar tabs de N instancias, copiar cookie entre profiles. + +Cualquier app del registry que controle browsers (navegator_dashboard, cdp-cli, graph_explorer, futuras) consume esta capa en vez de reimplementar. + +## Funciones propuestas (lista inicial) + +Todas en `functions/browser/`. Lenguaje preferido entre parentesis. Pureza siempre `impure` (todas tocan red/IO). + +### Localizacion / queries + +| ID | Que hace | +|---|---| +| `cdp_find_by_text_go_browser` | Devuelve selector CSS unico del primer elemento cuyo `innerText` contiene un texto dado. Filtros: `tag`, `case_sensitive`, `exact`. | +| `cdp_find_all_by_text_go_browser` | Como el anterior pero retorna lista de selectores. | +| `cdp_find_by_xpath_go_browser` | Equivalente con XPath. | +| `cdp_find_by_aria_go_browser` | Por `aria-label` o `role`. Util en SPAs accesibles. | +| `cdp_get_attribute_go_browser` | `.getAttribute(name)`. | +| `cdp_count_elements_go_browser` | `document.querySelectorAll(sel).length`. | + +### Acciones + +| ID | Que hace | +|---|---| +| `cdp_click_text_go_browser` | `find_by_text` + `click`. Errores claros si no encuentra. | +| `cdp_fill_form_go_browser` | Recibe `map[selector]value`, hace click + type por cada uno. | +| `cdp_submit_form_go_browser` | Click en boton submit + `wait_navigation`. | +| `cdp_scroll_to_go_browser` | `selector.scrollIntoView({block:'center'})` + verifica que esta en viewport. | +| `cdp_press_key_go_browser` | `Input.dispatchKeyEvent` con tecla nombrada (Enter, Tab, Escape, ArrowDown). | +| `cdp_hover_go_browser` | `Input.dispatchMouseEvent` mouseMoved sobre el elemento. | + +### Esperas robustas + +| ID | Que hace | +|---|---| +| `cdp_wait_navigation_go_browser` | Espera `Page.frameNavigated` + `Page.loadEventFired` con timeout. | +| `cdp_wait_text_go_browser` | Espera a que aparezca un texto en cualquier sitio del DOM. | +| `cdp_wait_url_go_browser` | Espera a que `location.href` matchee un regex. | +| `cdp_wait_idle_go_browser` | Espera a que la red este idle N ms (Network.* events). | + +### Captura estructurada + +| ID | Que hace | +|---|---| +| `cdp_har_record_go_browser` | Subscribe `Network.*` durante una accion, retorna HAR JSON estandar. | +| `extract_jsonld_py_browser` | Parse `