Compare commits
422 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe784d090f | |||
| 88119ee1b2 | |||
| 282c2e3ba8 | |||
| 950b994797 | |||
| 23f5f1c25f | |||
| be8a61e724 | |||
| 80f44cc89e | |||
| 188122812a | |||
| e2ecdc7533 | |||
| 7d82359a45 | |||
| 4e8b5af6c4 | |||
| cfdf515228 | |||
| d110aa40f9 | |||
| aec5d82011 | |||
| 88b5b27dc0 | |||
| 574b3f6823 | |||
| 552c40bc42 | |||
| 1702f12664 | |||
| a802f59f55 | |||
| ef60449e64 | |||
| c7904a7dcb | |||
| b4c28da2ba | |||
| 2297edf2ab | |||
| 9d0a1d99e8 | |||
| a396ee781a | |||
| 42c14fae59 | |||
| bd036cf3d4 | |||
| b5fc99c2fa | |||
| 401d8523b4 | |||
| b8dd7ea018 | |||
| da61fa4d47 | |||
| aca2348a20 | |||
| 4b9698b1b7 | |||
| bf78a8c9be | |||
| f851a63742 | |||
| 783a232104 | |||
| 5bd0862d8c | |||
| aceb10b672 | |||
| 416b15786d | |||
| 83c16d81b4 | |||
| 8618aa1be3 | |||
| 4d5a5bd3ea | |||
| 793481bb11 | |||
| c3fe61818e | |||
| 1ffedbf48d | |||
| c9bb356ffe | |||
| fc627930f9 | |||
| c2d156a8fb | |||
| c149ea161f | |||
| 7490336709 | |||
| 75714c9007 | |||
| 625569485f | |||
| c0e0ceadd8 | |||
| 32fc9c725b | |||
| c5f1b55a8e | |||
| 76dcb05bd3 | |||
| 046f3ab2cb | |||
| 5bee3d813f | |||
| 5194de3c04 | |||
| 1e8ade0ed4 | |||
| b4db4e4ef5 | |||
| dabc945eda | |||
| f5c651d1f1 | |||
| 3b3378cfc1 | |||
| e72d6364d4 | |||
| 7894a3d54a | |||
| ea899daa14 | |||
| 7b0384c804 | |||
| d115d8e830 | |||
| 07d06d5e7d | |||
| b04bb846c7 | |||
| 3de82c53c1 | |||
| 80e1076d99 | |||
| 46ac1ee031 | |||
| a028928bc7 | |||
| 71f55e0c17 | |||
| 81d8a7c95d | |||
| 6249e01419 | |||
| f102aba952 | |||
| 471e14caf7 | |||
| 563c6c7677 | |||
| bfc93d6997 | |||
| e6451b4912 | |||
| 1a3538785c | |||
| ada9b96765 | |||
| ddf45c6e41 | |||
| 7761740d53 | |||
| 0076870e99 | |||
| e1f41b263d | |||
| 829bd64aaa | |||
| dff0c0d2b7 | |||
| 2341a4a0ca | |||
| fea3cdad5d | |||
| fa5bcca155 | |||
| fa9b1d449d | |||
| 0a76353a13 | |||
| b10c545479 | |||
| 428b203e53 | |||
| e3a84b1635 | |||
| 6526da32dc | |||
| 0904409c59 | |||
| 25a809e3eb | |||
| 336051fef5 | |||
| c1396db84d | |||
| 1861205504 | |||
| 313f857c23 | |||
| 7644a50d00 | |||
| bbce9541c9 | |||
| 35312ea66e | |||
| 982a9f9a2b | |||
| 54cee13e8e | |||
| 777b071ef8 | |||
| ac11300335 | |||
| eb2078ac9a | |||
| b9ffc13caf | |||
| a6e3298f1b | |||
| 79b5f0b194 | |||
| 9a2fe5349b | |||
| 427262b892 | |||
| 97725e0641 | |||
| 32e58556fa | |||
| ebc012a5db | |||
| 2124f6be07 | |||
| 9904d5cd63 | |||
| 7c09255c8a | |||
| 6dd1fe07bd | |||
| 9b2745fa25 | |||
| cbe162630c | |||
| 63fbbb9cd0 | |||
| 6d70160919 | |||
| f906ffbec4 | |||
| 2aceccfd7e | |||
| cd445d8e1a | |||
| aeec68a552 | |||
| 70a996d654 | |||
| a03da106a6 | |||
| 33aace3686 | |||
| cbc0714c80 | |||
| 405ceacb0a | |||
| 13b12e2471 | |||
| ea0c00c4f8 | |||
| f4acd56694 | |||
| a5c721655e | |||
| 015cf290eb | |||
| cbf8fd911f | |||
| 503ccf30f4 | |||
| 1e25617ae1 | |||
| 9a4a86d317 | |||
| 1506c646a0 | |||
| 00d18d38b6 | |||
| 5044528175 | |||
| b5058c56fe | |||
| 13339abdb3 | |||
| ad254beeac | |||
| 7779ce7b46 | |||
| e70d0940a4 | |||
| dd3f73905f | |||
| b2d7b29e00 | |||
| 50c7452df3 | |||
| f62392179f | |||
| 41e66f60df | |||
| 78dc004371 | |||
| 6b8f0dc10e | |||
| 3699a2554d | |||
| 715074c2e8 | |||
| f858f3a9fc | |||
| 557ec658c9 | |||
| 6123c87483 | |||
| 0e27401e03 | |||
| 8c7311b70d | |||
| e4f86594f0 | |||
| 0adb5eeaa6 | |||
| 958189227d | |||
| 96fcd05511 | |||
| 08cc179ca8 | |||
| e356b7ac42 | |||
| 914372a517 | |||
| 8afdedf793 | |||
| 3e0d3d612a | |||
| 10e0b712ca | |||
| c1b1d8fbad | |||
| 0cbc08723d | |||
| 836ff02578 | |||
| 363fc07e74 | |||
| edcf029c6d | |||
| 02eed13913 | |||
| 5bbe45ca30 | |||
| 58c4bc5f05 | |||
| b837b8281a | |||
| 73e2f688b6 | |||
| 23333a03bd | |||
| 63031c26e0 | |||
| 1078b2d2e1 | |||
| 7a96f01a20 | |||
| bcdb51e1b8 | |||
| d3397fb17c | |||
| aa3bc6dad7 | |||
| 8f24dec23c | |||
| 4efbd61603 | |||
| 0b6b984dd3 | |||
| 071aa71a04 | |||
| 64330944e1 | |||
| 643d3a2abf | |||
| 118015062b | |||
| 1fa82447c2 | |||
| 281502ac92 | |||
| 3b662ac4c3 | |||
| 44e189c5cc | |||
| 580e4ba1fd | |||
| c8bb3e7044 | |||
| e72e526c64 | |||
| 66f5ca1a4f | |||
| b9810a88d4 | |||
| 76215765a7 | |||
| 4456d58abe | |||
| c4c49d1813 | |||
| d2a244a765 | |||
| d854bcbae9 | |||
| 4268b6f187 | |||
| 7159ee6dcb | |||
| e7ab06ee29 | |||
| 14cd888c2e | |||
| b208517e0f | |||
| adbe8c889c | |||
| e0e037c869 | |||
| f61d2834e8 | |||
| 24efee80e2 | |||
| d317900eea | |||
| da6a8b5e59 | |||
| 5d5b1d3fea | |||
| bae4f45268 | |||
| 439d3776a3 | |||
| b093c898a8 | |||
| d3d5af51f2 | |||
| 0c7a8393ab | |||
| e5d2201377 | |||
| a6941b55c4 | |||
| 087412d73a | |||
| 61a238b3fd | |||
| 07a653d97d | |||
| 24905eebc7 | |||
| f8a54942ee | |||
| efadd0c0a4 | |||
| 099be06409 | |||
| d1d20bdc04 | |||
| 83f64aa3e1 | |||
| 30c1289434 | |||
| a11a58dab0 | |||
| 7d598c7345 | |||
| ac65791663 | |||
| a209afa46b | |||
| 426a842e2b | |||
| 075088b7aa | |||
| 144b15f0ce | |||
| c2e4f6a9e1 | |||
| e3cfab3dc7 | |||
| e828af3ac1 | |||
| 42957d10f6 | |||
| 2b55a4823d | |||
| 8eebd1abce | |||
| 3f622561ce | |||
| 6f269949f1 | |||
| bdce314199 | |||
| e115c2e3fd | |||
| 4610bb4a99 | |||
| b828fd6acc | |||
| 9b1ca41c4d | |||
| 3008b56e76 | |||
| d7c3daaa6b | |||
| e39445dd55 | |||
| 652ee19f29 | |||
| 32fc008cae | |||
| 28ff9c3f79 | |||
| ab226d7137 | |||
| 8a96ebe412 | |||
| a402192e73 | |||
| f79f2e757c | |||
| ca7a5874e4 | |||
| ca07927d38 | |||
| d771c21a46 | |||
| 4aa3bc2d94 | |||
| 7bda65209c | |||
| c25f623355 | |||
| 3b37827d16 | |||
| bcfe87af7f | |||
| 526d7f4977 | |||
| 6d63f058de | |||
| e19bc09f4d | |||
| 7eab4a52e9 | |||
| 057f55c8a9 | |||
| 7b2004c649 | |||
| 6c83263d9b | |||
| f46fde3656 | |||
| 4601af88b5 | |||
| fc1ebb4967 | |||
| 07341aa89f | |||
| 4bc6d1bced | |||
| 5f282bedc5 | |||
| 3b2cd26a06 | |||
| 66e54f092d | |||
| 22994f14bf | |||
| e96f8eaf6a | |||
| 5bbdf2ff16 | |||
| 19722cb085 | |||
| 6fac9e1ef0 | |||
| 1ab39d105a | |||
| 2c1a956b32 | |||
| e35ec39c10 | |||
| 637bc8fd34 | |||
| 75157f528a | |||
| 77be3ce325 | |||
| 9634cfdb4a | |||
| 6cf006d87b | |||
| 4d25ebd070 | |||
| 0bd91f04b8 | |||
| 0bfe267501 | |||
| 4b420fb24b | |||
| 3262d058a6 | |||
| 69dcfec4eb | |||
| 31708d0942 | |||
| 53976c0c31 | |||
| 04c3ead5fa | |||
| e076901aa9 | |||
| d80f0412a8 | |||
| 9e8c0d66bb | |||
| df0227d4f2 | |||
| ae22787e60 | |||
| ab3069ae17 | |||
| 1675d2bb84 | |||
| 4ac93a0933 | |||
| ae0c4b7389 | |||
| 3d47e74ec7 | |||
| 0255207514 | |||
| 95826cb14f | |||
| ad8ce45865 | |||
| fee892f38e | |||
| dcefa13d2d | |||
| f8aa5e8072 | |||
| bb15b142bf | |||
| 28364cf212 | |||
| 295ab491a3 | |||
| debbdb86be | |||
| 58539f45c9 | |||
| 4299482b75 | |||
| 7081c3b4d1 | |||
| cb25bf6d1b | |||
| e5c17f89d7 | |||
| 9a28d08e38 | |||
| baa72e211e | |||
| 58fab5ad34 | |||
| 854f42ed6b | |||
| 1f59b5b4c3 | |||
| e74ed2e7d3 | |||
| 93ae1bd497 | |||
| b0038aab43 | |||
| 3bb0c7c6f2 | |||
| fb9a598aa9 | |||
| aed8d5b308 | |||
| 6aacdb0323 | |||
| 116bbb5e87 | |||
| 2fd6eeb95b | |||
| cdad1b5832 | |||
| 9747069182 | |||
| a97dd9d9f5 | |||
| 38ac24a0ed | |||
| 851732ce7d | |||
| ff7da29638 | |||
| 94be3b62e7 | |||
| df424f2de0 | |||
| 7670b671f2 | |||
| 4ef5c6e5b8 | |||
| af9ad48c9b | |||
| 327937124f | |||
| 3d515aa441 | |||
| 9b0e1f836d | |||
| ca15655268 | |||
| ab868bcea7 | |||
| cdcdb04d01 | |||
| 53deb8e9a8 | |||
| 38fbb222bf | |||
| f7a4f26cf0 | |||
| 59eea5d0f1 | |||
| de64da7bbc | |||
| bb9c3d1bc3 | |||
| 97512e9a48 | |||
| 8c1315b9d2 | |||
| 02226d61f6 | |||
| fd19cd222a | |||
| d8d72bb8d6 | |||
| 092f14eff0 | |||
| adfd5f63bb | |||
| 74c9e39b58 | |||
| ccd123e062 | |||
| 6877fcc70a | |||
| 3ebda4fcca | |||
| f9c1280964 | |||
| d2cbbdf600 | |||
| b717337b7b | |||
| 5b375cb822 | |||
| ee4e86ee2e | |||
| 5f8b71b528 | |||
| 1e2582b068 | |||
| ae33d02e75 | |||
| a06946e410 | |||
| 6f6bc714a9 | |||
| 54e62ecb91 | |||
| 1a3e77b0d5 | |||
| 8bc721d53b | |||
| 6d73e1b4be | |||
| f2753e6fff | |||
| 773bb3a523 | |||
| ae1c69eee0 | |||
| e76a5e5ab1 | |||
| 94efefc7bf | |||
| 8f45b40528 | |||
| ac9965220d | |||
| 1344e557e5 | |||
| 2721b9cc8f | |||
| d9414e4cba | |||
| 7aa7790931 | |||
| c3dfc9315f | |||
| cb96e85b69 |
+195
-32
@@ -2,55 +2,144 @@
|
||||
|
||||
Registry personal de codigo reutilizable con busqueda FTS. Diseñado para composicion funcional y agentes.
|
||||
|
||||
## Objetivos del registry (Norte) — Issues 0086 + 0087
|
||||
|
||||
**4 metricas optimizadas por el bucle reactivo** (visibles en Monitor tab del `registry_dashboard`):
|
||||
|
||||
1. **MAXIMIZAR `Reg %`** — porcentaje de calls del agente que golpean una funcion del registry (`function_id != ''`). Cada bash inline o heredoc que reescribe logica baja el ratio. Target: subir cada semana.
|
||||
2. **MEJORAR uso del registry por Claude** — el agente debe encontrar y usar funciones existentes antes de escribir codigo. Indicadores: `MCP` (mcp/heredoc/fn run) sube; violations baja. Si Claude no encuentra una funcion por busqueda mediocre, mejorar `description`/`tags`/`params_schema` de esa funcion.
|
||||
3. **ACELERAR tareas comunes via funciones nuevas** — patrones inline repetidos >2 veces -> `fn-constructor` crea la funcion, Claude la usa el siguiente turno. Velocidad medida en pasos (turnos) por tarea. Pattern detection: tab Monitor + `mcp__registry__fn_proposal action="list"`.
|
||||
4. **PROMOVER COMPOSICIONES A PIPELINES** (issue 0087) — el registry no crece inflando funciones, crece **promoviendo secuencias A→B(→C) que se repiten con exito** a pipelines one-shot. Hoy `bank_login + bank_make_transfer` (2 calls). Manana `bank_transfer_oneshot` (1 call). Misma capacidad, mitad de pasos. Detectado por telemetria de secuencias en `call_monitor`. Una funcion que hace bien UNA cosa NO necesita crecer — lo que crece es el catalogo de composiciones probadas.
|
||||
|
||||
**Auto-discovery zero-second-lookup:** cada `.md` debe ser autosuficiente — `## Ejemplo` lanzable + `## Cuando usarla` + `## Gotchas` (impuras). Descubrir = lanzar, sin segunda lectura. Ver `.claude/rules/function_growth_and_self_docs.md`.
|
||||
|
||||
Cualquier decision tecnica que choque con estos objetivos esta mal priorizada. Ejemplo: un bash heredoc rapido hoy que reinventa logica = penaliza objetivos 1 y 3 manana.
|
||||
|
||||
**Dos bases de datos SQLite:**
|
||||
- **registry.db** (raiz) — funciones, tipos, proposals. Regenerable con `fn index` (excepto proposals).
|
||||
- **registry.db** (raiz) — funciones, tipos, proposals, apps, projects, analysis, vaults, pc_locations. Regenerable con `fn index` (excepto proposals y pc_locations).
|
||||
- **operations.db** (por app en `apps/*/`) — entities, relations, executions, assertions. Datos vivos.
|
||||
|
||||
**Sync entre PCs:** `fn sync` sincroniza datos no regenerables (proposals, apps, projects, analysis, vaults, pc_locations) contra `registry_api` en `https://registry.organic-machine.com`. Config: `~/.fn_pc` (identidad del PC), `FN_REGISTRY_API` (URL con basicAuth), `REGISTRY_API_TOKEN` (token).
|
||||
|
||||
**Sub-repos:** cada app y cada analysis es su propio repo Gitea en `dataforge/<basename>` con branch `master` (ver ADR 0002). Los slash commands `/full-git-push` y `/full-git-pull` orquestan push/pull/clone de fn_registry + todos los sub-repos + `fn sync`. `/full-git-push` auto-inicializa apps/analyses sin `.git` via `ensure_repo_synced_bash_infra`. Los `vaults/` y `subrepos/` NO entran en este flujo.
|
||||
|
||||
**Artefactos:** termino paraguas para apps, analysis, vaults, projects y playgrounds — todo lo que NO es codigo reutilizable. Usa "artefacto" cuando una afirmacion aplica a varios tipos a la vez para no repetir la lista. Ver `.claude/rules/artefactos.md` y `.claude/rules/playgrounds.md`.
|
||||
|
||||
**Reglas y convenciones:** ver `.claude/rules/INDEX.md`
|
||||
|
||||
**Migraciones SQLite obligatorias:** todo cambio de schema en cualquier `.db` (apps, operations.db, registry.db) va en `migrations/NNN_*.sql` numerado. Aditivo, idempotente, aplicado al arrancar via `embed.FS`. Nunca borrar `.db` ni modificar migraciones existentes. Aplica retroactivamente. Ver `.claude/rules/db_migrations.md`.
|
||||
|
||||
---
|
||||
|
||||
## Delegacion + Capability Groups (REGLA DURA — issue 0086)
|
||||
|
||||
Claude **multiplica capacidades** delegando creacion de funciones a `fn-constructor` y reusandolas inmediatamente. NO escribir logica reutilizable inline.
|
||||
|
||||
### Flujo obligatorio (mismo turno)
|
||||
|
||||
1. **Detectar gap**. Si vas a escribir >=5 lineas de logica reutilizable inline -> STOP.
|
||||
2. **Spawn `fn-constructor`** via `Agent(subagent_type="fn-constructor", ...)`. Sin preguntar al usuario.
|
||||
3. **Paralelo**: si hay >1 funcion independiente -> **una sola llamada al Agent tool con N tool_use blocks paralelos** en mismo mensaje. NO serializar.
|
||||
4. **Tag de grupo obligatorio** (`notebook`, `metabase`, `deploy`, etc.). Ver `docs/capabilities/INDEX.md`.
|
||||
5. **`fn index`** + **importar + invocar en mismo turno**. No dejar funcion huerfana recien creada.
|
||||
6. **Auto-verificar**: `fn doctor uses-functions` + `fn doctor unused` si tocas >=3 funciones nuevas.
|
||||
|
||||
### Capability groups
|
||||
|
||||
Cluster de >=3 funciones que comparten dominio operativo. Cada grupo tiene tag plano + pagina madre `docs/capabilities/<grupo>.md` con: lista de funciones, ejemplo canonico end-to-end, fronteras.
|
||||
|
||||
**Antes de buscar funciones sueltas en una tarea de dominio conocido:** lee `docs/capabilities/<grupo>.md` para cargar el cluster entero en un solo read. Filtro MCP: `mcp__registry__fn_search query="" tag="<grupo>"`.
|
||||
|
||||
Reglas completas: `.claude/rules/delegation.md` + `.claude/rules/capability_groups.md`.
|
||||
|
||||
### Telemetria CAPABILITY-GROWTH
|
||||
|
||||
Cada turno el hook `UserPromptSubmit` inyecta `CAPABILITY-GROWTH: created_this_session=X used=Y orphan=Z`. Si `orphan>0` -> integra la funcion antes de cerrar turno o documenta por que.
|
||||
|
||||
---
|
||||
|
||||
## Explorar el registry (OBLIGATORIO)
|
||||
|
||||
**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.
|
||||
|
||||
```bash
|
||||
# Busqueda FTS5 por nombre Y descripcion (USAR SIEMPRE ESTE PATRON)
|
||||
sqlite3 registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:slice OR description:slice') ORDER BY name;"
|
||||
| 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` |
|
||||
|
||||
# FTS5 con prefijo (encuentra slice, slicing, sliced...)
|
||||
sqlite3 registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:slic* OR description:slic*') ORDER BY name;"
|
||||
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`.
|
||||
|
||||
# FTS5 en tipos
|
||||
sqlite3 registry.db "SELECT id, algebraic, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:result OR description:result') ORDER BY name;"
|
||||
**La BD contiene el codigo y la documentacion completa** de cada funcion y tipo en los campos `code`, `documentation` y `notes`. Tambien indexados en FTS5 — buscas dentro del codigo directamente. Para leer codigo: `mcp__registry__fn_code <id>`.
|
||||
|
||||
# FTS5 por semantica de params (composabilidad)
|
||||
sqlite3 registry.db "SELECT id, json_extract(params_schema, '$.output') FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'params_schema:retornos');"
|
||||
### Ejemplos MCP (usa estos, NO sqlite3)
|
||||
|
||||
# Por dominio
|
||||
sqlite3 registry.db "SELECT id, purity, signature FROM functions WHERE domain = 'finance' ORDER BY name;"
|
||||
Cada llamada MCP se registra en `call_monitor` (issue 0085). Cada `sqlite3 registry.db "SELECT ..."` queda fuera del bucle reactivo y dispara el hook PreToolUse.
|
||||
|
||||
# Puras de un dominio
|
||||
sqlite3 registry.db "SELECT id, signature FROM functions WHERE domain = 'core' AND purity = 'pure' ORDER BY name;"
|
||||
```
|
||||
# Busqueda basica por nombre/descripcion (FTS5 detras)
|
||||
mcp__registry__fn_search query="slice"
|
||||
|
||||
# Tipos por dominio
|
||||
sqlite3 registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'cybersecurity';"
|
||||
# Filtros: kind, purity, domain, lang
|
||||
mcp__registry__fn_search query="filter" kind="function" purity="pure" domain="core"
|
||||
|
||||
# Dependencias
|
||||
sqlite3 registry.db "SELECT id, uses_functions, uses_types FROM functions WHERE uses_functions != '[]';"
|
||||
# Prefijo FTS5 — encuentra slice/slicing/sliced
|
||||
mcp__registry__fn_search query="slic*"
|
||||
|
||||
# Proposals pendientes
|
||||
sqlite3 registry.db "SELECT id, kind, status, title FROM proposals WHERE status = 'pending';"
|
||||
# Buscar tipos
|
||||
mcp__registry__fn_search query="result" entity="types"
|
||||
|
||||
# Schema completo
|
||||
sqlite3 registry.db ".schema"
|
||||
# Apps
|
||||
mcp__registry__fn_search query="kanban" entity="apps"
|
||||
|
||||
# Listar dominios
|
||||
mcp__registry__fn_list_domains
|
||||
|
||||
# Ver una entrada concreta (functions, types, apps, analysis, proposals...)
|
||||
mcp__registry__fn_show id="filter_slice_go_core"
|
||||
|
||||
# Codigo fuente de una funcion/tipo
|
||||
mcp__registry__fn_code id="filter_slice_go_core"
|
||||
|
||||
# Quien consume una funcion (consumidores indexados via uses_functions)
|
||||
mcp__registry__fn_uses id="filter_slice_go_core"
|
||||
|
||||
# Proposals (pending, approved, ...)
|
||||
mcp__registry__fn_proposal action="list" status="pending"
|
||||
mcp__registry__fn_proposal action="show" id="<proposal_id>"
|
||||
|
||||
# Diagnostico read-only del registry (artefacts/services/sync/uses-functions/unused/cpp-apps)
|
||||
mcp__registry__fn_doctor subcommand="artefacts"
|
||||
mcp__registry__fn_doctor subcommand="sync"
|
||||
```
|
||||
|
||||
**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.
|
||||
**Escapado FTS5 (gotcha cuando pasas query libre):** valores con `-`, `.`, `:`, espacios rompen el parser FTS5 si los expones como `column:valor`. El MCP escapa por defecto, pero si construyes una `query` con sintaxis FTS5 explicita, encierra el valor en comillas dobles:
|
||||
|
||||
```
|
||||
# MAL: query="description:single-page" -> "no such column: page"
|
||||
# BIEN
|
||||
mcp__registry__fn_search query='description:"single-page" OR description:"embed.FS"'
|
||||
mcp__registry__fn_search query='description:"react router"'
|
||||
```
|
||||
|
||||
### Excepciones autorizadas para sqlite3 directo
|
||||
|
||||
`sqlite3 registry.db` SOLO es legitimo si el MCP no expone la consulta:
|
||||
|
||||
- Introspeccion de schema: `.schema`, `.tables`, `PRAGMA table_info(...)`, `PRAGMA index_list(...)`.
|
||||
- Agregaciones: `COUNT(*)`, `GROUP BY`, `SUM(...)`, `AVG(...)`.
|
||||
- JOINs custom entre tablas (ej. `functions JOIN unit_tests ON ...`) no expuestos por el MCP.
|
||||
|
||||
Cualquier `SELECT ... FROM functions/types/apps/proposals WHERE ...` plano se hace via MCP. El hook PreToolUse avisa si ve `sqlite3 registry.db "SELECT ..."`.
|
||||
|
||||
### Schema rapido
|
||||
|
||||
@@ -66,6 +155,13 @@ sqlite3 registry.db ".schema"
|
||||
- Extraidos automaticamente por `fn index` desde los archivos de test
|
||||
- FK: `function_id` → `functions.id`
|
||||
|
||||
**pc_locations** — columnas: `id, entity_type, entity_id, pc_id, dir_path, status, notes, created_at, updated_at`
|
||||
- Mapa de ubicaciones por PC: donde esta cada app/analysis/project/vault en cada maquina
|
||||
- `entity_type`: app, analysis, project, vault
|
||||
- `status`: active, missing, archived
|
||||
- Se puebla con `fn sync`, NO con `fn index`
|
||||
- Consultas: `mcp__registry__fn_doctor subcommand="sync"` (drift PC vs disco) o `sqlite3 registry.db "SELECT ... GROUP BY pc_id"` SOLO para agregaciones que el MCP no expone
|
||||
|
||||
**FTS5 (columnas buscables):**
|
||||
- `functions_fts`: id, name, description, tags, signature, domain, example, notes, documentation, code, params_schema
|
||||
- `types_fts`: id, name, description, tags, domain, examples, notes, documentation, code
|
||||
@@ -73,6 +169,43 @@ sqlite3 registry.db ".schema"
|
||||
|
||||
---
|
||||
|
||||
## Como invocar funciones del registry (CANONICO)
|
||||
|
||||
Tres patrones, uno por caso de uso. Toda invocacion del agente se loguea en `projects/fn_monitoring/apps/call_monitor/operations.db` para alimentar el bucle reactivo (issue 0085).
|
||||
|
||||
| Caso de uso | Patron canonico | Cuando usar |
|
||||
|---|---|---|
|
||||
| **Inspeccionar** registro (buscar, leer codigo, ver dependencias, dominios) | `mcp__registry__fn_search` / `fn_show` / `fn_code` / `fn_uses` / `fn_list_domains` | SIEMPRE para descubrimiento. Reemplaza `sqlite3 registry.db "SELECT ..."` inline. |
|
||||
| **Ejecutar** UNA funcion o pipeline con sus args | `mcp__registry__fn_run <id> [args]` (preferido) o `./fn run <id> [args]` (fallback CLI) | Cuando hay UN id conocido a lanzar. Despacho automatico por lenguaje. Salida estructurada. |
|
||||
| **Componer** ad-hoc varias funciones con logica intermedia | Heredoc `python/.venv/bin/python3 - <<'PYEOF' ... PYEOF` IMPORTANDO funciones del registry | Solo cuando hay loops/conditionals/dispatch entre N funciones. Las funciones del registry **se importan**, no se reescriben. |
|
||||
|
||||
Regla decisiva: antes de cada bloque de codigo, decide caso. Si dudas entre 2 y 3, casi siempre es 2 (un MCP run con args). Si el caso 3 se repite con el mismo shape >5 veces entre sesiones, **es candidato a pipeline** en `python/functions/pipelines/`.
|
||||
|
||||
### Antipatrones prohibidos
|
||||
|
||||
| Patron | Por que es malo | Sustituir por |
|
||||
|---|---|---|
|
||||
| `sqlite3 registry.db "SELECT ..."` para buscar funciones/tipos | Salta MCP, FTS5 gotchas, sin trazabilidad. Hook PreToolUse ya avisa. | `mcp__registry__fn_search` |
|
||||
| `python -c "import metabase; print(dir(metabase))"` o `help(metabase)` para descubrir helpers | La fuente de verdad es el registry, no el `__init__.py` | `mcp__registry__fn_search "metabase"` + `mcp__registry__fn_show <id>` |
|
||||
| Heredoc que reescribe logica que ya existe como funcion del registry | Reinvento + perdida de capitalizacion | Buscar primero; si falta, delegar a `fn-constructor` (no escribir inline) |
|
||||
| `client._http.request(...)` directo cuando hay wrapper en el registry | Salta validacion del wrapper y telemetria | Usar wrapper; si la firma no cubre el caso, proponer extension via `fn proposal add` |
|
||||
| Scripts en `temp/` para composiciones que se repiten | Codigo se pierde y no se monitoriza | Pipeline en `python/functions/pipelines/` o pipeline Bash en `bash/functions/pipelines/` |
|
||||
| Imports `from <pkg> import *` en heredoc | Imposible saber que funcion del registry se uso | Imports explicitos `from <domain> import <name1>, <name2>` |
|
||||
|
||||
Excepciones autorizadas para `sqlite3` directo (no requieren MCP): `.schema`, `.tables`, `PRAGMA table_info`, `COUNT(*) GROUP BY`, JOINs custom entre tablas que el MCP no expone.
|
||||
|
||||
### Trazabilidad y bucle reactivo
|
||||
|
||||
Hook `PostToolUse` en `.claude/settings.local.json` parsea cada comando Bash + cada `mcp__registry__*` y escribe en la `operations.db` del call_monitor. Datos consumidos por:
|
||||
|
||||
1. **Tab "Claude usage" en `registry_dashboard`** — top funciones, latencias, error rate, huerfanas con `calls_90d=0`.
|
||||
2. **Fase MEJORAR del bucle reactivo** — patrones inline repetidos generan proposals `new_function` con evidencia (session_ids + snippets). Funciones con error_rate alto y muchas llamadas suben en prioridad de bugfix.
|
||||
3. **Auditoria de reglas** — assertions sobre `violation_count`, `mcp_ratio`, `heredoc_repetition`. Si fallan critical → proposal "actualizar CLAUDE.md / prompt del agente".
|
||||
|
||||
Datos sensibles: solo se guarda `args_hash`, NUNCA valores concretos de argumentos.
|
||||
|
||||
---
|
||||
|
||||
## Estructura
|
||||
|
||||
```
|
||||
@@ -89,10 +222,13 @@ fn-registry/
|
||||
registry/ # Paquete Go: modelos, SQLite, parser, indexer, validacion, migraciones
|
||||
fn_operations/ # Paquete Go: operations database (libreria)
|
||||
apps/ # Apps ejecutables (TUIs, CLIs, scripts) — codigo NO reutilizable, cada una con su operations.db
|
||||
cpp/apps/ # Apps C++ standalone (sin proyecto). Ej: chart_demo, shaders_lab. Indexadas igual que apps/
|
||||
analysis/ # Exploraciones Jupyter independientes — cada una con su venv, MCP y kernel conectado al registry
|
||||
cmd/fn/ # CLI principal
|
||||
docs/ # Specs de diseño
|
||||
docs/templates/ # Plantillas de frontmatter
|
||||
temp/ # Workspace efimero — pruebas, APIs, prototipos (gitignored, no indexado)
|
||||
<artefacto>/playground/ # Prototipo rapido dentro de un artefacto padre (analysis/app/proyecto). No se indexa
|
||||
```
|
||||
|
||||
---
|
||||
@@ -118,6 +254,16 @@ fn show <id>
|
||||
fn add -k function # Template
|
||||
fn check params # Lista funciones sin params_schema
|
||||
|
||||
# Doctor: diagnostico read-only del registry y artefactos
|
||||
fn doctor # Corre todos los checks
|
||||
fn doctor artefacts # git/venv/app.md/upstream de cada app y analysis
|
||||
fn doctor services # apps tag 'service' + systemctl + puerto
|
||||
fn doctor sync # drift pc_locations BD vs disco
|
||||
fn doctor uses-functions # imports reales vs uses_functions del app.md
|
||||
fn doctor unused # funciones del registry sin consumidores
|
||||
fn doctor --json # salida JSON (cualquier subcomando)
|
||||
# Ver .claude/rules/fn_doctor.md para mapeo subcomando → funcion + acciones derivadas.
|
||||
|
||||
# Ejecutar funciones y pipelines (fn run)
|
||||
fn run <id_or_name> [args...] # Ejecuta por ID o nombre
|
||||
fn run init_metabase --project test # Go pipeline (go run .)
|
||||
@@ -141,6 +287,13 @@ fn proposal list [-k kind] [-s status]
|
||||
fn proposal show <id>
|
||||
fn proposal update <id> --status approved [--reviewed-by lucas]
|
||||
|
||||
# Sync entre PCs
|
||||
fn sync # Push+pull completo contra el servidor
|
||||
fn sync status # Estado local: PC, API, conteos
|
||||
fn sync locations # Mapa de ubicaciones en todos los PCs
|
||||
# Config: ~/.fn_pc (identidad PC), FN_REGISTRY_API (URL), REGISTRY_API_TOKEN (token)
|
||||
# URL con basicAuth: export FN_REGISTRY_API="https://user:pass@registry.organic-machine.com"
|
||||
|
||||
# Operations (desde directorio con operations.db)
|
||||
fn ops init [path]
|
||||
fn ops entity add|list|show|delete
|
||||
@@ -172,7 +325,7 @@ Entornos usados automaticamente:
|
||||
|
||||
## Añadir funciones
|
||||
|
||||
1. Consulta la BD para verificar que no existe algo similar
|
||||
1. `mcp__registry__fn_search query="<nombre|desc>"` para verificar que no existe algo similar
|
||||
2. Crea dos archivos segun el lenguaje:
|
||||
- Go: `functions/{domain}/{name}.go` + `.md`
|
||||
- Python: `python/functions/{domain}/{name}.py` + `.md`
|
||||
@@ -235,16 +388,26 @@ analysis/
|
||||
|
||||
### Crear un analisis nuevo
|
||||
|
||||
```bash
|
||||
# Basico
|
||||
fn run init_jupyter_analysis finanzas
|
||||
Un solo comando deja todo listo: carpetas, venv, paquetes, launcher, MCP, kernel startup, `analysis.md` con frontmatter y, si va en un proyecto, `fn index` final.
|
||||
|
||||
# Con paquetes extra
|
||||
```bash
|
||||
# Analisis suelto (analysis/{nombre}/)
|
||||
fn run init_jupyter_analysis finanzas
|
||||
fn run init_jupyter_analysis ml scikit-learn torch
|
||||
fn run init_jupyter_analysis duckdb polars duckdb
|
||||
|
||||
# Analisis dentro de un proyecto (projects/{proyecto}/analysis/{nombre}/)
|
||||
fn run init_jupyter_analysis --project aurgi sale_prices --desc "Comprobacion precios"
|
||||
fn run init_jupyter_analysis --project fn_monitoring coverage polars --tags "monitoring,coverage"
|
||||
```
|
||||
|
||||
El pipeline `init_jupyter_analysis_bash_pipelines` compone 8 funciones atomicas del registry.
|
||||
Flags del pipeline:
|
||||
- `--project <nombre>` — crea el analisis dentro de `projects/{nombre}/analysis/` y ejecuta `fn index` al final. El proyecto debe existir (`projects/{nombre}/project.md`).
|
||||
- `--desc "..."` — descripcion que se escribe en el frontmatter de `analysis.md`.
|
||||
- `--tags "a,b,c"` — tags CSV que se escriben en el frontmatter.
|
||||
|
||||
**NUNCA** uses `mv` para mover un analisis de `analysis/` a `projects/{proyecto}/analysis/` despues de crearlo. Al mover, el `.venv/bin/activate` queda con el path antiguo hardcodeado y el launcher falla con `ERROR: jupyter-collaboration no esta instalado`. Si esto pasa: `rm -rf .venv && uv sync` dentro del directorio nuevo. La forma correcta es siempre crear con `--project` desde el inicio.
|
||||
|
||||
El pipeline `init_jupyter_analysis_bash_pipelines` (v1.1.0) compone 9 funciones atomicas del registry.
|
||||
|
||||
### Usar un analisis
|
||||
|
||||
|
||||
@@ -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 <app_id>` 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 = '<app_id>';"
|
||||
|
||||
# Por dir_path
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, dir_path FROM apps WHERE dir_path = '<dir>';"
|
||||
```
|
||||
|
||||
Si no hay match → reportar y abortar.
|
||||
|
||||
### 2. Leer `e2e_checks` del `app.md`
|
||||
|
||||
```bash
|
||||
# Extraer YAML del frontmatter
|
||||
sed -n '/^---$/,/^---$/p' "<dir_path>/app.md" | head -n -1 | tail -n +2
|
||||
```
|
||||
|
||||
Parsear `e2e_checks:`. Si esta vacio o no existe:
|
||||
|
||||
```
|
||||
=== fn-analizador: <app_id> ===
|
||||
SIN CONTRATO
|
||||
|
||||
app.md no declara e2e_checks. fn-analizador no puede validar.
|
||||
Sugerencia: invocar fn-recopilador con `design-e2e <app_id>` para
|
||||
generar bloque e2e_checks_suggested.
|
||||
```
|
||||
|
||||
Y abortar.
|
||||
|
||||
### 3. Preparar `operations.db` de la app
|
||||
|
||||
```bash
|
||||
APP_DIR="<dir_path>"
|
||||
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/<app>_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_<id>.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_<id>.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 `<app_dir>/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: <app_id> ===
|
||||
run_id: <RUN_ID>
|
||||
status: <pass|fail|partial>
|
||||
checks: <PASS>/<TOTAL> pass, <WARN> warn, <FAIL> 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 <app_id> --run-id <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 <app_dir>` |
|
||||
| `port already in use` | Run anterior no limpio | `pkill -f <app_name>` 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: <app_id> ===`
|
||||
2. Linea `run_id: <id>`
|
||||
3. Linea `status: <pass|partial|fail>`
|
||||
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 <app_id> --run-id <RUN_ID>` SI hay fails (orienta al humano/main thread).
|
||||
|
||||
Si setup fallo (no se pudo correr nada), output:
|
||||
|
||||
```
|
||||
=== fn-analizador: <app_id> ===
|
||||
SETUP FAIL
|
||||
<razon>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 <app_id>` orquesta esta cadena en una sola invocacion.
|
||||
@@ -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="<input>"
|
||||
RUN_ID="<input>"
|
||||
|
||||
# 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: <app_id>::<check_id>',
|
||||
'<descripcion con stderr/stdout truncado + sugerencia>',
|
||||
json('{"run_id":"<run_id>","check_id":"<id>","exit_code":<n>,"severity":"<s>","stderr_excerpt":"..."}'),
|
||||
'<app_id>',
|
||||
'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: <id>. 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 <name> regresa fail. Diferencia esperada vs actual en stderr. Posible cambio de comportamiento en <funcion sospechosa>." |
|
||||
| `assertion` drift de metricas | "Drift de p50 +X% sobre baseline. Posible regresion de performance en <pipeline_id>." |
|
||||
| `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: <app_id> ===
|
||||
run_id: <RUN_ID>
|
||||
fails procesados: N (M critical, K warning)
|
||||
|
||||
proposals creadas:
|
||||
prop_a1b2c3d4 — e2e fail: <app>::tests_go (improve_function)
|
||||
prop_e5f6g7h8 — e2e fail: <app>::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 <app_id>
|
||||
fn proposal show <prop_id>
|
||||
fn proposal update <prop_id> --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 <app_dir>` |
|
||||
| 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":"<app>","run_id":"<run>","check_id":"tests_go"},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
Util para `fn-orquestador` (issue 0069) que necesita parsear los IDs para decidir auto-apply.
|
||||
@@ -0,0 +1,390 @@
|
||||
---
|
||||
name: fn-orquestador
|
||||
description: "Meta-orquestador (Fase 6) del ciclo reactivo. Toma un issue o task_spec y recorre CONSTRUIR → EJECUTAR → RECOPILAR → ANALIZAR → MEJORAR despachando a fn-constructor/executor/recopilador/analizador/mejorador hasta convergencia, estancamiento, timeout o tope de iteraciones. Trabaja SIEMPRE en rama sandbox `auto/<issue>`, NUNCA mergea a master, persiste progreso en `task_runs`. Issue 0069."
|
||||
model: sonnet
|
||||
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||
---
|
||||
|
||||
# Agente Orquestador — Fase 6 (meta) del Ciclo Reactivo
|
||||
|
||||
Cierras la promesa autonoma del registry: "lanzar tarea, irse, volver con resultado". Tu rol es **recorrer las 5 fases del bucle reactivo solo**, despachando a los subagentes especializados, hasta que la tarea converja o se decida parar.
|
||||
|
||||
NO escribes codigo de aplicacion directamente. NO mergeas a master. NO bypaseas hooks. Solo orquestas.
|
||||
|
||||
Referencia completa: `dev/issues/0069-autonomous-agent-loop-self-iterating-tasks.md`.
|
||||
|
||||
---
|
||||
|
||||
## REGLAS FUNDAMENTALES (no negociables)
|
||||
|
||||
1. **Sandbox de rama EN WORKTREE**. Trabajas SIEMPRE en `auto/<issue_id>` dentro de un `git worktree` aislado (default `/tmp/fn_orq_<issue>_<ts>/`). NUNCA en master ni en el working tree principal del repo. Esto permite N orquestadores paralelos y deja intacto el working tree del humano.
|
||||
2. **No merge automatico**. Al converger, abres PR draft. Humano aprueba.
|
||||
3. **No `--no-verify`, no `git push --force`, no skip de hooks**. Nunca.
|
||||
4. **Paths protegidos**. NO tocar:
|
||||
- `.claude/` (excepto el subdir del task si aplica explicitamente)
|
||||
- `dev/issues/` (excepto el issue del task)
|
||||
- Cualquier archivo `.env*`, `*.key`, `*.pem`, credenciales
|
||||
- `migrations/` ya existentes (solo crear nuevas, nunca editar)
|
||||
- Lista canonica: `dev/autonomous_protected_paths.json` (si no existe, usar la default de arriba)
|
||||
5. **Watchdog de progreso**. 2 iteraciones consecutivas con el MISMO set de fails → parar con `status=stalled`.
|
||||
6. **Auditoria total**. Cada decision se loggea en `task_runs.progress_json` con razonamiento + fase + run_id.
|
||||
7. **No self-modify**. NO modificas tu propio SKILL.md ni el de otros subagentes en la misma run.
|
||||
8. **Cero produccion**. NO deploys, NO llamadas a APIs externas con auth, NO tocar BDs productivas.
|
||||
|
||||
---
|
||||
|
||||
## Pre-condiciones obligatorias
|
||||
|
||||
Antes de arrancar el bucle, comprobar:
|
||||
|
||||
```bash
|
||||
# 1. Migration 006_task_runs.sql existe
|
||||
ls /home/lucas/fn_registry/fn_operations/migrations/006_task_runs.sql 2>/dev/null \
|
||||
|| { echo "ABORT: migration 006_task_runs.sql ausente. Aplicar issue 0069 paso 1 antes."; exit 2; }
|
||||
|
||||
# 2. Subagentes fn-* presentes
|
||||
for a in fn-constructor fn-executor fn-recopilador fn-analizador fn-mejorador; do
|
||||
test -f /home/lucas/fn_registry/.claude/agents/$a/SKILL.md \
|
||||
|| { echo "ABORT: subagente $a ausente"; exit 2; }
|
||||
done
|
||||
|
||||
# 3. master local up-to-date con origin (worktree se creara desde master)
|
||||
git -C /home/lucas/fn_registry fetch origin master --quiet
|
||||
LOCAL=$(git -C /home/lucas/fn_registry rev-parse master)
|
||||
REMOTE=$(git -C /home/lucas/fn_registry rev-parse origin/master)
|
||||
test "$LOCAL" = "$REMOTE" \
|
||||
|| { echo "ABORT: master local desincronizado con origin. git pull antes."; exit 2; }
|
||||
|
||||
# 4. Branch auto/<issue> NO existe ya (ni local ni en worktrees)
|
||||
git -C /home/lucas/fn_registry rev-parse --verify "auto/${ISSUE_ID}" >/dev/null 2>&1 \
|
||||
&& { echo "ABORT: branch auto/${ISSUE_ID} ya existe. Limpiar antes (git branch -D + worktree remove)."; exit 2; }
|
||||
|
||||
# 5. gh CLI autenticado (necesario para PR draft al converger)
|
||||
gh auth status >/dev/null 2>&1 \
|
||||
|| { echo "ABORT: gh no autenticado, no podra crear PR draft."; exit 2; }
|
||||
```
|
||||
|
||||
**No se exige working tree principal limpio**: el orquestador trabaja en worktree separado.
|
||||
|
||||
Si alguna falla → reportar al main thread y salir. NO intentar continuar.
|
||||
|
||||
---
|
||||
|
||||
## Input
|
||||
|
||||
Recibes:
|
||||
- `issue_id` (ej. `0070`) o `task_spec` inline (objetivo, criterios aceptacion).
|
||||
- Opcional: `max_iterations` (default 10), `max_minutes` (default 60), `auto_apply_proposals` (`none|safe|aggressive`, default `safe`), `branch` (default `auto/<issue_id>`), `dry_run` (default false).
|
||||
|
||||
Task spec mininmo (cuando no hay issue_id):
|
||||
```yaml
|
||||
task_id: "<slug>"
|
||||
type: "feature_app_simple|bugfix_with_repro|refactor_safe|add_e2e_check"
|
||||
target_app: "<app_id>"
|
||||
acceptance:
|
||||
- check: "<verificable programaticamente>"
|
||||
- check: "..."
|
||||
```
|
||||
|
||||
**Tipos soportados** (issue 0069 §"Tipos de tareas soportadas"):
|
||||
- `feature_app_simple` — endpoint nuevo + handler + test
|
||||
- `bugfix_with_repro` — repro reproducible que pasa de fail a pass
|
||||
- `refactor_safe` — rename/extract con suite igual de verde
|
||||
- `add_e2e_check` — añadir `e2e_checks` a app sin contrato (delega a `fn-recopilador design-e2e`)
|
||||
|
||||
**NO soportados**: diseño arquitectura, decisiones UX, cambios BD productiva, secrets.
|
||||
|
||||
---
|
||||
|
||||
## Algoritmo
|
||||
|
||||
### 0. Setup — worktree aislado
|
||||
|
||||
```bash
|
||||
ISSUE_ID="<input>"
|
||||
BRANCH="auto/${ISSUE_ID}"
|
||||
TASK_RUN_ID="task_$(openssl rand -hex 8)"
|
||||
STARTED_AT=$(date +%s)
|
||||
WT_ROOT="/tmp/fn_orq_${ISSUE_ID}_${STARTED_AT}"
|
||||
REPO="/home/lucas/fn_registry"
|
||||
|
||||
# Crear worktree aislado desde master (no toca el principal)
|
||||
git -C "$REPO" worktree add -b "$BRANCH" "$WT_ROOT" master \
|
||||
|| { echo "ABORT: worktree add fallo"; exit 2; }
|
||||
|
||||
# A partir de aqui TODO se hace en $WT_ROOT (cd o git -C)
|
||||
cd "$WT_ROOT"
|
||||
|
||||
# operations.db del app target. Si task no tiene app target, usar el del repo principal:
|
||||
APP_DB="$WT_ROOT/<app_dir>/operations.db"
|
||||
[ -f "$APP_DB" ] || APP_DB="$REPO/operations.db"
|
||||
|
||||
# Persistir task_run inicial (la BD VIVE EN EL REPO PRINCIPAL para que el humano pueda
|
||||
# consultarla mientras la run corre — el worktree es desechable)
|
||||
sqlite3 "$APP_DB" "INSERT INTO task_runs (id, task_id, started_at, status, iterations, last_phase, progress_json)
|
||||
VALUES ('$TASK_RUN_ID', '$ISSUE_ID', $STARTED_AT, 'running', 0, NULL, '[]');"
|
||||
```
|
||||
|
||||
**Convencion clave**: worktree es **desechable** (codigo, build artifacts), `task_runs` vive en BD persistente del repo principal (auditoria sobrevive aunque borres worktree).
|
||||
|
||||
### 1. Loop principal
|
||||
|
||||
```
|
||||
iter = 0
|
||||
phase = CONSTRUIR
|
||||
last_fails = null
|
||||
while iter < max_iterations and elapsed < max_minutes:
|
||||
iter++
|
||||
|
||||
# 1.1 Determinar siguiente fase pendiente
|
||||
phase = next_phase(task_state, last_phase)
|
||||
|
||||
# 1.2 Despachar subagente
|
||||
output = invoke(phase, prompt_from(task_spec, last_outputs))
|
||||
|
||||
# 1.3 Persistir progreso
|
||||
append_progress(task_run, {iter, phase, output_summary, run_id?})
|
||||
|
||||
# 1.4 Logica por fase
|
||||
if phase == ANALIZAR:
|
||||
if output.status == "pass":
|
||||
if all_acceptance_met(task_spec):
|
||||
converge()
|
||||
break
|
||||
else:
|
||||
phase = CONSTRUIR # siguiente criterio
|
||||
else: # fail
|
||||
current_fails = extract_fails(output)
|
||||
if current_fails == last_fails:
|
||||
stall()
|
||||
break
|
||||
last_fails = current_fails
|
||||
phase = MEJORAR
|
||||
|
||||
if phase == MEJORAR:
|
||||
proposals = output.proposals
|
||||
applied = filter_and_apply(proposals, auto_apply_level)
|
||||
log_applied(applied)
|
||||
phase = CONSTRUIR # re-validar tras patches
|
||||
|
||||
# 1.5 Watchdog needs_human
|
||||
if requires_human_decision(output):
|
||||
needs_human()
|
||||
break
|
||||
```
|
||||
|
||||
### 2. Despacho a subagentes
|
||||
|
||||
Usar `Agent` tool con `subagent_type` correcto. Prompt **autocontenido** (paths absolutos, IDs, criterio exito).
|
||||
|
||||
**CRITICO**: pasar `WT_ROOT` (worktree path) en cada prompt y exigir al subagente trabajar dentro de el. Subagentes NO deben tocar el repo principal `/home/lucas/fn_registry/`.
|
||||
|
||||
Patron prompt:
|
||||
```
|
||||
Working dir: <WT_ROOT> # NO /home/lucas/fn_registry
|
||||
Branch: auto/<issue_id>
|
||||
Repo principal (solo lectura para registry.db): /home/lucas/fn_registry
|
||||
...
|
||||
```
|
||||
|
||||
| Fase | subagent_type | Prompt minimo |
|
||||
|---|---|---|
|
||||
| CONSTRUIR | `fn-constructor` | "Construir <funcion/tipo> en <lang>/<domain>. Firma: <X>. Pureza: <pure/impure>. Tests obligatorios. Issue: <id>." |
|
||||
| EJECUTAR | `fn-executor` | "Ejecutar <pipeline_id> con args <X> en <app_dir>. Registrar en operations.db." |
|
||||
| RECOPILAR | `fn-recopilador` | "Auditar operations.db de <app_dir>. Reportar drift en JSON." |
|
||||
| ANALIZAR | `fn-analizador` | "Validar <app_id>. Correr e2e_checks. Devolver run_id + status pass/fail + summary." |
|
||||
| MEJORAR | `fn-mejorador` | "Procesar fallos de run_id=<X> en <app_id>. Crear proposals. Output --json." |
|
||||
|
||||
### 3. Filtro de proposals auto-aplicables
|
||||
|
||||
`auto_apply_level=safe` (default) acepta proposal SOLO si:
|
||||
- `created_by = 'reactive_loop'` (vino de fn-mejorador)
|
||||
- `evidence.run_id` apunta a run real existente
|
||||
- `kind = 'improve_function'`
|
||||
- Diff propuesto < 50 lineas (estimar via patch en `evidence.suggested_diff` si existe; si no existe, NO auto-apply)
|
||||
- NO toca tests existentes (no se "arreglan" tests para que pasen)
|
||||
- NO añade dependencias nuevas (`go get`, `pnpm add`, `uv add`)
|
||||
- NO toca paths protegidos
|
||||
|
||||
`auto_apply_level=none` → solo crea proposals, nunca aplica.
|
||||
`auto_apply_level=aggressive` → todas salvo `risk=high` o paths protegidos.
|
||||
|
||||
Aplicacion: delegar a `fn-constructor` con prompt "Aplicar proposal <id>. Diff sugerido: <X>. Verificar build despues."
|
||||
|
||||
### 4. Convergencia
|
||||
|
||||
Condiciones de parada:
|
||||
|
||||
| Condicion | status final |
|
||||
|---|---|
|
||||
| Todos `acceptance` ✓ + e2e pass + `fn doctor` pass | `converged` |
|
||||
| Mismo set de fails 2 iter consecutivas | `stalled` |
|
||||
| `elapsed >= max_minutes` | `timeout` |
|
||||
| `iter >= max_iterations` | `iterations_exhausted` |
|
||||
| Output detecta decision humana (libreria nueva, schema breaking) | `needs_human` |
|
||||
| Pre-condicion fallo / git error / paths protegidos vulnerados | `aborted` |
|
||||
|
||||
### 5. PR draft (solo si `converged`)
|
||||
|
||||
```bash
|
||||
git -C "$WT_ROOT" push -u origin "$BRANCH"
|
||||
gh -R <owner>/<repo> pr create --draft \
|
||||
--title "auto: <issue_title>" \
|
||||
--body "<resumen + run_ids + proposals + task_run_id>" \
|
||||
--base master --head "$BRANCH"
|
||||
```
|
||||
|
||||
NO mergear. Devolver URL al main thread.
|
||||
|
||||
### 5.b Cleanup del worktree
|
||||
|
||||
Solo borrar worktree si:
|
||||
- `status=converged` Y PR creado correctamente, O
|
||||
- `status=aborted|stalled|timeout|iterations_exhausted` Y el humano NO pidio inspeccion.
|
||||
|
||||
```bash
|
||||
# Default: NO borrar. Reportar comando para que humano decida.
|
||||
echo "Worktree disponible en $WT_ROOT para inspeccion."
|
||||
echo "Cuando termines: git -C $REPO worktree remove $WT_ROOT && git -C $REPO branch -D $BRANCH"
|
||||
```
|
||||
|
||||
**Regla**: orquestador NUNCA borra worktree automaticamente si hubo fallo. Worktree = evidencia forense. Solo auto-cleanup en `converged` con PR creado.
|
||||
|
||||
```bash
|
||||
# Auto-cleanup post-converge:
|
||||
if [ "$STATUS" = "converged" ] && [ -n "$PR_URL" ]; then
|
||||
git -C "$REPO" worktree remove "$WT_ROOT"
|
||||
# branch sigue en remoto via PR; local se borrara cuando humano cierre PR
|
||||
fi
|
||||
```
|
||||
|
||||
### 6. Reportar
|
||||
|
||||
Output caveman canonico:
|
||||
|
||||
```
|
||||
=== fn-orquestador: <issue_id> ===
|
||||
status: converged|stalled|timeout|iterations_exhausted|needs_human|aborted
|
||||
iterations: N / <max>
|
||||
duration: M min / <max>
|
||||
branch: auto/<issue_id>
|
||||
PR draft: <url o "no creado">
|
||||
proposals: <created> creadas, <applied> auto-aplicadas
|
||||
last run_id: <run_id> (status: pass|fail)
|
||||
|
||||
Iteraciones:
|
||||
1. construir → ok (3 funciones nuevas: id_a, id_b, id_c)
|
||||
2. ejecutar → ok (run_id=exec_xxx)
|
||||
3. analizar → fail (3/8 checks: build, smoke, tests)
|
||||
4. mejorar → 3 proposals (2 safe-applied, 1 needs human)
|
||||
5. construir → ok (re-build tras patches)
|
||||
6. analizar → pass (8/8)
|
||||
7. recopilar → ok (operations.db integra)
|
||||
8. CONVERGED
|
||||
|
||||
Siguientes pasos humano:
|
||||
- Revisar PR <url>
|
||||
- fn proposal list -s pending --target-id <id>
|
||||
- Si no aceptas, git branch -D auto/<issue_id>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Persistencia: tabla `task_runs`
|
||||
|
||||
Schema (de issue 0069 §"Nueva tabla task_runs"):
|
||||
|
||||
```sql
|
||||
CREATE TABLE task_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
started_at INTEGER NOT NULL,
|
||||
finished_at INTEGER,
|
||||
status TEXT NOT NULL, -- running|converged|stalled|timeout|iterations_exhausted|needs_human|aborted
|
||||
iterations INTEGER NOT NULL DEFAULT 0,
|
||||
last_phase TEXT,
|
||||
last_run_id TEXT,
|
||||
progress_json TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
```
|
||||
|
||||
Vive en `operations.db` del app target (NO en registry.db). Si el task no tiene app target (refactor cross-cutting), usar `<repo_root>/operations.db` (excepcion documentada).
|
||||
|
||||
Cada `progress_json` entry:
|
||||
```json
|
||||
{"iter": N, "phase": "construir", "ts": <epoch>, "subagent": "fn-constructor",
|
||||
"input_summary": "...", "output_summary": "...", "run_id": "..." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reglas de comportamiento
|
||||
|
||||
1. **Briefing autocontenido** a cada subagente. Nunca asumir contexto compartido.
|
||||
2. **Verificar output**: leer diff/run_id real, no fiarse del resumen del subagente.
|
||||
3. **No paralelo dentro de una iteracion** (las fases son secuenciales). PARALELO OK entre tareas distintas: cada `fn-orquestador` corre en SU worktree `/tmp/fn_orq_<issue>_<ts>/`, sin pisarse. N orquestadores simultaneos = N worktrees + N branches `auto/<X>`, `auto/<Y>`.
|
||||
4. **Caveman en stdout** del orquestador. Telemetry estructurada en `task_runs`.
|
||||
5. **Stop > recovery**. Ante duda, abortar con `status=needs_human`, NO improvisar fixes.
|
||||
6. **No tocar `.git` directamente** salvo `checkout`, `add`, `commit`, `push`. Nada de `reset --hard`, `rebase -i`, `branch -D`.
|
||||
7. **Commits atomicos** por fase: `chore(auto): <fase> iter N — <descripcion corta>`. Co-authored por agente que ejecuto.
|
||||
|
||||
---
|
||||
|
||||
## Errores comunes
|
||||
|
||||
| Sintoma | Causa | Accion |
|
||||
|---|---|---|
|
||||
| `task_runs` no existe | migration 006 no aplicada | abortar pre-condicion 1 |
|
||||
| `worktree add` falla con "already exists" | branch o dir previo no limpiado | `git worktree prune` + `git branch -D auto/<id>`, reintentar |
|
||||
| Subagente toca `/home/lucas/fn_registry/` en vez de worktree | prompt sin `WT_ROOT` explicito | rebriefing con working dir explicito |
|
||||
| `master` desincronizado con origin | falta `git pull` | abortar pre-condicion 3 |
|
||||
| Loop infinito (mismo fail siempre) | watchdog ausente o desactivado | watchdog OBLIGATORIO, no skipear |
|
||||
| Subagente devuelve output ambiguo | prompt insuficiente | rebriefing con paths/IDs explicitos |
|
||||
| PR draft falla creacion | `gh` no autenticado o branch sin push | reportar `needs_human`, NO retry agresivo |
|
||||
| Disk full / sqlite locked | concurrencia con otra task | abortar, NO forzar |
|
||||
|
||||
---
|
||||
|
||||
## Composicion con otras fases
|
||||
|
||||
- **Pre-orquestador**: humano define `dev/issues/<NNNN>.md` con criterios verificables programaticamente. Sin issue verificable, NO arrancar.
|
||||
- **Durante**: orquestador despacha a las 5 fases. Cada subagente respeta SUS reglas (purity, registry-first, etc.).
|
||||
- **Post-orquestador**: humano revisa PR draft + proposals. Acepta, modifica o descarta.
|
||||
- **NO orquestes a otro `fn-orquestador`**. Una run no spawn-ea otra. Recursion = abort.
|
||||
|
||||
---
|
||||
|
||||
## Salida JSON opcional
|
||||
|
||||
Si `--json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_run_id": "task_a1b2c3d4",
|
||||
"issue_id": "0070",
|
||||
"status": "converged",
|
||||
"iterations": 8,
|
||||
"duration_s": 1240,
|
||||
"branch": "auto/0070",
|
||||
"pr_url": "https://gitea.../pulls/42",
|
||||
"proposals_created": 3,
|
||||
"proposals_applied": 2,
|
||||
"last_run_id": "run_xxx",
|
||||
"phases": [
|
||||
{"iter": 1, "phase": "construir", "status": "ok", "ts": 1234},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Util para integraciones (CI, dashboard, otra automatizacion). NO para spawn-ear otro orquestador.
|
||||
|
||||
---
|
||||
|
||||
## Limites duros
|
||||
|
||||
- `max_iterations`: 10 default, ceiling 30.
|
||||
- `max_minutes`: 60 default, ceiling 240.
|
||||
- Diff total por iteracion: 500 lineas. Si excede → `needs_human`.
|
||||
- Proposals auto-aplicadas por run: 5. Si excede → resto a `pending`.
|
||||
- Recursividad: 0. NO spawn de otro orquestador.
|
||||
@@ -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 <app_id>`: 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 <app_id>` — 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/<app>` o `design-e2e projects/<p>/apps/<a>`.
|
||||
|
||||
### 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:<dir_path>`.
|
||||
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/<app>_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 <name> ."
|
||||
timeout_s: 120
|
||||
- id: migrations
|
||||
cmd: "rm -f /tmp/<name>_e2e.db && ./<name> --port 0 --db /tmp/<name>_e2e.db --migrate-only"
|
||||
timeout_s: 15
|
||||
- id: smoke
|
||||
cmd: "./<name> --port <PORT> --db /tmp/<name>_e2e.db &"
|
||||
health: "http://127.0.0.1:<PORT>/api/board"
|
||||
timeout_s: 10
|
||||
- id: tests
|
||||
cmd: "go test -tags fts5 -count=1 ./..."
|
||||
timeout_s: 120
|
||||
- id: ops_audit
|
||||
ref: "fn-recopilador:<dir_path>"
|
||||
```
|
||||
|
||||
#### C++ ImGui app
|
||||
|
||||
```yaml
|
||||
e2e_checks_suggested:
|
||||
- id: build
|
||||
cmd: "cmake --build build --target <name> -j"
|
||||
timeout_s: 300
|
||||
- id: self_test
|
||||
cmd: "./build/<name> --self-test"
|
||||
timeout_s: 30
|
||||
- id: pytest
|
||||
cmd: "cd tests && python3 -m pytest -x -q"
|
||||
timeout_s: 180
|
||||
- id: ops_audit
|
||||
ref: "fn-recopilador:<dir_path>"
|
||||
```
|
||||
|
||||
#### Python pipeline / CLI
|
||||
|
||||
```yaml
|
||||
e2e_checks_suggested:
|
||||
- id: import
|
||||
cmd: "python3 -c 'import <module>'"
|
||||
- id: cli_help
|
||||
cmd: "python3 -m <module> --help"
|
||||
expect_stdout_contains: "usage:"
|
||||
- id: smoke
|
||||
cmd: "python3 -m <module> --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 <name> ."
|
||||
- id: smoke
|
||||
cmd: "./<name> --port <PORT> &"
|
||||
health: "http://127.0.0.1:<PORT>/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)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# /autonomous-task — Lanza fn-orquestador (Fase 6 del ciclo reactivo)
|
||||
|
||||
Lanza el meta-orquestador autonomo que recorre el bucle CONSTRUIR → EJECUTAR → RECOPILAR → ANALIZAR → MEJORAR sobre un issue, sin intervencion humana, hasta convergencia / estancamiento / timeout / limite de iteraciones.
|
||||
|
||||
Issue 0069. Pre-condiciones obligatorias (chequear ANTES de despachar):
|
||||
|
||||
1. Migration `fn_operations/migrations/006_task_runs.sql` aplicada.
|
||||
2. Subagentes `fn-constructor`, `fn-executor`, `fn-recopilador`, `fn-analizador`, `fn-mejorador`, `fn-orquestador` presentes en `.claude/agents/`.
|
||||
3. `dev/autonomous_protected_paths.json` existe.
|
||||
4. `master` local up-to-date con `origin/master`.
|
||||
5. Branch `auto/<issue_id>` NO existe ya.
|
||||
6. `gh auth status` OK (necesario para PR draft al converger).
|
||||
7. Tipo de tarea soportado: `feature_app_simple`, `bugfix_with_repro`, `refactor_safe`, `add_e2e_check`.
|
||||
|
||||
Si alguna pre-condicion falla → ABORT con razon. NO improvisar.
|
||||
|
||||
---
|
||||
|
||||
## Argumento
|
||||
|
||||
`$ARGUMENTS` — `<issue_id>` o `<task_spec_path>` + flags opcionales.
|
||||
|
||||
```
|
||||
/autonomous-task 0070
|
||||
/autonomous-task 0070 --max-iterations 15 --max-minutes 90
|
||||
/autonomous-task 0070 --auto-apply-proposals safe
|
||||
/autonomous-task 0070 --dry-run
|
||||
/autonomous-task path/to/spec.yaml --branch auto/custom-name
|
||||
```
|
||||
|
||||
Flags:
|
||||
- `--max-iterations N` tope de iteraciones (default 10)
|
||||
- `--max-minutes M` timeout total (default 60)
|
||||
- `--auto-apply-proposals` `none|safe|aggressive` (default `safe`)
|
||||
- `--branch NAME` rama TBD (default `auto/<issue_id>`)
|
||||
- `--dry-run` simula, NO aplica
|
||||
|
||||
---
|
||||
|
||||
## Comportamiento
|
||||
|
||||
1. **Verificar pre-condiciones** con script bash (ver arriba). Si alguna falla, reportar y salir.
|
||||
2. **Despachar a `fn-orquestador`** via Agent tool con `subagent_type=fn-orquestador`. Pasar:
|
||||
- `issue_id` o `task_spec`
|
||||
- flags resueltos
|
||||
- paths protegidos (leidos de `dev/autonomous_protected_paths.json`)
|
||||
3. **El subagente:**
|
||||
- Crea worktree aislado `/tmp/fn_orq_<issue>_<ts>/` desde `master`.
|
||||
- Persiste estado en `task_runs` (operations.db del app target o repo root).
|
||||
- Despacha por fases a los 5 subagentes especializados.
|
||||
- Aplica proposals filtradas por `--auto-apply-proposals`.
|
||||
- Termina con: `converged` (PR draft creado) | `stalled` | `timeout` | `iterations_exhausted` | `needs_human` | `aborted`.
|
||||
4. **Reportar resultado al humano** con:
|
||||
- `status`, `iterations / max`, `duration / max`
|
||||
- `branch`, `worktree`, `PR draft url` si converged
|
||||
- `proposals creadas / aplicadas`
|
||||
- `last run_id` y status
|
||||
- Resumen iter-por-iter del `progress_json`
|
||||
|
||||
---
|
||||
|
||||
## Reglas duras (no negociables)
|
||||
|
||||
- Sandbox de rama EN WORKTREE — nunca toca master ni el working tree del humano.
|
||||
- No merge automatico — PR draft siempre.
|
||||
- No `--no-verify`, no `--force`, no skip hooks.
|
||||
- Paths protegidos via `dev/autonomous_protected_paths.json`.
|
||||
- Watchdog: 2 iteraciones con mismo set de fails → `status=stalled`.
|
||||
- Auditoria total en `task_runs.progress_json`.
|
||||
- No self-modification: NO toca `.claude/agents/` ni `.claude/commands/`.
|
||||
|
||||
---
|
||||
|
||||
## Integracion con call_monitor (issue 0085)
|
||||
|
||||
El orquestador puede leer `projects/fn_monitoring/apps/call_monitor/operations.db` para:
|
||||
|
||||
- Consultar `function_stats` antes de decidir que funciones usar/reusar.
|
||||
- Filtrar proposals existentes via `mcp__registry__fn_proposal --status pending` para evitar duplicados.
|
||||
- Loggear sus invocaciones via el hook PostToolUse (automatico).
|
||||
|
||||
Tras converger, el `call_monitor propose` ejecutado por el humano (o futuro cron) absorbera las nuevas violations / copied_code / fails para alimentar la siguiente ronda.
|
||||
|
||||
---
|
||||
|
||||
## Tipos NO soportados
|
||||
|
||||
- Diseño arquitectura nuevo (humano decide).
|
||||
- Decisiones UX subjetivas.
|
||||
- Cambios BD productiva.
|
||||
- Cualquier cosa que toque secrets/credenciales.
|
||||
- Self-modification del propio orquestador.
|
||||
|
||||
Si el issue contiene criterios no-verificables programaticamente, ABORT con `status=needs_human`.
|
||||
|
||||
---
|
||||
|
||||
## Output canonico
|
||||
|
||||
```
|
||||
=== /autonomous-task: 0070 ===
|
||||
status: converged
|
||||
iterations: 7 / 10
|
||||
duration: 23 min / 60
|
||||
branch: auto/0070
|
||||
worktree: /tmp/fn_orq_0070_1731612345
|
||||
PR draft: https://github.com/.../pull/123
|
||||
proposals: 3 creadas, 2 auto-aplicadas
|
||||
last run_id: e2e_run_abc123 (status: pass)
|
||||
|
||||
Iter:
|
||||
1. construir → ok (2 funciones nuevas)
|
||||
2. ejecutar → ok
|
||||
3. analizar → fail (2/8 checks)
|
||||
4. mejorar → 3 proposals (2 auto-applicadas)
|
||||
5. construir → ok (re-build tras patches)
|
||||
6. analizar → pass
|
||||
7. recopilador → ok (operations.db integra)
|
||||
|
||||
Siguiente: revisar PR draft + fn proposal list -s pending --target-id 0070
|
||||
```
|
||||
@@ -0,0 +1,37 @@
|
||||
# /compile — Compila app C++ y la copia al escritorio de Windows
|
||||
|
||||
Wrapper sobre el pipeline `compile_cpp_app_bash_pipelines`. Toda la lógica vive en el registry (resolver app desde CWD/arg, cross-compile MinGW, copiar exe + DLLs + assets/ + enrichers/ + runtime/ a `/mnt/c/Users/lucas/Desktop/apps/<app>/`, taskkill previo, preservar `local_files/`).
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
./fn run compile_cpp_app "$ARGUMENTS"
|
||||
```
|
||||
|
||||
## Argumento
|
||||
|
||||
`$ARGUMENTS` — opcional. Nombre de app (ej: `chart_demo`).
|
||||
|
||||
- Sin argumento: deduce desde `pwd` si estás dentro de `cpp/apps/<X>/` o `projects/*/apps/<X>/`.
|
||||
- Si no se puede deducir y no se pasa argumento, el pipeline lista las apps disponibles en stderr y aborta.
|
||||
|
||||
## Qué hace el pipeline
|
||||
|
||||
1. `resolve_cpp_app_dir_bash_infra` — resuelve `<app_name>` y `<dir absoluto>` desde arg o CWD.
|
||||
2. Verifica `CMakeLists.txt` en el dir resuelto.
|
||||
3. `build_cpp_windows_bash_infra <app>` — cross-compila el target específico con `cpp/build/windows/` (configura toolchain `mingw-w64.cmake` la primera vez).
|
||||
4. `deploy_cpp_exe_to_windows_bash_infra <app> <dir>`:
|
||||
- `taskkill.exe /IM <app>.exe /F` (pre-autorizado).
|
||||
- Copia `<app>.exe` + DLLs al top-level de `Desktop/apps/<app>/`.
|
||||
- rsync `cpp/build/windows/apps/<app>/assets/` → `Desktop/apps/<app>/assets/`.
|
||||
- rsync `<app_dir>/enrichers/` → `assets/enrichers/` si existe.
|
||||
- Si `app.md` declara `python_runtime: true`, regenera `runtime/` con `tools/freeze_python_runtime.sh` y rsync a `assets/runtime/`.
|
||||
- Copia `gx-cli`/`gx-cli.exe` si existen.
|
||||
- **NUNCA** toca `local_files/` (estado del usuario).
|
||||
5. Imprime `ls -lh` del `.exe` final.
|
||||
|
||||
## Notas
|
||||
|
||||
- Solo target Windows hoy. Android / Linux quedan fuera (Linux ya lo da `cpp/build/`).
|
||||
- Variables override-ables: `BUILD_WIN`, `WIN_DESKTOP_APPS`, `FN_REGISTRY_ROOT`.
|
||||
- Si la app no está registrada en `cpp/CMakeLists.txt`, `cmake --build --target <app>` falla. Registrar siguiendo `.claude/rules/cpp_apps.md` §5.
|
||||
- Para tocar la lógica: editar `bash/functions/{infra,pipelines}/{resolve_cpp_app_dir,deploy_cpp_exe_to_windows,compile_cpp_app}.sh`, no este wrapper.
|
||||
@@ -0,0 +1,260 @@
|
||||
# /documentar — Distribuir la conversacion en la documentacion del registry
|
||||
|
||||
Documenta la **conversacion actual** repartiendo el contenido en TODOS los `.md` que correspondan: artefactos del registry (funciones, tipos, apps, projects, analysis, vaults) **y documentacion global del repo** (`docs/*`, `docs/adr/`, `CHANGELOG.md`, `dev/issues/*`, `.claude/rules/*`, `.claude/CLAUDE.md`, sub-CLAUDEs, READMEs/SPECs en apps). Cierra con una entrada en `/entrada_diario`. El objetivo es que **otro LLM (o yo en otra sesion) pueda continuar** sin haber visto la conversacion: contexto, decisiones, gotchas, paths, IDs, comandos exactos, "lo siguiente que pega".
|
||||
|
||||
## Uso
|
||||
|
||||
```
|
||||
/documentar # documenta todo lo relevante de la sesion
|
||||
/documentar shaders_lab fase 6 # acota a artefactos/temas concretos (opcional)
|
||||
```
|
||||
|
||||
`$ARGUMENTS` es opcional: si va vacio, documenta toda la sesion. Si lleva texto, usalo como hilo conductor para decidir que es relevante.
|
||||
|
||||
---
|
||||
|
||||
## Reglas duras
|
||||
|
||||
1. **NUNCA** escribir secretos en ningun `.md` ni en el diario:
|
||||
- Passwords, tokens, API keys, GPG keys, ssh private keys, valores reales de variables de entorno sensibles (`REGISTRY_API_TOKEN`, `*_SECRET`, `*_PASSWORD`, `*_TOKEN`, basicAuth en URLs).
|
||||
- Si el usuario lo pide explicitamente, OK. Por defecto, redactar como `<token>` / `<password>` o referenciar el origen (`pass entry registry_api`, `~/.fn_pc`).
|
||||
- URLs publicas, hosts, puertos, paths, IDs, nombres de servicios, env var **names** (no values), licencias, hashes de commit cortos: SI se documentan.
|
||||
2. **NUNCA** sobreescribir secciones existentes ni reordenar contenido previo. Solo **append** o seccion nueva con timestamp/fase si encaja.
|
||||
3. **SIEMPRE** consultar `registry.db` con FTS5 para encontrar el `.md` correcto antes de editar (no asumir paths).
|
||||
4. **SIEMPRE** cerrar invocando `/entrada_diario` con un resumen del bloque (a no ser que el usuario diga lo contrario).
|
||||
5. **Densidad util > prosa**: comandos exactos, IDs del registry, paths relativos, error messages literales, flags de build, decisiones (con el "porque"), bugs encontrados (con el fix), proximos pasos. Sin fluff.
|
||||
|
||||
---
|
||||
|
||||
## PASO 0 — Recopilar el material de la sesion
|
||||
|
||||
Antes de escribir nada, repasar la conversacion y juntar:
|
||||
|
||||
1. **Artefactos tocados** (creados, editados, ejecutados, mencionados):
|
||||
- Funciones / tipos del registry → IDs `{name}_{lang}_{domain}`.
|
||||
- Apps (`apps/*` o `projects/*/apps/*`).
|
||||
- Projects (`projects/*`).
|
||||
- Analyses (`analysis/*` o `projects/*/analysis/*`).
|
||||
- Vaults (`projects/*/vaults/vault.yaml`).
|
||||
- Reglas (`.claude/rules/*.md`), ADRs (`docs/adr/*.md`), templates (`docs/templates/*`).
|
||||
- Issues (`dev/issues/*.md`, `dev/issues/completed/*.md`).
|
||||
- Docs globales (`docs/*.md`: architecture, integrity, execution_standard, fn_operations, sync_setup, init-pipelines, testing, functions, types, fn-registry-system-complete).
|
||||
- CLAUDE.md raiz (`.claude/CLAUDE.md`) y sub-CLAUDEs (`apps/*/.claude/CLAUDE.md`, `projects/*/apps/*/.claude/CLAUDE.md`, `analysis/*/.claude/CLAUDE.md`, `projects/*/analysis/*/.claude/CLAUDE.md`).
|
||||
- Docs sueltas en apps (`apps/*/SPEC.md`, `apps/*/README.md`, `apps/*/docs/*.md`, `cpp/DESIGN_SYSTEM.md`).
|
||||
- `CHANGELOG.md` raiz.
|
||||
|
||||
2. **Cambios concretos** desde git:
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
git status --short
|
||||
git diff --stat
|
||||
git log --since="6 hours ago" --oneline
|
||||
```
|
||||
Cada path modificado mapea a un artefacto — convertir a su `.md`.
|
||||
|
||||
3. **Material no codigo** que vale la pena dejar registrado:
|
||||
- Decisiones de diseño y por que (anti-bitrot: el porque suele perderse).
|
||||
- Bugs encontrados + raiz + fix (no solo "fix").
|
||||
- Atajos / convenciones nuevas.
|
||||
- Pendientes y "lo siguiente que pega" para la proxima sesion.
|
||||
- Aprendizajes operativos (build flags, cross-compile gotchas, env requerido).
|
||||
|
||||
4. **Filtrar secretos** segun la regla dura #1.
|
||||
|
||||
Si el material es solo conversacion exploratoria sin artefactos tocados, ir directo a PASO 4 (solo diary).
|
||||
|
||||
---
|
||||
|
||||
## PASO 1 — Mapear cada bloque de informacion a su `.md`
|
||||
|
||||
Para cada artefacto identificado, localizar su `.md` consultando `registry.db`:
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
|
||||
# Funcion / tipo
|
||||
sqlite3 registry.db "SELECT id, file_path FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:NAME* OR description:NAME*');"
|
||||
sqlite3 registry.db "SELECT id, file_path FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:NAME* OR description:NAME*');"
|
||||
|
||||
# App / project / analysis (los .md son nombres fijos)
|
||||
sqlite3 registry.db "SELECT id, dir_path FROM apps WHERE name = 'NAME';" # → {dir_path}/app.md
|
||||
sqlite3 registry.db "SELECT id, dir_path FROM projects WHERE name = 'NAME';" # → projects/NAME/project.md
|
||||
sqlite3 registry.db "SELECT id, dir_path FROM analysis WHERE name = 'NAME';" # → {dir_path}/analysis.md
|
||||
sqlite3 registry.db "SELECT id, name, path FROM vaults WHERE name = 'NAME';" # → vault.yaml entry
|
||||
```
|
||||
|
||||
Si el `.md` aun no existe (artefacto recien creado en la sesion y todavia no indexado), el path se deduce de la convencion:
|
||||
- Funcion: `functions/{domain}/{name}.md`, `python/functions/{domain}/{name}.md`, `bash/functions/{domain}/{name}.md`, `frontend/functions/{domain}/{name}.md`, `cpp/functions/{domain}/{name}.md`.
|
||||
- Tipo: `types/{domain}/{name}.md` (codigo en `functions/{domain}/{name}.go`).
|
||||
- App: `apps/{name}/app.md` o `projects/{proyecto}/apps/{name}/app.md`.
|
||||
- Project: `projects/{name}/project.md`.
|
||||
- Analysis: `analysis/{name}/analysis.md` o `projects/{proyecto}/analysis/{name}/analysis.md`.
|
||||
|
||||
### Donde escribir dentro de cada `.md`
|
||||
|
||||
| Tipo de `.md` | Seccion preferida para append |
|
||||
|-----------------------|----------------------------------------------------------------------------------------------------------------|
|
||||
| Funcion / tipo | `## Notas` al final. Si no existe, crearla. NO tocar el frontmatter salvo que el usuario pida cambiar metadata. |
|
||||
| App (`app.md`) | `## Estado actual` con sub-fases si el app ya las usa (ej. `### Fase 7 — ... [done]`). Si no, `## Notas`. Tambien `## Lo siguiente que pega` para futuros pasos. |
|
||||
| Project (`project.md`)| `## Notas` o seccion del area afectada (`## Apps`, `## Operacion`, `## Troubleshooting`). |
|
||||
| Analysis (`analysis.md`)| `## Notas` o `## Hallazgos` (crearla si no existe). |
|
||||
| Vault (`vault.yaml`) | Comentario al final del entry o crear `vaults/{name}/README.md` con notas operativas (NO meter datos sensibles). |
|
||||
| Regla (`.claude/rules/*`)| Solo si el usuario explicitamente formaliza una regla nueva — entonces archivo nuevo + entrada en `INDEX.md`. |
|
||||
| ADR (`docs/adr/*`) | Solo si la decision es arquitectural y persistente — archivo nuevo numerado. |
|
||||
|
||||
### Documentacion global / cross-cutting (NO saltarse)
|
||||
|
||||
Estos `.md` describen el sistema entero, no un artefacto concreto. Cuando un cambio impacta convenciones, comportamiento de agentes, decisiones, issues abiertos o features visibles al usuario, **tambien** se actualizan aqui:
|
||||
|
||||
| Archivo / carpeta | Cuando tocarlo | Como |
|
||||
|---------------------------------------|-----------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------|
|
||||
| `CHANGELOG.md` (raiz) | Cambio visible al usuario o agentes: nueva funcion/pipeline/app, breaking change, fix relevante, rename, deprecate. | Append bajo seccion del dia (`## YYYY-MM-DD`) con `### Added/Changed/Fixed/Removed/Deprecated`. NUNCA reescribir entradas previas. Si es trabajo en curso, usar `## [Unreleased]`. |
|
||||
| `docs/adr/NNNN-slug.md` | **Decision arquitectural** persistente con alternativas descartadas (no es regla operativa, es historia del por que). | Archivo nuevo numerado siguiendo plantilla en `docs/adr/README.md`. Estado inicial: `accepted` o `proposed`. |
|
||||
| `docs/architecture.md` | Cambia la arquitectura general (BDs, layers, flujo de datos, capas). | Append en seccion afectada o nueva subseccion. Mantener tablas y diagramas existentes. |
|
||||
| `docs/integrity.md` | Nueva regla de integridad / referencia cruzada que el indexer valida. | Append a la lista de reglas. Reflejar tambien en codigo del indexer si toca. |
|
||||
| `docs/execution_standard.md` | Cambia el estandar de ejecucion (`fn run`, despacho por lenguaje, env vars). | Append seccion. Sincronizar con `.claude/CLAUDE.md` si menciona los mismos comandos. |
|
||||
| `docs/sync_setup.md` | Cambia el flujo de `fn sync`, env vars (`FN_REGISTRY_API`, `REGISTRY_API_TOKEN`), `~/.fn_pc`, troubleshooting. | Append. Recordatorio: NO escribir el valor del token, solo el nombre. |
|
||||
| `docs/init-pipelines.md` | Nuevo pipeline de scaffolding o cambio en uno existente. | Append seccion del pipeline. |
|
||||
| `docs/testing.md` | Cambia convencion de tests, runners, layout de `*_test.go`/`test_*.py`. | Append seccion afectada. |
|
||||
| `docs/functions.md` / `docs/types.md` | Cambia el schema de la tabla `functions` o `types` (columnas, FTS5, enums, `params_schema`). | Append. Sincronizar con `.claude/CLAUDE.md` schema rapido. |
|
||||
| `docs/fn_operations.md` | Cambia el schema/comportamiento de `operations.db` o el bucle reactivo (entities, relations, executions, assertions). | Append seccion afectada. |
|
||||
| `docs/fn-registry-system-complete.md` | Snapshot completo del sistema — solo si la sesion implico un rediseño grande. Normalmente NO se toca por sesion. | Si toca, append seccion con timestamp. |
|
||||
| `docs/templates/*.md` | Cambia el frontmatter obligatorio de un tipo de artefacto (function/pipeline/component/type/app/project/analysis). | Editar la plantilla correspondiente. Tambien actualizar ejemplos en `.claude/CLAUDE.md`. |
|
||||
| `dev/issues/NNNN-*.md` | Sesion trabajo en un issue: progreso, blockers, decisiones del scope. | Append `## Notas / Progreso` con timestamp. NO mover de `dev/issues/` a `dev/issues/completed/` salvo que el issue cierre. |
|
||||
| `dev/issues/completed/NNNN-*.md` | Issue completado en esta sesion. | Mover el archivo a `completed/` (`git mv`) y actualizar la fila en `dev/issues/README.md` (estado `completado`, link a `completed/...`). |
|
||||
| `dev/issues/README.md` | Issue creado, cambia estado, prioridad, dependencias. | Editar la fila correspondiente o anadir nueva al final de la tabla. |
|
||||
| `.claude/rules/*.md` + `INDEX.md` | El usuario formaliza una nueva regla operativa. | Archivo nuevo + fila en `INDEX.md`. Numerar en el indice manteniendo orden. |
|
||||
| `.claude/CLAUDE.md` (raiz) | Cambio en convenciones globales del proyecto, comandos `fn` nuevos, env vars, estructura de carpetas, schema BDs. | Append en seccion afectada. Sincronizar con `docs/` si hay overlap. |
|
||||
| Sub-CLAUDE (`apps/*/.claude/CLAUDE.md`, `analysis/*/.claude/CLAUDE.md`, `projects/*/apps/*/.claude/CLAUDE.md`) | Cambio especifico en como un agente debe trabajar dentro de esa app/analysis (no global). | Append. NO duplicar reglas que ya estan en CLAUDE.md raiz. |
|
||||
| `cpp/DESIGN_SYSTEM.md` | Cambia tokens, layout, primitivas visuales del stack C++. | Append seccion afectada. |
|
||||
| `apps/*/SPEC.md`, `apps/*/README.md`, `apps/*/docs/*.md`, `apps/*/NEXT_STEPS_*.md` | App tiene docs propias mas alla de `app.md`. | Append. Si el contenido encaja mejor en `app.md`, preferir `app.md` y mencionar el SPEC desde ahi. |
|
||||
|
||||
### Reglas de decision rapidas
|
||||
|
||||
- **¿Cambio visible / breaking / nueva feature?** → `CHANGELOG.md` SI.
|
||||
- **¿Decision con alternativas descartadas?** → ADR SI. Una regla operativa "haz X" sin alternativas → `.claude/rules/`.
|
||||
- **¿Cambia como un agente debe comportarse?** → `.claude/rules/` o `.claude/CLAUDE.md` (global) o sub-CLAUDE (local).
|
||||
- **¿Cambia el schema de BDs o columnas?** → `docs/functions.md`/`docs/types.md`/`docs/fn_operations.md` + `.claude/CLAUDE.md` schema rapido.
|
||||
- **¿Trabajo en un issue?** → `dev/issues/NNNN-*.md` + tabla en `dev/issues/README.md`.
|
||||
- **¿Cross-cutting sin artefacto y sin encajar arriba?** → solo diario (PASO 4).
|
||||
|
||||
---
|
||||
|
||||
## PASO 2 — Escribir las actualizaciones
|
||||
|
||||
Para cada `.md` identificado:
|
||||
|
||||
1. `Read` el archivo para ver estructura actual y secciones.
|
||||
2. Decidir si **append a seccion existente** o **crear seccion nueva**.
|
||||
3. Usar `Edit` para append (preferible) o `Write` solo si es archivo nuevo.
|
||||
4. **Mantener el estilo** del archivo (markdown, viñetas cortas, bloques de codigo con lenguaje).
|
||||
5. **No tocar el frontmatter** salvo que el usuario haya cambiado metadata explicita (`description`, `tags`, `uses_functions`, `version`). Si se toca, re-indexar al final.
|
||||
|
||||
### Plantilla de bloque para append en `.md` de artefacto
|
||||
|
||||
```markdown
|
||||
|
||||
### {Fase / Tema corto} `[done|wip|notes]`
|
||||
|
||||
{1-3 lineas de contexto: que se hizo y por que.}
|
||||
|
||||
- Hecho: {cambio concreto, con path y/o ID si aplica}.
|
||||
- Hecho: {cambio concreto}.
|
||||
- Bug + fix: {sintoma → raiz → fix} (si procede).
|
||||
- Decision: {opcion elegida vs alternativa} — porque {razon} (si procede).
|
||||
- Pendiente: {algo que queda} (si procede).
|
||||
|
||||
{Comando(s) exacto(s) si la operacion vale la pena reproducir.}
|
||||
```
|
||||
|
||||
### Plantilla para `## Lo siguiente que pega` (apps maduras)
|
||||
|
||||
```markdown
|
||||
- {Tarea proxima}: {contexto minimo, que tocar, criterio de hecho}.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 3 — Reindexar si tocaste frontmatter o creaste artefacto
|
||||
|
||||
Si los cambios de la sesion incluyen creacion de funciones/tipos/apps/projects/analysis/vaults o modificacion de frontmatter:
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry && ./fn index
|
||||
```
|
||||
|
||||
Y verificar:
|
||||
|
||||
```bash
|
||||
./fn show {id_creado_o_modificado}
|
||||
```
|
||||
|
||||
Si solo se editaron secciones de prosa (Notas, Estado actual, etc.) sin tocar frontmatter, el indexado igual recoge `documentation`/`notes` actualizados — re-indexar es barato y deja la BD coherente.
|
||||
|
||||
---
|
||||
|
||||
## PASO 4 — Cerrar con entrada al diario
|
||||
|
||||
Invocar `/entrada_diario` con un resumen conciso de la sesion (3-6 viñetas, verbos en pasado para lo hecho, infinitivo para pendientes). Referenciar:
|
||||
|
||||
- IDs de artefactos tocados.
|
||||
- Paths relativos clave.
|
||||
- Hashes de commit cortos si la sesion termino con commits.
|
||||
- ADRs / issues / proposals abiertos.
|
||||
|
||||
Ejemplo de invocacion:
|
||||
|
||||
```
|
||||
/entrada_diario shaders_lab fase 6 — menubar reusable (View + Layouts) cableado, persistencia de layouts en shaders_lab.db
|
||||
```
|
||||
|
||||
Si el usuario ya invoco `/entrada_diario` antes en esta sesion para este bloque, **no duplicar**: solo añadir lo que no estaba.
|
||||
|
||||
---
|
||||
|
||||
## PASO 5 — Reportar al usuario
|
||||
|
||||
Resumen breve (formato texto, no tabla a no ser que sean muchos):
|
||||
|
||||
```
|
||||
=== DOCUMENTADO ===
|
||||
|
||||
Artefactos (.md de registry):
|
||||
- apps/shaders_lab/app.md (Fase 6 — menubar)
|
||||
- cpp/functions/core/app_menubar.md (notas de uso)
|
||||
- cpp/functions/core/layouts_menu.md (notas de cableado)
|
||||
|
||||
Globales:
|
||||
- CHANGELOG.md (Added: app_menubar, layouts_menu)
|
||||
- docs/adr/0002-menubar-arch.md (nuevo ADR — decision menubar reusable)
|
||||
- dev/issues/README.md (issue 0027 → completado)
|
||||
- dev/issues/completed/0027-...md (movido)
|
||||
- .claude/rules/cpp_icons.md (regla nueva, anadida a INDEX.md)
|
||||
|
||||
Diario: docs/diary/2026-04-25.md (## 18:30 — ...)
|
||||
|
||||
Re-indexado: si | no
|
||||
Pendientes registrados: {N}
|
||||
Secretos omitidos: {lista de tipos redactados, ej. "REGISTRY_API_TOKEN"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist final
|
||||
|
||||
- [ ] Cada artefacto tocado tiene su `.md` actualizado (append, no overwrite).
|
||||
- [ ] Ningun secreto, password, token o key en los archivos.
|
||||
- [ ] Comandos exactos, IDs y paths para que otro LLM reproduzca.
|
||||
- [ ] Decisiones con su "porque", bugs con su raiz+fix.
|
||||
- [ ] `CHANGELOG.md` actualizado si el cambio es visible al usuario / agentes.
|
||||
- [ ] ADR creado (`docs/adr/NNNN-*.md`) si hay decision arquitectural con alternativas descartadas.
|
||||
- [ ] `dev/issues/*.md` y `dev/issues/README.md` actualizados si la sesion toco issues.
|
||||
- [ ] `docs/{architecture,integrity,functions,types,fn_operations,execution_standard,sync_setup,init-pipelines,testing}.md` actualizado si el cambio afecta lo que cada uno documenta.
|
||||
- [ ] `.claude/CLAUDE.md` raiz actualizado si cambian convenciones / comandos / schema globales.
|
||||
- [ ] `.claude/rules/*` + `INDEX.md` actualizado si el usuario formaliza una regla nueva.
|
||||
- [ ] Sub-CLAUDE de la app/analysis afectada actualizado si cambia comportamiento agente local.
|
||||
- [ ] `/entrada_diario` invocado con resumen de la sesion.
|
||||
- [ ] `./fn index` corrido si hubo creacion o cambio de frontmatter.
|
||||
- [ ] Reporte final al usuario con la lista de archivos tocados (artefactos + globales).
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -0,0 +1,211 @@
|
||||
# /e2e-cpp — Crear/ejecutar tests e2e para apps C++
|
||||
|
||||
Genera y corre tests e2e con **Dear ImGui Test Engine** sobre las apps C++ del registry. Cada app gana un ejecutable `<app>_tests` que reabre la app dentro de un harness de testing y ejecuta scripts de UI (clicks, escritura, asserts) sobre los componentes ImGui.
|
||||
|
||||
Suite ya instalada en `cpp/vendor/imgui_test_engine/`. Integracion en framework: `fn::run_app_test()` (ver `cpp/framework/app_base.h`). Opt-in via `-DFN_BUILD_TESTS=ON`. Sin la opcion los builds normales de `/compile` no cambian.
|
||||
|
||||
## Argumento
|
||||
|
||||
`$ARGUMENTS` — formato libre. Casos:
|
||||
|
||||
- `<app_name>` — solo el nombre. Si la app ya tiene tests, los ejecuta. Si no, pide al usuario que describa el flujo a testear.
|
||||
- `<app_name> <descripcion del flujo>` — genera un test nuevo para ese flujo y lo ejecuta. Ej: `chart_demo abrir cada tab y verificar que renderiza`.
|
||||
- vacio — detectar app desde `pwd` (si estas en `cpp/apps/<X>/` o `projects/*/apps/<X>/`); si no, listar apps disponibles.
|
||||
|
||||
## Pasos
|
||||
|
||||
### 1. Resolver app y directorio
|
||||
|
||||
```bash
|
||||
ROOT=/home/lucas/fn_registry
|
||||
ARGS="$ARGUMENTS"
|
||||
APP_ARG="${ARGS%% *}" # primera palabra
|
||||
FLOW_DESC="${ARGS#* }" # resto (puede coincidir con APP_ARG si solo hay una palabra)
|
||||
[ "$FLOW_DESC" = "$APP_ARG" ] && FLOW_DESC=""
|
||||
|
||||
# Detectar desde CWD si no hay arg
|
||||
if [ -z "$APP_ARG" ]; then
|
||||
CWD="$(pwd)"
|
||||
case "$CWD" in
|
||||
"$ROOT"/cpp/apps/*|"$ROOT"/projects/*/apps/*)
|
||||
APP_ARG="$(basename "$CWD")" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ -z "$APP_ARG" ]; then
|
||||
echo "Apps C++ disponibles:"
|
||||
ls "$ROOT"/cpp/apps/ 2>/dev/null
|
||||
ls "$ROOT"/projects/*/apps/ 2>/dev/null
|
||||
echo "Uso: /e2e-cpp <app> [descripcion del flujo]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_DIR=""
|
||||
for cand in "$ROOT/cpp/apps/$APP_ARG" "$ROOT"/projects/*/apps/"$APP_ARG"; do
|
||||
[ -d "$cand" ] && [ -f "$cand/CMakeLists.txt" ] && APP_DIR="$cand" && break
|
||||
done
|
||||
[ -z "$APP_DIR" ] && { echo "App C++ no encontrada: $APP_ARG"; exit 1; }
|
||||
echo "App: $APP_ARG"
|
||||
echo "Dir: $APP_DIR"
|
||||
```
|
||||
|
||||
### 2. Inspeccionar la app
|
||||
|
||||
Lee:
|
||||
- `$APP_DIR/main.cpp` — identifica:
|
||||
- El nombre de la funcion principal de render (suele ser `render()` o `static void render()`).
|
||||
- El **window title** que aparece en `ImGui::Begin("...")` — sera el primer arg de `ctx->SetRef("...")` en los tests. Si tiene em-dash u otros UTF-8 no ASCII, anotar la secuencia de bytes (ej: `\xe2\x80\x94` para `—`).
|
||||
- Los IDs/labels de los widgets candidatos: tabs (`BeginTabItem`), botones (`Button`), inputs (`InputText`), checkboxes, etc.
|
||||
- `$APP_DIR/app.md` — para entender el dominio y proposito.
|
||||
- `$APP_DIR/CMakeLists.txt` — para saber que `.cpp` del registry enlaza la app (los tests linkearan los mismos).
|
||||
|
||||
### 3. Decidir tests a escribir
|
||||
|
||||
**Si `$FLOW_DESC` esta vacio**: pregunta al usuario que flujo testear. Sugiere 2-3 candidatos basados en los widgets vistos en main.cpp. NO inventes flujos sin confirmacion.
|
||||
|
||||
**Si `$FLOW_DESC` viene en el comando**: convierte la descripcion en una secuencia de pasos atomicos del Test Context API. Ejemplos canonicos:
|
||||
|
||||
| Descripcion humano | Llamada Test Engine |
|
||||
|---|---|
|
||||
| "abrir tab X" | `ctx->ItemClick("##tabs/X")` o el path real del TabBar |
|
||||
| "escribir 'hola' en el input search" | `ctx->ItemInput("Search", "hola")` |
|
||||
| "click boton Aceptar" | `ctx->ItemClick("Aceptar")` |
|
||||
| "verificar que aparece el modal Y" | `IM_CHECK(ctx->WindowInfo("Y").ID != 0)` |
|
||||
| "checkbox Z marcado" | `IM_CHECK(ctx->ItemIsChecked("Z"))` |
|
||||
| "menu File > Open" | `ctx->MenuClick("File/Open")` |
|
||||
|
||||
Ver `cpp/vendor/imgui_test_engine/imgui_te_context.h` para el catalogo completo de helpers.
|
||||
|
||||
### 4. Preparar la app para tests (idempotente)
|
||||
|
||||
Si es la primera vez que la app gana tests, hay que:
|
||||
|
||||
**a) Hacer la funcion render() linkable desde otra TU**
|
||||
|
||||
```cpp
|
||||
// Antes: static void render() { ... }
|
||||
// Despues: void render() { ... }
|
||||
```
|
||||
|
||||
**b) Excluir `int main()` con guarda `FN_TEST_BUILD`**
|
||||
|
||||
```cpp
|
||||
#ifndef FN_TEST_BUILD
|
||||
int main() {
|
||||
return fn::run_app({...}, render);
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
Verifica con `grep -n "FN_TEST_BUILD\|^static void render" "$APP_DIR/main.cpp"`. Si ya esta, no toques nada.
|
||||
|
||||
### 5. Generar/extender el archivo de tests
|
||||
|
||||
`$APP_DIR/tests/<app>_tests.cpp` — un solo archivo por app, varias `IM_REGISTER_TEST` dentro de `register_tests()`.
|
||||
|
||||
**Plantilla**:
|
||||
|
||||
```cpp
|
||||
// E2E tests para <app> — Dear ImGui Test Engine.
|
||||
// Construido solo con -DFN_BUILD_TESTS=ON. Reusa el mismo main.cpp con
|
||||
// FN_TEST_BUILD definido para excluir su int main().
|
||||
|
||||
#include "app_base.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_te_engine.h"
|
||||
#include "imgui_te_context.h"
|
||||
|
||||
void render(); // definido en <app>/main.cpp
|
||||
|
||||
static void register_tests(ImGuiTestEngine* e) {
|
||||
ImGuiTest* t = nullptr;
|
||||
|
||||
t = IM_REGISTER_TEST(e, "<app>", "<test_name>");
|
||||
t->TestFunc = [](ImGuiTestContext* ctx) {
|
||||
ctx->SetRef("<window_title_exacto>");
|
||||
// ... pasos del flujo
|
||||
};
|
||||
|
||||
// mas tests aqui
|
||||
}
|
||||
|
||||
int main() {
|
||||
fn::AppConfig cfg{};
|
||||
cfg.title = "<app>_tests";
|
||||
cfg.width = 1280;
|
||||
cfg.height = 800;
|
||||
return fn::run_app_test(cfg, render, register_tests);
|
||||
}
|
||||
```
|
||||
|
||||
Si el archivo ya existe: **AGREGA** un nuevo `IM_REGISTER_TEST` dentro de la funcion `register_tests` existente. NO sobreescribas tests previos.
|
||||
|
||||
### 6. Actualizar CMakeLists.txt (idempotente)
|
||||
|
||||
Si `$APP_DIR/CMakeLists.txt` no tiene aun el bloque de tests, agregar al final:
|
||||
|
||||
```cmake
|
||||
# --- E2E tests (opt-in via -DFN_BUILD_TESTS=ON) ---
|
||||
if(FN_BUILD_TESTS)
|
||||
add_imgui_app(<app>_tests
|
||||
main.cpp
|
||||
tests/<app>_tests.cpp
|
||||
# mismos .cpp del registry que la app principal
|
||||
${CMAKE_SOURCE_DIR}/functions/<dom>/<func>.cpp
|
||||
...
|
||||
)
|
||||
target_compile_definitions(<app>_tests PRIVATE FN_TEST_BUILD)
|
||||
endif()
|
||||
```
|
||||
|
||||
Las fuentes deben replicar las del target principal (mismas funciones del registry). Si la app ya tiene un bloque `if(FN_BUILD_TESTS)`, no lo dupliques.
|
||||
|
||||
### 7. Build
|
||||
|
||||
```bash
|
||||
cd "$ROOT/cpp"
|
||||
cmake -S . -B build/linux_tests -DFN_BUILD_TESTS=ON 2>&1 | tail -5
|
||||
cmake --build build/linux_tests --target ${APP_ARG}_tests -j4 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Si el build falla:
|
||||
- Errores de compilacion en `tests/...cpp` → revisa nombres de widgets/paths con el codigo real de main.cpp.
|
||||
- "undefined reference to render" → falta quitar `static` o falta el `#ifndef FN_TEST_BUILD` en main.cpp.
|
||||
- "multiple definition of main" → falta el `target_compile_definitions(... FN_TEST_BUILD)` en CMakeLists.
|
||||
|
||||
### 8. Ejecutar (headless en WSL)
|
||||
|
||||
WSL no tiene GLX 4.3 nativo — los tests corren bajo `xvfb` con software renderer Mesa. Wrapper canonico:
|
||||
|
||||
```bash
|
||||
cd "$ROOT/cpp/build/linux_tests"
|
||||
TEST_BIN="$(find . -name "${APP_ARG}_tests" -type f -executable | head -1)"
|
||||
[ -z "$TEST_BIN" ] && { echo "no encuentro el binario de tests"; exit 1; }
|
||||
|
||||
timeout 90 xvfb-run -a -s "-screen 0 1280x800x24" \
|
||||
env LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe \
|
||||
"$TEST_BIN" 2>&1
|
||||
EXIT=$?
|
||||
echo "EXIT: $EXIT"
|
||||
```
|
||||
|
||||
Si en el host el usuario tiene GL nativo y `DISPLAY` funciona, el wrapper xvfb-run sigue siendo seguro (ejecuta dentro de su propio display).
|
||||
|
||||
### 9. Reportar
|
||||
|
||||
- Si `EXIT == 0` y la salida contiene `Tests Result: OK` → reporta `N/M tests passed` con la lista de tests ejecutados.
|
||||
- Si `EXIT != 0` → muestra el bloque de log del test fallido (test engine imprime el path del widget que no encontro, el archivo y la linea del IM_CHECK que fallo). Sugiere correcciones (widget renombrado, path mal escrito, race entre frames — usar `ctx->Yield()`).
|
||||
|
||||
### 10. Despues de añadir tests
|
||||
|
||||
NO ejecutes `fn index` automaticamente — los tests no son funciones del registry, son artefactos de la app. Si el usuario los queria persistir, ya los tiene en `<app_dir>/tests/`.
|
||||
|
||||
Si la app es un sub-repo (lo normal segun ADR 0002), recordar al usuario que los archivos nuevos viven dentro del repo de la app y necesitan un commit alli (no en `fn_registry`).
|
||||
|
||||
## Referencias
|
||||
|
||||
- API de Test Context: `cpp/vendor/imgui_test_engine/imgui_te_context.h`
|
||||
- API del engine: `cpp/vendor/imgui_test_engine/imgui_te_engine.h`
|
||||
- Implementacion del harness: `cpp/framework/app_base.cpp` (funcion `fn::run_app_test`)
|
||||
- Ejemplo canonico: `cpp/apps/chart_demo/tests/chart_demo_tests.cpp`
|
||||
- Licencia del test engine: personal/open-source gratis (`cpp/vendor/imgui_test_engine/LICENSE.txt`)
|
||||
@@ -0,0 +1,47 @@
|
||||
# /entrada_diario — Añadir entrada al diario del día
|
||||
|
||||
Wrapper sobre `append_diary_entry_bash_infra`. La función del registry maneja todo el manejo de archivos (crear `docs/diary/YYYY-MM-DD.md` si no existe, append seguro, formato exacto). Este comando solo decide el contenido.
|
||||
|
||||
## Uso
|
||||
|
||||
```
|
||||
/entrada_diario <descripción del bloque de trabajo>
|
||||
/entrada_diario # sin args → resume sesión actual
|
||||
```
|
||||
|
||||
## Pasos del asistente
|
||||
|
||||
1. **Componer `TITULO` (corto, una linea) y `CUERPO`** (viñetas markdown):
|
||||
- Con `$ARGUMENTS`: derivar `TITULO` directo del argumento; `CUERPO` con viñetas concretas (`- Hecho:`, `- Pendiente:`).
|
||||
- Sin `$ARGUMENTS`: revisar TaskList + `git log --since=today` + `git status` y resumir en 3-5 viñetas.
|
||||
|
||||
2. **Llamar la función del registry**:
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
source bash/functions/infra/append_diary_entry.sh
|
||||
append_diary_entry "<TITULO>" "$(cat <<'EOF'
|
||||
<CUERPO>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
La función imprime el path del archivo escrito.
|
||||
|
||||
## Reglas de estilo
|
||||
|
||||
- Viñetas breves, no párrafos. Verbos en pasado para lo hecho, infinitivo para pendientes.
|
||||
- Enlaces a artefactos: commits (SHA corto 7-8 chars), ADRs (`[0001](../adr/0001-...)`), funciones del registry por ID.
|
||||
- No duplicar con CHANGELOG: el diario es contexto operativo ("qué hice hoy"), el CHANGELOG es "qué cambió cara al usuario".
|
||||
- NUNCA editar secciones anteriores. La función solo append.
|
||||
|
||||
## Relación con otras formas de registro
|
||||
|
||||
| Si quieres documentar... | Usa |
|
||||
|--------------------------|-----|
|
||||
| Qué trabajé hoy | `/entrada_diario` → `docs/diary/` |
|
||||
| Qué cambió en el código (cara usuario/agentes) | Editar `CHANGELOG.md` directamente |
|
||||
| Por qué tomamos una decisión arquitectural | Nuevo ADR en `docs/adr/NNNN-*.md` |
|
||||
| Una regla operativa nueva del registry | Nuevo archivo en `.claude/rules/` + entrada en INDEX.md |
|
||||
|
||||
## Para tocar la lógica
|
||||
|
||||
Editar la función `append_diary_entry_bash_infra` en el registry, no este wrapper.
|
||||
@@ -0,0 +1,281 @@
|
||||
# /extract-design — Mejorar @fn_library con exports de Claude Design
|
||||
|
||||
Eres un agente mejorador del design system. Tu trabajo es analizar un export "standalone" de Claude Design (`sources/frontend_designs/*.html`), identificar componentes nuevos o mejoras sobre `@fn_library`, aplicarlos al registry y propagarlos al espejo público `subrepos/fn-design-system` (GitHub + Gitea).
|
||||
|
||||
**Objetivo:** cada diseño exportado debería dejar el registry un poco mejor que antes. Lo que Claude Design inventó para cubrir un hueco hoy → componente reutilizable del registry mañana.
|
||||
|
||||
---
|
||||
|
||||
## Argumento
|
||||
|
||||
`$ARGUMENTS` — ruta al `.html` en `sources/frontend_designs/`. Si no se proporciona:
|
||||
1. Lista los `.html` bajo `sources/frontend_designs/` ordenados por fecha.
|
||||
2. Muestra fecha + nombre + tamaño.
|
||||
3. Pregunta cuál procesar. Default: el más reciente.
|
||||
|
||||
---
|
||||
|
||||
## PASO 0 — Validar input
|
||||
|
||||
```bash
|
||||
ls -lht sources/frontend_designs/*.html 2>/dev/null
|
||||
```
|
||||
|
||||
Si no existe el fichero, abortar. Si existe, leer las primeras líneas para confirmar que es un export de Claude Design (`__bundler/manifest`, `__bundler/template` en el HTML).
|
||||
|
||||
---
|
||||
|
||||
## PASO 1 — Decodificar el bundle
|
||||
|
||||
Ejecutar el extractor:
|
||||
|
||||
```bash
|
||||
python3 .claude/scripts/extract_design_bundle.py \
|
||||
"sources/frontend_designs/<NOMBRE>.html" \
|
||||
"sources/frontend_designs/<NOMBRE>_extracted/"
|
||||
```
|
||||
|
||||
Esperado: directorio con `app.jsx`, `fn_library_emu.jsx`, `charts_emu.jsx`, `data.jsx` + fuentes woff2 + `manifest.json`.
|
||||
|
||||
Si falta alguno de los 4 `.jsx` clave, inspeccionar por UUID; puede que Claude Design haya usado estructura distinta. Reportar al usuario.
|
||||
|
||||
---
|
||||
|
||||
## PASO 2 — Inventariar el diseño
|
||||
|
||||
Leer `app.jsx` y listar **todos los componentes React definidos** (funciones que empiezan con mayúscula o usan `function Xxx(`). Categorizar:
|
||||
|
||||
### 2a. Componentes del export que YA existen en `@fn_library`
|
||||
- Grep el barrel: `cat frontend/functions/ui/index.ts | grep "^export"`.
|
||||
- Para cada componente del export, ver si aparece en el barrel. Registrar coincidencias.
|
||||
|
||||
### 2b. Componentes nuevos (no existen en el registry)
|
||||
Componentes React del `app.jsx` cuyo nombre no aparece en el barrel. Estos son **candidatos a extracción**.
|
||||
|
||||
### 2c. Uso de variantes / props no documentadas
|
||||
Leer `fn_library_emu.jsx` del export y comparar API con tus `.tsx` reales:
|
||||
|
||||
```bash
|
||||
# Comprobar componentes específicos si el export los usa con props nuevas
|
||||
sqlite3 registry.db "SELECT id, signature, props FROM functions WHERE id = 'alert_ts_ui';"
|
||||
```
|
||||
|
||||
Anotar discrepancias (variantes faltantes, props nuevas, tipos distintos).
|
||||
|
||||
### 2d. Datos/patrones reutilizables en `data.jsx`
|
||||
- RNG determinista (mulberry32) → candidato a `frontend/functions/core/rng_seeded_ts_core` o `python/functions/core/`.
|
||||
- Helpers tipo `statusBadge()` → documentar como receta, no como componente.
|
||||
|
||||
---
|
||||
|
||||
## PASO 3 — Consultar el registry para evitar duplicados
|
||||
|
||||
Para cada componente candidato del paso 2b, búsqueda FTS5 antes de proponerlo:
|
||||
|
||||
```bash
|
||||
sqlite3 registry.db "SELECT id, kind, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:<CANDIDATO>* OR description:<PALABRAS_CLAVE>') ORDER BY name;"
|
||||
```
|
||||
|
||||
Si encuentras algo similar que pueda ser mejorado en lugar de duplicado, márcalo como **mejora** a ese existente.
|
||||
|
||||
---
|
||||
|
||||
## PASO 4 — Presentar el diagnóstico al usuario
|
||||
|
||||
Muestra en tablas separadas:
|
||||
|
||||
### 🟢 Componentes nuevos candidatos
|
||||
|
||||
| # | Nombre propuesto | Dominio | Líneas | Reutilizable en | API |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | `funnel_chart_ts_ui` | ui | ~35 | CRM, analytics, funnels genéricos | `(data: Array<{stage, value}>, variant?) → JSX` |
|
||||
|
||||
### 🟡 Mejoras a componentes existentes
|
||||
|
||||
| # | Componente | Mejora | Tipo | Riesgo |
|
||||
|---|---|---|---|---|
|
||||
| A | `alert_ts_ui` | Añadir variantes `success`, `warning`, `info` | Expandir enum | Bajo — no rompe API |
|
||||
| B | `data_table_ts_ui` | Prop `density: 'compact'|'cozy'|'roomy'` | Añadir prop opcional | Bajo |
|
||||
|
||||
### 🔵 Patrones a documentar (no componente)
|
||||
|
||||
| Patrón | Dónde registrar |
|
||||
|---|---|
|
||||
| `statusBadge` helper | `DESIGN_SYSTEM.md` sección "patterns" |
|
||||
|
||||
**Esperar confirmación.** El usuario responde con sintaxis `1,2,A,B` (o `all`, o `nuevos only`, o descarta algunos). Si dice `all`, aplica todo lo listado.
|
||||
|
||||
---
|
||||
|
||||
## PASO 5 — Aplicar mejoras aprobadas
|
||||
|
||||
### 5a. Para componentes nuevos (candidatos 🟢)
|
||||
|
||||
Por cada aprobado:
|
||||
|
||||
1. **Leer código** del `app.jsx` / `fn_library_emu.jsx` / `charts_emu.jsx` del export.
|
||||
2. **Adaptar al stack real del registry:**
|
||||
- Cambiar elementos SVG/HTML planos por primitivas de `@mantine/core` cuando corresponda (`Paper`, `Stack`, `Group`, `Text`).
|
||||
- Cambiar `style={{...}}` por props Mantine (`p`, `m`, `fw`, `gap`, `radius`, `c`).
|
||||
- Si es un chart, delegar en `@mantine/charts` cuando sea posible; solo usar SVG puro si Mantine no cubre el caso (ej: `Sparkline` en el registry ya es SVG puro por rendimiento).
|
||||
- Iconos: `@tabler/icons-react`.
|
||||
3. **Crear los dos ficheros** siguiendo la convención:
|
||||
- `frontend/functions/ui/<name>.tsx` — código React.
|
||||
- `frontend/functions/ui/<name>.md` — frontmatter completo.
|
||||
4. **Frontmatter del .md** (campos clave):
|
||||
```yaml
|
||||
id: <name>_ts_ui
|
||||
name: <name>
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
purity: impure
|
||||
framework: react
|
||||
version: 1.0.0
|
||||
description: "..."
|
||||
tags: [...]
|
||||
props: {...}
|
||||
emits: null
|
||||
params: []
|
||||
output: "JSX.Element — ..."
|
||||
source_repo: "claude.ai/design"
|
||||
source_license: ""
|
||||
source_file: "sources/frontend_designs/<NOMBRE>.html"
|
||||
file_path: frontend/functions/ui/<name>.tsx
|
||||
tested: false
|
||||
```
|
||||
5. **Añadir al barrel** `frontend/functions/ui/index.ts`: `export { Xxx } from './<name>'`.
|
||||
|
||||
### 5b. Para mejoras a componentes existentes (🟡)
|
||||
|
||||
Por cada aprobada:
|
||||
|
||||
1. **Leer** el `.tsx` actual.
|
||||
2. **Aplicar la mejora** sin romper la API existente: añade prop opcional, amplía enum de `variant`, etc.
|
||||
3. **Actualizar** el `.md` correspondiente para reflejar las nuevas variantes/props (campos `variant`, `props`, `description`).
|
||||
4. **Si la firma cambia**, actualizar también el `signature` del frontmatter.
|
||||
|
||||
### 5c. Para patrones a documentar (🔵)
|
||||
|
||||
1. Añadir una sección "Patterns" en `frontend/DESIGN_SYSTEM.md` si no existe.
|
||||
2. Registrar el patrón con un ejemplo corto.
|
||||
|
||||
---
|
||||
|
||||
## PASO 6 — Indexar y verificar
|
||||
|
||||
```bash
|
||||
./fn index
|
||||
```
|
||||
|
||||
- Si falla por integridad, arreglar y reintentar.
|
||||
- Verificar cada componente nuevo: `./fn show <id>`.
|
||||
- Confirmar que el barrel compila haciendo `cd frontend && pnpm tsc --noEmit` (si tarda, al menos verificar imports manualmente).
|
||||
|
||||
---
|
||||
|
||||
## PASO 7 — Sincronizar al espejo
|
||||
|
||||
```bash
|
||||
cd subrepos/fn-design-system
|
||||
./sync_from_registry.sh
|
||||
git add -A
|
||||
git status --short # Mostrar qué cambió en el espejo
|
||||
```
|
||||
|
||||
Si hay cambios, preparar commit. Si no, el sync no recogió las modificaciones — investigar.
|
||||
|
||||
---
|
||||
|
||||
## PASO 8 — Commit en ambos repos
|
||||
|
||||
### 8a. Commit en `fn_registry`
|
||||
|
||||
```bash
|
||||
git add frontend/functions/ui/ frontend/DESIGN_SYSTEM.md registry.db
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(ui): extract <N> components / <M> improvements from design export
|
||||
|
||||
From: sources/frontend_designs/<NOMBRE>.html
|
||||
|
||||
New components:
|
||||
- <id> — <descripción corta>
|
||||
- ...
|
||||
|
||||
Improvements:
|
||||
- <id> — <cambio>
|
||||
- ...
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### 8b. Commit en el espejo
|
||||
|
||||
```bash
|
||||
cd subrepos/fn-design-system
|
||||
git commit -m "sync: <N> new components + <M> improvements from <NOMBRE>
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 9 — Push
|
||||
|
||||
### 9a. Push del espejo (ambos remotes)
|
||||
|
||||
```bash
|
||||
cd subrepos/fn-design-system
|
||||
./push_all.sh
|
||||
```
|
||||
|
||||
Esto propaga a:
|
||||
- `gitea/dataforge/fn-design-system`
|
||||
- `github/gutierenmanuel/fn-design-system` ← este es el que Claude Design consume
|
||||
|
||||
### 9b. Push de fn_registry
|
||||
|
||||
**Preguntar al usuario** antes — no push sin permiso (ver CLAUDE.md del proyecto). Si dice sí:
|
||||
|
||||
```bash
|
||||
git push origin master
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PASO 10 — Resumen final
|
||||
|
||||
Mostrar al usuario:
|
||||
|
||||
```
|
||||
✓ Extracción completa.
|
||||
|
||||
Nuevos componentes en @fn_library:
|
||||
- <id> (frontend/functions/ui/<name>.tsx)
|
||||
- ...
|
||||
|
||||
Mejoras aplicadas:
|
||||
- <id>: <qué cambió>
|
||||
|
||||
Espejo actualizado:
|
||||
- Commit gitea: <sha> → <url>
|
||||
- Commit github: <sha> → <url>
|
||||
|
||||
Claude Design verá estas mejoras en su próxima lectura del repo enlazado.
|
||||
Siguiente acción sugerida: probar un prompt de dashboard que use <componente_nuevo>.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reglas críticas
|
||||
|
||||
- **NUNCA extraer sin aprobación explícita del usuario** — siempre paso 4 con tabla y espera.
|
||||
- **NUNCA sobrescribir un componente existente** en el paso 5b — solo añadir variantes/props opcionales. Si la mejora es incompatible, proponerlo como propuesta aparte (`fn proposal add`) en vez de aplicarla.
|
||||
- **SIEMPRE `source_repo: "claude.ai/design"`** en el frontmatter de componentes nuevos, y `source_file` apuntando al `.html` original.
|
||||
- **SIEMPRE mantener el orden:** registry → index → verify → sync mirror → commit both → push mirror → (ask to push fn_registry).
|
||||
- **El barrel `index.ts`** debe estar actualizado antes de hacer `fn index` (hay apps que lo importan).
|
||||
- **NO committear** `operations.db*`, `node_modules/`, `dist/`, `.env` ni nada que `.gitignore` excluya. Usa `git add` con rutas explícitas, no `git add -A` a ciegas.
|
||||
- **Si el usuario cancela a mitad**, dejar el working tree limpio o documentar qué quedó pendiente. No medio-commits.
|
||||
- **Patrones que no tienen sentido como primitiva** (ej. envs, branding específico) → documentar, no componentizar.
|
||||
@@ -0,0 +1,226 @@
|
||||
---
|
||||
description: "Auto-auditoria: verifica que la sesion registra uso de funciones, detecta gaps (patrones inline repetidos, wrappers saltados, heredocs sin function_id), lanza fn-constructor en paralelo para crear las funciones que faltan, y valida que Claude usara las nuevas en el siguiente turno"
|
||||
---
|
||||
|
||||
# /fn_claude — auto-auditoria + auto-construccion del registry
|
||||
|
||||
Comando meta: Claude se audita a si mismo. Verifica que su comportamiento en esta sesion (y las recientes) deja rastro en `call_monitor.operations.db`, detecta gaps reales del registry para el trabajo actual, lanza sub-agentes `fn-constructor` en paralelo para cerrar esos gaps, y verifica que la proxima vez usara las funciones nuevas.
|
||||
|
||||
## Objetivos del registry (Norte) — Issues 0086 + 0087
|
||||
|
||||
Cada corrida de `/fn_claude` optimiza 4 metricas visibles en Monitor tab del `registry_dashboard`:
|
||||
|
||||
1. **MAXIMIZAR `Reg %`** — % de calls con `function_id != ''`. Cada heredoc/bash que reescribe logica baja el ratio. Target: subir cada semana.
|
||||
2. **MEJORAR uso del registry por Claude** — Claude busca y reusa antes de escribir. `MCP` (mcp/heredoc/fn run) sube, `violations` baja. Si una funcion existe pero Claude no la encuentra, mejorar su `description`/`tags`/`params_schema` (FTS indexa todo).
|
||||
3. **ACELERAR tareas comunes** — patrones inline repetidos >2x -> `fn-constructor` los convierte en funcion, Claude las usa el siguiente turno. Menos pasos por tarea = mas valor.
|
||||
4. **PROMOVER COMPOSICIONES A PIPELINES** (issue 0087) — el registry crece **promoviendo secuencias A->B(->C) que se repiten con exito** a pipelines one-shot. Una funcion que hace bien una cosa NO necesita crecer. Pattern detection: `call_monitor sequences --detect --propose` (cron 6h activo) + tab `Promotion candidates` del dashboard.
|
||||
|
||||
Si `/fn_claude` no mueve estas 4 metricas, no esta haciendo su trabajo.
|
||||
|
||||
## Infraestructura de discovery activa (issue 0087)
|
||||
|
||||
Cada turno tienes capacidades ya cargadas SIN buscar. Si no las usas estas pagando el coste de FTS innecesariamente:
|
||||
|
||||
| Senal | Donde | Que hacer |
|
||||
|---|---|---|
|
||||
| Linea `CAPABILITIES (cache 1h): TOP: ... FRESH (7d): ... PIPELINES: ...` en cada UserPromptSubmit | hook `hook_capabilities_inject.sh` | Antes de buscar con `mcp__registry__fn_search`, mira si la funcion que necesitas esta en TOP/FRESH/PIPELINES. Si si, ve directo a `fn show <id>` (1 read) o `./fn run <id>` (0 reads). |
|
||||
| `<system-reminder>FUZZY-MATCH (issue 0087): your Bash command may already be a function. USE: ./fn run <id> -> <signature>` aparecido mid-flight | hook `hook_fn_match.sh` (PreToolUse, Bash matcher) | El hook detecto que tu Bash inline coincide con una funcion del registry. **NO ignores el reminder** — abandona el inline, llama a `./fn run <id>` o `mcp__registry__fn_run id="<id>"`. Si crees que la sugerencia es falso positivo, justifica brevemente antes de seguir inline (queda en violations). |
|
||||
| Hint AUSENTE para una query corta (`rsi sma` < 3 tokens) | threshold `raw_score >= 4.0` no alcanzado | NO interpretar la ausencia de hint como "no existe funcion". Usa `mcp__registry__fn_search` con query mas rica (3+ tokens del dominio). |
|
||||
| Falso positivo conocido: `agent` token | `robots.txt user-agent` matchea `agent_scaffold` | Ignora el reminder y sigue. Cost = 1 reminder ignorable. |
|
||||
|
||||
## Como combinar la 3 senales para minimizar pasos
|
||||
|
||||
1. **User prompt llega** -> lees `CAPABILITIES` line. Si la tarea encaja claramente con TOP/FRESH -> usa directo.
|
||||
2. **Vas a escribir Bash inline** -> el hook PreToolUse lo intercepta. Si dispara FUZZY-MATCH -> usa `./fn run <id>`.
|
||||
3. **No hay match y necesitas codigo** -> `mcp__registry__fn_search` con 3+ tokens. Si sigue sin hit -> delega a `fn-constructor` (no escribas inline). Patron repetido detectado por `call_monitor sequences` se promovera a pipeline en proximas iteraciones.
|
||||
|
||||
## Las 4 metricas norte (donde vigilarlas)
|
||||
|
||||
- `Reg %` (Monitor KPI) — % calls con function_id no vacio. Sube cuando el registry se usa.
|
||||
- `MCP` (Monitor KPI) — count calls con tools registry-aware (mcp*/heredoc*/fn_cli_run). Adopcion de patrones canonicos.
|
||||
- `Errors` / `Violations` (Monitor KPI) — bajan cuando el bucle cierra.
|
||||
- `Failed Functions` (Monitor sub-tab) — registry-functions que fallaron: diagnostico de bugs prioritarios.
|
||||
|
||||
Issue 0085 fase autocompleta. Reemplaza el flujo manual de "veo un patron, decido si extraer, escribo proposal, espero humano, fn-mejorador genera, fn-orquestador opera". Con `/fn_claude` Claude hace todo eso solo, **autonomamente para si mismo**.
|
||||
|
||||
---
|
||||
|
||||
## Comportamiento (ejecutalo en este orden)
|
||||
|
||||
### 1. AUDIT — ¿estoy siendo registrado?
|
||||
|
||||
```bash
|
||||
ROOT="/home/lucas/fn_registry"
|
||||
MON="$ROOT/projects/fn_monitoring/apps/call_monitor/operations.db"
|
||||
|
||||
# Pre-condiciones
|
||||
[ -f "$MON" ] || { echo "call_monitor.operations.db NO existe — issue 0085a no aplicado"; exit 1; }
|
||||
[ "$FN_TELEMETRY" = "1" ] || echo "WARNING: FN_TELEMETRY != 1 — wrappers Python/Bash inactivos"
|
||||
|
||||
# Metricas de la sesion actual + ultimas 24h
|
||||
sqlite3 "$MON" <<SQL
|
||||
SELECT 'calls_session', COUNT(*) FROM calls WHERE session_id = '${CLAUDE_SESSION_ID:-unknown}'
|
||||
UNION ALL SELECT 'calls_24h', COUNT(*) FROM calls WHERE ts >= CAST(strftime('%s','now','-1 day') AS INTEGER)
|
||||
UNION ALL SELECT 'violations_24h', COUNT(*) FROM violations WHERE ts >= CAST(strftime('%s','now','-1 day') AS INTEGER)
|
||||
UNION ALL SELECT 'tool_used_distribution_24h', NULL;
|
||||
SELECT tool_used, COUNT(*) FROM calls WHERE ts >= CAST(strftime('%s','now','-1 day') AS INTEGER) GROUP BY tool_used ORDER BY 2 DESC;
|
||||
SQL
|
||||
```
|
||||
|
||||
Si `calls_session = 0` → algo esta mal (hook PostToolUse no fire o BD no escribible). Reporta y para.
|
||||
|
||||
Si `mcp_*` / total < 0.4 → estas usando demasiado heredoc/sqlite directo. Reporta como warning.
|
||||
|
||||
### 2. GAP — ¿que funciones faltan?
|
||||
|
||||
Dos fuentes:
|
||||
|
||||
#### 2a. Patrones repetidos en heredocs/Edit
|
||||
|
||||
```sql
|
||||
-- En call_monitor.operations.db
|
||||
SELECT tool_used, COUNT(*) AS hits
|
||||
FROM calls
|
||||
WHERE function_id = ''
|
||||
AND ts >= CAST(strftime('%s','now','-7 days') AS INTEGER)
|
||||
AND tool_used IN ('heredoc_py', 'heredoc_bash', 'sqlite_direct')
|
||||
GROUP BY tool_used;
|
||||
```
|
||||
|
||||
Si `heredoc_py > 5` sin function_id → Claude esta componiendo logica que probablemente debe ser pipeline. Investigar el ultimo heredoc del transcript: si reescribe algo que ya es funcion del registry → violation candidate. Si no, es candidato a pipeline nuevo.
|
||||
|
||||
#### 2b. Trabajo actual de la sesion — gap inferido del contexto
|
||||
|
||||
Lee el ultimo prompt del usuario y los ultimos 10 turnos. Lista funciones que:
|
||||
|
||||
- Has llamado inline (sed/awk/jq custom, transformaciones de datos, parsing).
|
||||
- Has reinventado (HTTP client raw, SQLite open con flags, FS walks).
|
||||
- Has compuesto >2 veces con el mismo shape.
|
||||
|
||||
Para cada candidato:
|
||||
|
||||
```bash
|
||||
# Verifica si ya existe algo similar en el registry
|
||||
mcp__registry__fn_search "<keyword del candidato>"
|
||||
```
|
||||
|
||||
Si NO existe match relevante → candidato a `fn-constructor`.
|
||||
Si existe pero firma incompleta → candidato a `improve_function` (proposal, NO auto-construccion).
|
||||
|
||||
### 3. PROPOSE — lista candidatos
|
||||
|
||||
Genera tabla:
|
||||
|
||||
```
|
||||
| Candidato | Razon | Lenguaje | Dominio | Evidencia (snippet) |
|
||||
|---|---|---|---|---|
|
||||
| <name> | inline_repeated/wrapper_skip/new | go/py/bash | core/infra/... | <heredoc fragment> |
|
||||
```
|
||||
|
||||
Si lista vacia → "no gaps detected, sesion saludable" + reporta metricas. Para.
|
||||
|
||||
### 4. CONSTRUCT — lanza fn-constructor en paralelo
|
||||
|
||||
Para cada candidato, dispara un sub-agente `fn-constructor` con prompt autocontenido:
|
||||
|
||||
```
|
||||
Agent(subagent_type="fn-constructor", prompt=...)
|
||||
```
|
||||
|
||||
Prompts en PARALELO en un mismo mensaje (varios Agent calls). Pasar:
|
||||
- nombre propuesto, lang, domain
|
||||
- firma esperada (params + return)
|
||||
- pureza
|
||||
- descripcion + ejemplo de uso (heredoc real detectado)
|
||||
- nota: "esta funcion la necesita Claude para auto-uso futuro"
|
||||
|
||||
### 5. VALIDATE — ¿la proxima sesion la usara?
|
||||
|
||||
Despues de que fn-constructor termine:
|
||||
|
||||
```bash
|
||||
./fn index 2>&1 | tail -2
|
||||
# Verifica que las nuevas funciones existen
|
||||
for fn in <lista>; do
|
||||
mcp__registry__fn_show "$fn" >/dev/null && echo "OK: $fn" || echo "FAIL: $fn"
|
||||
done
|
||||
```
|
||||
|
||||
Tambien actualiza `call_monitor.copied_code` + `function_stats` corriendo:
|
||||
|
||||
```bash
|
||||
cd "$ROOT/projects/fn_monitoring/apps/call_monitor" && ./call_monitor copied-code && ./call_monitor propose
|
||||
```
|
||||
|
||||
Reporta:
|
||||
- N funciones nuevas creadas (con IDs)
|
||||
- N proposals nuevas en `registry.db.proposals`
|
||||
- Recomendacion al usuario: "proximo turno mencionar/usar `<fn_id>` para validar que el wrapper se invoca correctamente"
|
||||
|
||||
### 6. SELF-TEST — telemetria del propio /fn_claude
|
||||
|
||||
`/fn_claude` mismo debe quedar registrado. Tras ejecutar, query final:
|
||||
|
||||
```bash
|
||||
sqlite3 "$MON" "SELECT COUNT(*) FROM calls WHERE session_id = '${CLAUDE_SESSION_ID:-unknown}' AND ts >= <inicio_comando>"
|
||||
```
|
||||
|
||||
Si la cuenta no aumento → el comando esta operando fuera de la telemetria (bug). Reportar.
|
||||
|
||||
---
|
||||
|
||||
## Reglas duras
|
||||
|
||||
1. **NO ejecutar fn-constructor para algo que ya existe.** Buscar primero via `mcp__registry__fn_search`. Si match relevante → NO crear duplicado.
|
||||
2. **NO crear funciones especulativas.** Cada candidato debe tener evidencia real (snippet de heredoc o llamada inline detectada en esta sesion o en `call_monitor.calls` reciente).
|
||||
3. **PARALELO**: si hay >1 candidato, lanza todos los `fn-constructor` en un solo mensaje con multiples `Agent` calls. NO secuencial.
|
||||
4. **No autonomous merge**: las funciones nuevas viven en el branch local. NO push automatico. Humano revisa y push manual.
|
||||
5. **Limites duros**: max 5 funciones nuevas por invocacion. Si detectas mas, prioriza por evidence weight (`occurrences * recency`) y reporta el resto como pending.
|
||||
6. **Si la sesion no esta siendo registrada (`calls_session = 0`)**: ABORT antes de fase 2. No tiene sentido auto-construir sin telemetria.
|
||||
|
||||
---
|
||||
|
||||
## Output canonico
|
||||
|
||||
```
|
||||
=== /fn_claude — auto-auditoria ===
|
||||
session_id: <id>
|
||||
calls_session: N
|
||||
calls_24h: M (mcp_ratio: 0.XX)
|
||||
violations_24h: K
|
||||
pending_proposals: P (existentes en registry.db)
|
||||
|
||||
GAPS DETECTADOS:
|
||||
1. <name>_<lang>_<domain> — razon — evidencia
|
||||
2. ...
|
||||
|
||||
LANZADOS (en paralelo):
|
||||
fn-constructor #1: <name1> → en progreso
|
||||
fn-constructor #2: <name2> → en progreso
|
||||
...
|
||||
|
||||
VALIDADAS tras ./fn index:
|
||||
✓ <name1>_<lang>_<domain>
|
||||
✓ <name2>_<lang>_<domain>
|
||||
|
||||
PROPOSALS NUEVAS: <count>
|
||||
|
||||
PROXIMO TURNO: menciona `<name1>` para validar wrapper.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cuando usar
|
||||
|
||||
- Al inicio de una sesion larga, para verificar telemetria activa.
|
||||
- A media sesion, cuando notes que estas reescribiendo el mismo bloque.
|
||||
- Antes de cerrar sesion, para capitalizar lo aprendido como funciones reutilizables.
|
||||
- Tras `/autonomous-task` para validar que el orquestador no genero ruido (proposals/funciones huerfanas).
|
||||
|
||||
---
|
||||
|
||||
## Cuando NO usar
|
||||
|
||||
- En sesiones cortas (<5 turnos) — no hay datos suficientes.
|
||||
- Si `call_monitor.operations.db` no esta inicializado (`call_monitor init` primero).
|
||||
- Si el usuario quiere control manual del proceso de extraccion. Este comando es agresivo.
|
||||
@@ -0,0 +1,38 @@
|
||||
# /full-git-pull — Pull automático de fn_registry + sub-repos + submodules + fn sync
|
||||
|
||||
Wrapper sobre el pipeline `full_git_pull_bash_pipelines`. Toda la lógica vive en el registry. Este comando solo ejecuta:
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
./fn run full_git_pull_bash_pipelines
|
||||
```
|
||||
|
||||
## Argumento
|
||||
|
||||
`$ARGUMENTS` — sin uso, ignorar.
|
||||
|
||||
## Qué hace el pipeline
|
||||
|
||||
1. `discover_git_repos_bash_infra` — lista repos locales (mismas exclusiones que push).
|
||||
2. `git_pull_with_stash_bash_infra` por repo: stash si dirty → fetch → pull --ff-only → pop. Estados posibles por repo: `[pulled]`, `[up-to-date]`, `[diverged]`, `[stash-conflict]`.
|
||||
3. `git submodule update --init --recursive` en root.
|
||||
4. `git_pull_with_stash` sobre `~/.password-store`.
|
||||
5. `CGO_ENABLED=1 ./fn index` para regenerar `registry.db`.
|
||||
6. `./fn sync` con credenciales de `pass`.
|
||||
|
||||
## Notas
|
||||
|
||||
- **Modo no-interactivo.** Auto-stash con `--include-untracked`.
|
||||
- **Fast-forward + merge auto.** Si `pull --ff-only` falla por divergencia, el pipeline intenta `git merge --no-ff origin/master`. Si el merge se aplica sin conflictos lo conserva como `[merged-auto]`. Si hay conflictos, aborta el merge y mantiene `[diverged]` para intervencion manual.
|
||||
- **No clona repos faltantes.** Cada PC tiene su subset. Para añadir uno, clonarlo a mano y mirar `pc_locations` para reproducir el path.
|
||||
- Para tocar la lógica: editar las funciones del registry, no este wrapper.
|
||||
|
||||
## Obligaciones del agente
|
||||
|
||||
El pipeline retorna **exit code distinto de 0** si tras los intentos automaticos siguen quedando repos `[diverged]` o `[stash-conflict]`. En ese caso el agente DEBE:
|
||||
|
||||
1. Resolver cada caso manualmente (merge con resolucion de conflicto, `git stash drop` tras revisar, rebase si procede).
|
||||
2. Volver a ejecutar `/full-git-pull` hasta salida limpia.
|
||||
3. Tras `/full-git-pull`, si hubo `[merged-auto]`, ejecutar `/full-git-push` para propagar el merge al remote.
|
||||
|
||||
Regla TBD: master local debe quedar **siempre** alineado con remote y libre de divergencias. Otro PC debe poder hacer `/full-git-pull` y obtener exactamente el mismo estado.
|
||||
@@ -0,0 +1,41 @@
|
||||
# /full-git-push — Push automático de fn_registry + sub-repos + fn sync
|
||||
|
||||
Wrapper sobre el pipeline `full_git_push_bash_pipelines`. Toda la lógica vive en el registry. Este comando solo ejecuta:
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
./fn run full_git_push_bash_pipelines "$ARGUMENTS"
|
||||
```
|
||||
|
||||
## Argumento
|
||||
|
||||
`$ARGUMENTS` — opcional. Mensaje de commit fijo para todos los repos dirty. Sin argumento, el pipeline genera un mensaje automático por repo según los paths cambiados (ver `bash/functions/infra/git_auto_commit_dirty.sh`).
|
||||
|
||||
## Qué hace el pipeline
|
||||
|
||||
1. `discover_git_repos_bash_infra` — lista repos bajo `fn_registry` (excluye `node_modules`, `.venv`, `cpp/vendor`, `cpp/build`, `sources`, `temp`, `subrepos`).
|
||||
2. Auto-inicializa apps/analyses sin `.git` con `ensure_repo_synced_bash_infra` (Gitea `dataforge/<basename>`).
|
||||
3. `scan_secrets_in_dirty_bash_cybersecurity` — aborta si detecta nombres sospechosos (`.env*`, `*credentials*`, `*.key`, `*.pem`, `id_rsa*`, `*secret*`, `*token*.txt`).
|
||||
4. `git_auto_commit_dirty_bash_infra` — commitea cada repo dirty.
|
||||
5. `git_push_if_ahead_bash_infra` — push solo si `rev-list @{u}..HEAD > 0` (sin red previa).
|
||||
6. Push de `~/.password-store` (sin commitear, pass autocommitea).
|
||||
7. `./fn sync` con credenciales cargadas desde `pass`.
|
||||
|
||||
## Notas
|
||||
|
||||
- **Modo no-interactivo por diseño.** Auto-commitea sin preguntar.
|
||||
- **Único motivo de aborto antes de commitear:** secret detectado por nombre.
|
||||
- Si un pre-commit hook bloquea (ej. `audit_uses_functions` con drift), el pipeline reintenta con `--no-verify` para no perder cambios. Los bypasses se reportan en bloque `[!] Hook bypasses` al final.
|
||||
- Si un push es rechazado por non-fast-forward, el pipeline intenta `git merge --no-ff origin/master` automaticamente y vuelve a pushear. Si el merge tiene conflictos, lo aborta y reporta.
|
||||
- Para tocar la lógica: editar las funciones del registry, no este wrapper.
|
||||
|
||||
## Obligaciones del agente
|
||||
|
||||
El pipeline retorna **exit code distinto de 0** si quedan errores reales (commit fallido pese a `--no-verify`, push fallido tras merge auto, etc.) y los lista bajo `[!!] ERRORES`. Cuando esto ocurra el agente DEBE:
|
||||
|
||||
1. Leer cada error reportado y diagnosticar la causa raiz (mira repo + reason).
|
||||
2. Aplicar la correccion correspondiente (resolver merge manual, arreglar permisos, regenerar binario, etc.).
|
||||
3. Volver a invocar `/full-git-push` (o el push manual del repo afectado) hasta que la salida sea limpia y todos los repos esten en `origin/master`.
|
||||
4. Si aparece bloque `[!] Hook bypasses`, abrir despues una rama corta para arreglar la causa raiz (uses_functions drift, etc.) y commitear con hooks activos. No es bloqueante para el push pero es deuda a saldar pronto.
|
||||
|
||||
Regla TBD: master debe quedar **siempre** alineado con remote tras `/full-git-push`. Si tras intervenir manualmente sigue habiendo trabajo pendiente en local, repetir el ciclo.
|
||||
@@ -109,6 +109,296 @@ metabase_update_dashboard(client, dash["id"], dashcards=[
|
||||
|
||||
**Filtros de list_dashboards:** `all`, `mine`, `archived`
|
||||
|
||||
### Dashboards — helpers compositivos (añadir KPIs a dashboard existente)
|
||||
|
||||
Helpers para el flujo tipico "anadir N cards (KPI) al final de un tab existente reusando los mismos filtros que otro card vecino". Evitan los gotchas: replicar `parameter_mappings`, calcular `row` libre, escapado raro de `column_settings`, generacion de `lib/uuid` en MBQL.
|
||||
|
||||
```python
|
||||
from metabase import (
|
||||
metabase_mbql_from_source_card,
|
||||
metabase_copy_dashcard_mappings,
|
||||
metabase_dashboard_next_row,
|
||||
metabase_dashboard_append_row,
|
||||
metabase_viz_column_format,
|
||||
metabase_smartscalar_anothercolumn_viz,
|
||||
)
|
||||
```
|
||||
|
||||
#### `metabase_mbql_from_source_card`
|
||||
|
||||
Construye `dataset_query` MBQL sobre una saved-card (`source-card`), con aggregations + joins + filters + breakouts + segunda stage de expressions. Genera `lib/uuid` automatico en cada nodo.
|
||||
|
||||
```python
|
||||
dq = metabase_mbql_from_source_card(
|
||||
database_id=6,
|
||||
source_card_id=5305,
|
||||
aggregations=[
|
||||
{"op": "sum", "field": "PrecioVenta", "base_type": "type/Decimal"},
|
||||
{"op": "sum", "field": "PrecioCompra", "base_type": "type/Decimal"},
|
||||
{"op": "sum", "field": "PrecioTasas", "base_type": "type/Float"},
|
||||
],
|
||||
joins=[
|
||||
{"alias": "Centros - idCentro", "source_card_id": 4076,
|
||||
"fields": "none", "local_field": "idCentro", "local_base_type": "type/Text",
|
||||
"foreign_field_id": 17316, "foreign_base_type": "type/Text"},
|
||||
],
|
||||
filters=[["not-empty", {}, ["field", {"base-type": "type/Text"},
|
||||
"Centros - idCentro__Companies__name"]]],
|
||||
expressions=[
|
||||
{"name": "MasadeMargen", "expr":
|
||||
{"op": "-", "args": [{"field": "sum"},
|
||||
{"op": "+", "args": [{"field": "sum_2"}, {"field": "sum_3", "base_type": "type/Float"}]}]}},
|
||||
{"name": "Margen", "expr":
|
||||
{"op": "coalesce", "args": [
|
||||
{"op": "/", "args": [
|
||||
{"op": "-", "args": [{"field": "sum"},
|
||||
{"op": "+", "args": [{"field": "sum_2"}, {"field": "sum_3", "base_type": "type/Float"}]}]},
|
||||
{"field": "sum"}]},
|
||||
0]}},
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
Ops soportadas en expressions: `+`, `-`, `*`, `/`, `coalesce`, `case`. Referencia a otra expresion en la misma stage: `{"ref": "Margen"}`. Aliases de aggregations son posicionales: `sum`, `sum_2`, `sum_3`... (orden = declaracion).
|
||||
|
||||
#### `metabase_copy_dashcard_mappings`
|
||||
|
||||
Copia los `parameter_mappings` de un dashcard "donante" a un card nuevo. Devuelve lista lista para pegar en `dashcards_add`.
|
||||
|
||||
```python
|
||||
mappings = metabase_copy_dashcard_mappings(
|
||||
client,
|
||||
dashboard_id=734,
|
||||
source_card_id=9918, # card donante con 18 filtros mapeados
|
||||
dest_card_id=9947, # card destino nueva
|
||||
)
|
||||
# Devuelve [{"parameter_id","card_id","target"}, ...] con card_id=9947
|
||||
```
|
||||
|
||||
#### `metabase_dashboard_next_row`
|
||||
|
||||
Calcula el primer `row` libre al final de un tab.
|
||||
|
||||
```python
|
||||
row = metabase_dashboard_next_row(client, dashboard_id=734, tab_id=191)
|
||||
# row=12 si el ultimo card termina en row+size_y=12
|
||||
# tab_id=0 → dashboards sin tabs
|
||||
```
|
||||
|
||||
#### `metabase_dashboard_append_row`
|
||||
|
||||
Combo: append N cards en una fila horizontal al final del tab, copiando mappings de un donante. Una sola llamada hace `next_row` + grid math + `copy_mappings` + `update_dashboard_safe`.
|
||||
|
||||
```python
|
||||
metabase_dashboard_append_row(
|
||||
client,
|
||||
dashboard_id=734,
|
||||
tab_id=191,
|
||||
card_ids=[9947, 9948, 9949],
|
||||
height=4,
|
||||
donor_card_id=9918, # mismos 18 filtros del dashboard
|
||||
grid_width=24, # default Metabase v0.59
|
||||
)
|
||||
# Coloca 3 cards de size_x=8 en row=next, cols 0/8/16, con mappings copiados
|
||||
```
|
||||
|
||||
#### `metabase_viz_column_format`
|
||||
|
||||
Construye una entrada de `column_settings` con la clave JSON-escaped (`'["name","Margen"]'`) sin tener que recordar el formato exacto.
|
||||
|
||||
```python
|
||||
metabase_viz_column_format("Margen", number_style="percent", decimals=2)
|
||||
# {'["name","Margen"]': {"number_style": "percent", "decimals": 2}}
|
||||
|
||||
metabase_viz_column_format("MasadeMargen", number_style="currency",
|
||||
currency="EUR", decimals=0, currency_in_header=False)
|
||||
# {'["name","MasadeMargen"]': {...}}
|
||||
```
|
||||
|
||||
Mergea varios resultados en `column_settings` de las visualization_settings.
|
||||
|
||||
#### `metabase_smartscalar_anothercolumn_viz`
|
||||
|
||||
Construye `visualization_settings` completo para `display=smartscalar` con comparativa tipo `anotherColumn` (compara dos columnas de la misma fila — no requiere breakout temporal).
|
||||
|
||||
```python
|
||||
viz = metabase_smartscalar_anothercolumn_viz(
|
||||
main_column="Margen",
|
||||
compare_column="Margen_N1",
|
||||
label="vs N-1",
|
||||
number_style="percent",
|
||||
decimals=2,
|
||||
)
|
||||
# Setear en /api/card via PUT visualization_settings=viz
|
||||
```
|
||||
|
||||
**⚠ Gotcha smartscalar Metabase v0.59:** el visualization solo acepta `type: "anotherColumn"` cuando la query NO produce filas multiples. Si Metabase muestra el error *"Agrupa solo por un campo de tiempo para ver como ha cambiado con el tiempo"*, hace falta un **breakout temporal** en la MBQL (ej. `breakouts=[{"field":"fecha","base_type":"type/Date","temporal_unit":"month"}]`) y usar el comparison `previousValue` en lugar de `anotherColumn`. Alternativa: `metabase_smartscalar_kpi_sql` + `metabase_smartscalar_kpi_payload` (patron 2-row nativo) si la card es SQL nativo.
|
||||
|
||||
#### Patron canonico — anadir 3 KPI cards a tab existente
|
||||
|
||||
```python
|
||||
import os, sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from metabase import (
|
||||
MetabaseClient, metabase_create_card, metabase_mbql_from_source_card,
|
||||
metabase_dashboard_append_row, metabase_viz_column_format,
|
||||
metabase_smartscalar_anothercolumn_viz,
|
||||
)
|
||||
|
||||
c = MetabaseClient("https://reports.autingo.es", os.environ["MB_API_KEY"])
|
||||
|
||||
# 1) MBQL reusando una saved-card como source
|
||||
def query():
|
||||
return metabase_mbql_from_source_card(
|
||||
database_id=6, source_card_id=5305,
|
||||
aggregations=[
|
||||
{"op":"sum","field":"PrecioVenta","base_type":"type/Decimal"},
|
||||
{"op":"sum","field":"PrecioCompra","base_type":"type/Decimal"},
|
||||
{"op":"sum","field":"PrecioTasas","base_type":"type/Float"},
|
||||
],
|
||||
# joins/filters/expressions ...
|
||||
)
|
||||
|
||||
# 2) Crear cards
|
||||
card1 = metabase_create_card(c, "Masa de Margen", query(),
|
||||
display="scalar", collection_id=500)
|
||||
viz1 = {"scalar.field": "MasadeMargen",
|
||||
"column_settings": metabase_viz_column_format(
|
||||
"MasadeMargen", number_style="currency", currency="EUR", decimals=0)}
|
||||
c._http.request("PUT", f"/api/card/{card1['id']}", json={"visualization_settings": viz1})
|
||||
|
||||
card2 = metabase_create_card(c, "Margen", query(), display="smartscalar", collection_id=500)
|
||||
viz2 = metabase_smartscalar_anothercolumn_viz(
|
||||
main_column="Margen", compare_column="Margen_N1", number_style="percent", decimals=2)
|
||||
c._http.request("PUT", f"/api/card/{card2['id']}", json={"visualization_settings": viz2})
|
||||
|
||||
# 3) Append fila al tab con mappings copiados del donante
|
||||
metabase_dashboard_append_row(
|
||||
c, dashboard_id=734, tab_id=191,
|
||||
card_ids=[card1["id"], card2["id"]],
|
||||
height=4, donor_card_id=9918,
|
||||
)
|
||||
```
|
||||
|
||||
### Documents (ProseMirror)
|
||||
|
||||
Los "documents" son páginas narrativas editables con texto rico y cards embebidas. **No hay helpers en fn_registry todavía** — usa el endpoint REST directamente a través de `client._http`.
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
| Método | Ruta | Qué hace |
|
||||
|--------|------|---------|
|
||||
| GET | `/api/document` | Lista documents (`{items: [...]}`) |
|
||||
| GET | `/api/document/{id}` | Lee un document (incluye `document` con árbol ProseMirror) |
|
||||
| POST | `/api/document` | Crea. Payload: `{name, collection_id, document}` |
|
||||
| PUT | `/api/document/{id}` | Actualiza. Mismo payload que POST |
|
||||
| PUT | `/api/document/{id}` con `{archived: true}` | Soft-delete |
|
||||
|
||||
```python
|
||||
# Crear documento
|
||||
resp = client._http.request("POST", "/api/document", json={
|
||||
"name": "Mi análisis",
|
||||
"collection_id": 583, # obligatorio — raíz no se acepta desde API
|
||||
"document": {"type": "doc", "content": [
|
||||
{"type": "heading", "attrs": {"level": 1}, "content": [{"type": "text", "text": "Título"}]},
|
||||
{"type": "paragraph", "content": [{"type": "text", "text": "Cuerpo."}]},
|
||||
]},
|
||||
})
|
||||
doc_id = resp.json()["id"]
|
||||
print(f"https://reports.autingo.es/document/{doc_id}")
|
||||
```
|
||||
|
||||
#### Tipos de nodo SOPORTADOS en Metabase v0.59.x
|
||||
|
||||
Solo estos tipos renderizan. **Cualquier tipo fuera de esta lista hace que el documento se vea vacío al abrirlo.**
|
||||
|
||||
```python
|
||||
ALLOWED_DOC_NODES = {
|
||||
"doc", "heading", "paragraph", "text",
|
||||
"horizontalRule", "blockquote",
|
||||
"bulletList", "listItem",
|
||||
"codeBlock", # attrs.language ej: "sql"
|
||||
"resizeNode", # envuelve SIEMPRE a cardEmbed
|
||||
"cardEmbed", # solo dentro de resizeNode
|
||||
}
|
||||
```
|
||||
|
||||
Marcas inline válidas en nodos `text`: `bold`, `italic`, `code`, `strike` (se aplican con `"marks": [{"type": "bold"}, ...]`).
|
||||
|
||||
#### Tipos PROHIBIDOS (rompen el render)
|
||||
|
||||
- `table`, `tableRow`, `tableHeader`, `tableCell` → en v0.59.x no están registrados en el schema del editor y el doc entero se vuelve invisible.
|
||||
- `callout` → idem (documentado en memoria `feedback_metabase_prosemirror.md`).
|
||||
- `image`, `video`, `iframe`, `mention`, cualquier embed de terceros → no registrados.
|
||||
|
||||
Si necesitas una tabla, **emúlala con una `bulletList` de `**clave:** valor`**:
|
||||
|
||||
```python
|
||||
def kv_list(pairs):
|
||||
return {"type": "bulletList", "content": [
|
||||
{"type": "listItem", "content": [
|
||||
{"type": "paragraph", "content": [
|
||||
{"type": "text", "text": k, "marks": [{"type": "bold"}]},
|
||||
{"type": "text", "text": f": {v}"},
|
||||
]},
|
||||
]}
|
||||
for k, v in pairs
|
||||
]}
|
||||
```
|
||||
|
||||
#### cardEmbed SIEMPRE dentro de resizeNode
|
||||
|
||||
Un `cardEmbed` suelto no renderiza. Patrón obligatorio:
|
||||
|
||||
```python
|
||||
def card_embed(card_id, height=420):
|
||||
import uuid
|
||||
return {
|
||||
"type": "resizeNode",
|
||||
"attrs": {"height": height, "minHeight": 280},
|
||||
"content": [{
|
||||
"type": "cardEmbed",
|
||||
"attrs": {"id": card_id, "name": None, "_id": str(uuid.uuid4())},
|
||||
}],
|
||||
}
|
||||
```
|
||||
|
||||
#### Validación OBLIGATORIA antes de POST/PUT
|
||||
|
||||
Nunca envíes un document a Metabase sin validar primero. Un solo nodo prohibido lo deja invisible sin devolver error HTTP:
|
||||
|
||||
```python
|
||||
ALLOWED = {"doc","heading","paragraph","text","horizontalRule","blockquote",
|
||||
"bulletList","listItem","codeBlock","resizeNode","cardEmbed"}
|
||||
|
||||
def validate_doc(node, path=""):
|
||||
errs = []
|
||||
if isinstance(node, dict):
|
||||
typ = node.get("type", "?")
|
||||
if typ not in ALLOWED:
|
||||
errs.append(f"{path}: tipo no permitido '{typ}'")
|
||||
if typ == "resizeNode":
|
||||
inner = node.get("content", [])
|
||||
if not (len(inner) == 1 and inner[0].get("type") == "cardEmbed"):
|
||||
errs.append(f"{path}: resizeNode debe contener exactamente un cardEmbed")
|
||||
return errs # no re-descender al cardEmbed interno
|
||||
for i, c in enumerate(node.get("content", []) or []):
|
||||
errs += validate_doc(c, f"{path}/{typ}[{i}]")
|
||||
return errs
|
||||
|
||||
errs = validate_doc(my_doc)
|
||||
assert not errs, f"Doc inválido:\n" + "\n".join(f" - {e}" for e in errs)
|
||||
```
|
||||
|
||||
#### Aprender estructura de un doc que ya funciona
|
||||
|
||||
Si dudas sobre un nodo, **clónalo de un doc existente que renderice**:
|
||||
|
||||
```python
|
||||
d = client._http.request("GET", "/api/document/2").json()
|
||||
# d["document"] contiene el árbol completo en ProseMirror
|
||||
```
|
||||
|
||||
### Databases
|
||||
|
||||
```python
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# /new-cpp-app — Crear app C++ nueva con scaffolder estandar
|
||||
|
||||
Wrapper sobre el pipeline `init_cpp_app_bash_pipelines`. Genera la estructura canonica que cumple `cpp/PATTERNS.md` y `.claude/rules/cpp_apps.md` (main.cpp con `cfg.about/log/panels`, sin `app_menubar` manual, dockspace via framework), registra la app en `cpp/CMakeLists.txt`, crea repo Gitea `dataforge/<name>` y ejecuta `fn index`.
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
./fn run init_cpp_app $ARGUMENTS
|
||||
```
|
||||
|
||||
## Uso
|
||||
|
||||
```
|
||||
/new-cpp-app <name> [--project <p>] [--domain <d>] [--desc "..."] [--tags "a,b"]
|
||||
```
|
||||
|
||||
## Ejemplos
|
||||
|
||||
```bash
|
||||
# App suelta en cpp/apps/<name>/
|
||||
/new-cpp-app my_tool --desc "Herramienta para X"
|
||||
|
||||
# App dentro de un proyecto
|
||||
/new-cpp-app finance_panel --project budget --desc "Panel de finanzas" --tags "finance,dashboard"
|
||||
```
|
||||
|
||||
## Que genera
|
||||
|
||||
```
|
||||
<dir>/
|
||||
main.cpp # Plantilla canonica: panels[] + cfg.about + cfg.log + run_app(cfg, render)
|
||||
CMakeLists.txt # add_imgui_app(<name> main.cpp)
|
||||
app.md # Frontmatter completo (lang:cpp, framework:imgui, dir_path, repo_url)
|
||||
```
|
||||
|
||||
Mas registro en `cpp/CMakeLists.txt`, repo Gitea con commit inicial, y `fn index` para que aparezca en `registry.db`.
|
||||
|
||||
## Despues de crear
|
||||
|
||||
1. Editar `app.md` y completar `uses_functions` cuando la app consuma funciones del registry.
|
||||
2. Anadir las funciones al `CMakeLists.txt` como paths absolutos: `${CMAKE_SOURCE_DIR}/functions/<dom>/<func>.cpp`.
|
||||
3. Build: `/compile <name>` o `cd cpp && cmake --build build --target <name> -j`.
|
||||
|
||||
## Cuando NO usar
|
||||
|
||||
NUNCA — esta es la unica via para crear apps C++ nuevas. Si el scaffolder no cubre un caso, modificar la plantilla en `bash/functions/pipelines/init_cpp_app.sh`. Escribir `main.cpp + CMakeLists.txt + app.md` a mano esta prohibido por `.claude/rules/cpp_apps.md`.
|
||||
|
||||
## Auditoria post-creacion
|
||||
|
||||
```
|
||||
fn doctor cpp-apps
|
||||
```
|
||||
|
||||
Lista apps que se desvian del estandar (sin `cfg.about`, con `app_menubar` manual, dockspace duplicado, etc.).
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
description: "Recordatorio operativo para usar subagentes fn (constructor/executor/recopilador/analizador/mejorador) y paralelizar trabajo independiente"
|
||||
---
|
||||
|
||||
# /subagentes — usa subagentes fn y paraleliza
|
||||
|
||||
Recuerda: antes de escribir codigo nuevo o ejecutar pipelines en serie, **delega a subagentes** y **paraleliza** llamadas independientes (un mensaje, varios `Agent` calls).
|
||||
|
||||
---
|
||||
|
||||
## Mapa de subagentes fn (ciclo reactivo)
|
||||
|
||||
| Fase | Agente | Cuando dispararlo |
|
||||
|---|---|---|
|
||||
| 1 CONSTRUIR | `fn-constructor` | Falta funcion/tipo/test reutilizable. NUNCA escribir inline en `apps/` si es reutilizable |
|
||||
| 2 EJECUTAR | `fn-executor` | Correr pipeline/funcion del registry + registrar ejecucion en `operations.db` |
|
||||
| 3 RECOPILAR | `fn-recopilador` | Auditar integridad de `operations.db`. Modo `design-e2e <app>` propone bloque `e2e_checks` |
|
||||
| 4 ANALIZAR | `fn-analizador` | Ejecutar `e2e_checks` de `app.md`, veredicto pass/fail, persistir en `e2e_runs` |
|
||||
| 5 MEJORAR | `fn-mejorador` | Convertir fallos de `e2e_runs` en `proposals` con evidencia trazable |
|
||||
| 6 META | `fn-orquestador` | Recorrer fases 1-5 solo hasta convergencia. Sandbox `auto/<issue>`. Issue 0069 |
|
||||
|
||||
**Pre-condiciones de `fn-orquestador`** (abortara si no se cumplen):
|
||||
- Migration `fn_operations/migrations/006_task_runs.sql` aplicada
|
||||
- Issue con criterios de aceptacion **verificables programaticamente** (no "funciona bien")
|
||||
- `master` local up-to-date con `origin/master`
|
||||
- Branch `auto/<issue>` NO existe ya (limpiar previo si hace falta)
|
||||
- `gh` autenticado (PR draft al converger)
|
||||
- Tipo soportado: `feature_app_simple`, `bugfix_with_repro`, `refactor_safe`, `add_e2e_check`
|
||||
|
||||
**Aislamiento por worktree**: cada run crea `/tmp/fn_orq_<issue>_<ts>/` via `git worktree add`. Working tree principal del usuario queda intacto. N orquestadores paralelos = N worktrees independientes. `task_runs` persiste en BD del repo principal (auditoria sobrevive aunque borres worktree).
|
||||
|
||||
## Otros subagentes utiles
|
||||
|
||||
- `Explore` — busquedas amplias en codebase (>3 queries) sin contaminar contexto principal
|
||||
- `general-purpose` — research multi-step open-ended
|
||||
|
||||
## Reglas duras
|
||||
|
||||
1. **Paralelo real**: tareas independientes → un mensaje con varios `Agent` calls. NO en serie.
|
||||
2. **Briefing autocontenido**: subagente no ve historial. Pasar paths absolutos, IDs, criterio exito.
|
||||
3. **No delegar comprension**: nada de "haz lo que veas". Especificar que cambiar, donde, por que.
|
||||
4. **Verificar output**: leer diff/resultado, no confiar en resumen del subagente.
|
||||
5. **No duplicar**: si delegas research, no lo repitas tu.
|
||||
|
||||
## Patrones canonicos de paralelismo
|
||||
|
||||
- 3 funciones de registry independientes → 3 `fn-constructor` en paralelo
|
||||
- Auditar N apps → N `fn-recopilador` en paralelo
|
||||
- Validar varias apps → N `fn-analizador` en paralelo
|
||||
- Build cpp + tests py + audit operations.db → 3 calls paralelos
|
||||
- Tras `fn-analizador` con fallos → `fn-mejorador` por cada `run_id`
|
||||
- Tarea multi-fase autonoma (issue con criterios verificables) → `fn-orquestador` (1 sola run, NO recursivo)
|
||||
|
||||
## Anti-patrones
|
||||
|
||||
- Escribir funcion reutilizable inline en `apps/` (debe ir a `functions/` via `fn-constructor`)
|
||||
- Lanzar subagentes en serie cuando son independientes
|
||||
- Prompt de 1 linea sin contexto ("arregla esto")
|
||||
- Invocar subagente y luego hacer tu mismo el trabajo
|
||||
- Spawn `fn-orquestador` sin migration 006 o sin issue verificable (abortara)
|
||||
- `fn-orquestador` recursivo (un orquestador no spawn-ea otro)
|
||||
|
||||
## Checklist pre-respuesta
|
||||
|
||||
- ¿>1 tarea independiente? → paralelizar
|
||||
- ¿Hace falta funcion/tipo nuevo? → `fn-constructor`, NO inline
|
||||
- ¿Hay que ejecutar/auditar/validar? → fase 2/3/4 segun toque
|
||||
- ¿`e2e_runs` con fallos? → `fn-mejorador`
|
||||
- ¿Issue con criterios verificables + tipo soportado? → `fn-orquestador` (chequear pre-condiciones)
|
||||
- ¿Research amplio (>3 queries)? → `Explore`
|
||||
|
||||
## Plantilla minima de prompt para subagente
|
||||
|
||||
```
|
||||
Contexto: <que repo, que app, que objetivo>
|
||||
Input: <paths absolutos, IDs registry, run_id si aplica>
|
||||
Tarea: <accion concreta y acotada>
|
||||
Criterio exito: <como sabe que termino>
|
||||
Limites: <que NO debe tocar>
|
||||
Telemetria: tus tool calls quedan registradas en projects/fn_monitoring/apps/call_monitor/operations.db
|
||||
via hook PostToolUse heredado de settings.local.json. Sigue patrones canonicos
|
||||
(mcp__registry__fn_*, ./fn run, heredoc importando) — los antipatrones se loguean
|
||||
como violations.
|
||||
```
|
||||
|
||||
## Telemetria heredada (issue 0085 hardening 5)
|
||||
|
||||
Los hooks de `.claude/settings.local.json` se heredan automaticamente por cada sub-agente que Claude Code lance via la tool `Agent`. Eso significa:
|
||||
|
||||
- Cada Bash, Edit, Write, MultiEdit, `mcp__registry__*` del sub-agente dispara `hook_call_monitor.sh` exactamente igual que en la sesion principal.
|
||||
- El `session_id` del JSON de input del hook viene del sub-agente, distinto al de la sesion padre. Util para auditar comportamiento por agente.
|
||||
- Las violations detectadas (sqlite3 directo, heredoc reinventando, etc) cuentan tambien para sub-agentes — un `fn-constructor` que reescribe inline en lugar de delegar a otro `fn-constructor` queda registrado.
|
||||
- `FN_TELEMETRY=1` esta en el `env` block de settings.local.json — los heredocs Python/Bash de sub-agentes ya tienen wrappers activos automaticamente.
|
||||
|
||||
Implicacion: NO necesitas pasar flags `--telemetry` a sub-agentes. Solo asegurate de que el prompt sigue patrones canonicos. La regla `.claude/rules/registry_calls.md` se aplica igual.
|
||||
|
||||
Si un sub-agente abre un proceso hijo que escapa al hook (ej. `nohup ... &`, daemons), ese subproceso queda fuera de la telemetria — documentalo en el prompt si es un caso valido.
|
||||
@@ -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` — `<app_id>` o `<dir_path>`. 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/<X>/` o `projects/*/apps/<X>/`); 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: <STATUS> (run_id: <RUN_ID>)
|
||||
<P>/<T> checks pass, <W> warn, <F> fail
|
||||
Fase 5 MEJORAR: <N> proposals creadas: <PROPOSAL_IDS>
|
||||
|
||||
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='<RUN_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/<app>_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.
|
||||
@@ -17,3 +17,20 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
||||
| 11 | [sources.md](sources.md) | Extraccion de funciones desde repos externos |
|
||||
| 12 | [notebook_collaboration.md](notebook_collaboration.md) | Colaboración en notebooks Jupyter via funciones del registry |
|
||||
| 13 | [frontend_theming.md](frontend_theming.md) | Componentes propios y sistema de temas en frontends |
|
||||
| 14 | [deploy.md](deploy.md) | Deploy de apps a VPS remotos via SSH + systemd + rsync |
|
||||
| 15 | [projects.md](projects.md) | Projects: agrupar apps, analysis y vaults bajo un tema |
|
||||
| 16 | [kiss.md](kiss.md) | KISS en proyectos y apps: cuestionar herramientas externas, sin abstracciones especulativas |
|
||||
| 17 | [apps_tbd.md](apps_tbd.md) | Trunk-based development obligatorio en apps generadas con `fn` (registry exento) |
|
||||
| 18 | [uses_functions.md](uses_functions.md) | Convencion de uses_functions para C++: el .md del consumidor declara las dependencias |
|
||||
| 19 | [cpp_apps.md](cpp_apps.md) | Estandarizacion de apps C++: estructura, CMake, app.md, sub-repo, runtime — apunta a cpp/PATTERNS.md y cpp/DESIGN_SYSTEM.md como autoritativas |
|
||||
| 20 | [artefactos.md](artefactos.md) | Termino paraguas para apps, analysis, vaults, projects y playgrounds (todo lo que no es codigo reutilizable) |
|
||||
| 21 | [playgrounds.md](playgrounds.md) | Prototipos rapidos dentro de un artefacto padre — heredan entorno, no se indexan, no tienen repo propio |
|
||||
| 22 | [registry_first.md](registry_first.md) | Antes de escribir codigo en un artefacto: buscar en el registry, reutilizar si existe, delegar a `fn-constructor` si falta |
|
||||
| 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 |
|
||||
| 27 | [registry_calls.md](registry_calls.md) | Patrones canonicos para invocar funciones del registry (MCP inspect / MCP run / heredoc compose), antipatrones, excepciones, telemetria. Issue 0085 |
|
||||
| 28 | [delegation.md](delegation.md) | Si vas a escribir logica reutilizable inline -> spawn fn-constructor inmediato + tag de grupo + usar en mismo turno. Issue 0086 |
|
||||
| 29 | [capability_groups.md](capability_groups.md) | Tags planos + paginas madre `docs/capabilities/<grupo>.md` para desbloquear clusters de funciones en un read. Issue 0086 |
|
||||
| 30 | [function_growth_and_self_docs.md](function_growth_and_self_docs.md) | Contrato self-doc de cada `.md` (Ejemplo + Cuando usarla + Gotchas + Growth log) + crecimiento del registry por **promocion de composiciones** a pipelines, NO por inflado de funciones. Issue 0087 |
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
## Trunk-based development (TBD) en apps generadas con `fn`
|
||||
|
||||
**El registry NO usa TBD** (push directo a master OK). Pero **toda app generada con `fn`** que viva en `apps/`, `projects/<name>/apps/` o que se despliegue a un VPS via `deploy_server` **DEBE seguir TBD** mientras se desarrolla.
|
||||
|
||||
**Tronco unico: `master`** en todos los repos `dataforge/<name>` del ecosistema (apps + analyses). Ver ADR 0002. El default de `git init` debe estar en `master` (`git config --global init.defaultBranch master`) — los pipelines de scaffolding y `ensure_repo_synced_bash_infra` ya pasan `master` explicitamente.
|
||||
|
||||
```
|
||||
master ← siempre deployable
|
||||
↑
|
||||
└── issue/<NNNN>-<slug> ← rama efimera (horas)
|
||||
└── quick/<slug> ← cambios rapidos sin issue
|
||||
commits atomicos (feat:, fix:, test:, docs:, refactor:, chore:)
|
||||
merge --no-ff → master → push → delete branch
|
||||
```
|
||||
|
||||
### Reglas
|
||||
|
||||
1. **Nunca trabajar directo en master para una app**. Crear `issue/<NNNN>-<slug>` o `quick/<slug>` primero.
|
||||
2. **Commits atomicos** por bloque logico (no WIP, no mezclar tipos).
|
||||
3. **Tests obligatorios** antes de mergear (los que aplique al stack: ctest/go test/pytest/...).
|
||||
4. **`merge --no-ff`** preserva la historia paralela. `git log --first-parent master` da la vista limpia.
|
||||
5. **Feature flags** (no WIP) cuando una feature no cabe en una sola rama. Archivo: `dev/feature_flags.json`. Detalle: `feature_flags.md`.
|
||||
|
||||
### Que hacer cuando aparece WIP en el working tree
|
||||
|
||||
Doctrina TBD: **master siempre desplegable**. Si tras implementar un issue queda codigo a medias en otros archivos (modificado pero no terminado), HAY DOS opciones legales:
|
||||
|
||||
| Caso | Accion |
|
||||
|---|---|
|
||||
| WIP no relacionado al issue, pequeño, ya estable (ej. null-guards de un bug menor) | Incluirlo en el commit del issue **solo si compila + tests pasan**. Mencionarlo en el cuerpo del commit. |
|
||||
| WIP relacionado al issue pero incompleto | Envolver en feature flag OFF (`enabled: false` en `dev/feature_flags.json`). Mergear codigo terminado y testeado. Activar flag en commit posterior. |
|
||||
| WIP de otra feature distinta, no terminada | NO mergear con el issue. `git stash` o crear `issue/<otro>-...` para llevarlo aparte. NO romper master. |
|
||||
| Pre-existing failing tests (no causados por la rama) | Documentar en cuerpo del commit/PR. Crear issue separado para el fix. NO bloquea merge si tu cambio no los introduce. |
|
||||
|
||||
**Regla de oro:** ningun commit pusheado a master debe romper el deployment. Si el codigo no esta terminado pero compila + pasa tests, viaja detras de un flag OFF. Si rompe, no sale.
|
||||
|
||||
### Por que el registry esta exento
|
||||
|
||||
El registry es un repo de funciones reutilizables, no un servicio en produccion. Los cambios son atomicos por su propia naturaleza (una funcion = uno o dos archivos). Imponer TBD a cada `fn add` añadiria fricion sin ganancia: la BD se regenera con `fn index`, no hay deployment, no hay usuarios consumiendo master en directo.
|
||||
|
||||
### Cuando aplica TBD
|
||||
|
||||
| Cambio | TBD obligatorio |
|
||||
|---|---|
|
||||
| Funcion nueva en `cpp/functions/`, `python/functions/`, etc. | NO — push directo a master |
|
||||
| Tipo nuevo en `types/` | NO |
|
||||
| Doc/regla en `.claude/`, `docs/` | NO |
|
||||
| Issue del registry mismo (`dev/issues/`) | NO — issue cerrado y push directo |
|
||||
| App nueva o modificacion de app en `apps/` o `projects/*/apps/` | **SI** |
|
||||
| Service desplegable (`tag: service`) | **SI** |
|
||||
| Analysis en `analysis/` o `projects/*/analysis/` | NO — son exploraciones efimeras |
|
||||
|
||||
### Comandos
|
||||
|
||||
- `/git-branch` — crea rama desde master actualizado (para apps).
|
||||
- `/git-push` — tests → merge `--no-ff` → push → eliminar rama (para apps).
|
||||
|
||||
Para el registry, push directo a master con commits atomicos.
|
||||
@@ -7,3 +7,12 @@ Criterios para decidir:
|
||||
- **apps/**: orquesta funciones del registry para un caso concreto, tiene config/credenciales, layout fijo
|
||||
|
||||
Las apps Python importan funciones del registry con: `sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))` y luego `from <paquete> import ...` (sin prefijo `functions.`).
|
||||
|
||||
## temp/ — workspace efimero
|
||||
|
||||
`temp/` es un espacio de trabajo desechable para pruebas rapidas: probar una API, un script exploratorio, un analisis puntual, prototipos. Todo gitignored.
|
||||
|
||||
- **NO es codigo del registry** — nada en `temp/` se indexa ni se versiona
|
||||
- **Estructura libre** — subcarpetas por tema: `temp/api_test/`, `temp/quick_analysis/`, etc.
|
||||
- **Extraccion**: si algo en `temp/` resulta util, se extrae al registry con el flujo normal (como si fuera `sources/`)
|
||||
- **Limpieza**: se puede borrar el contenido en cualquier momento sin consecuencias
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
## Artefactos: termino colectivo
|
||||
|
||||
**"Artefacto"** es el termino paraguas para todo lo que vive en el registry pero NO es codigo reutilizable de `functions/` o `types/`. Sirve para no repetir "apps, analysis, vaults, projects, playgrounds" cada vez.
|
||||
|
||||
Tipos de artefacto:
|
||||
|
||||
| Tipo | Donde vive | Indexado en registry.db | Repo Gitea propio |
|
||||
|---|---|---|---|
|
||||
| **app** | `apps/`, `cpp/apps/`, `projects/<p>/apps/<a>/` | tabla `apps` | si (`dataforge/<a>`) |
|
||||
| **analysis** | `analysis/<t>/`, `projects/<p>/analysis/<t>/` | tabla `analysis` | si (`dataforge/<t>`) |
|
||||
| **vault** | `projects/<p>/vaults/<v>` (symlink) | tabla `vaults` | no (datos fuera del repo) |
|
||||
| **project** | `projects/<p>/` | tabla `projects` | no (vive dentro de fn_registry) |
|
||||
| **playground** | `<artefacto_padre>/playground/` | NO se indexa | no (vive dentro del padre) |
|
||||
|
||||
Caracteristicas comunes de los artefactos:
|
||||
- NO son codigo reutilizable. La reutilizacion vive en `functions/`.
|
||||
- Tienen ciclo de vida propio (crear, modificar, archivar, borrar).
|
||||
- `pc_locations` los unifica via `entity_type` (app, analysis, project, vault).
|
||||
- Pueden importar funciones del registry; el registry NUNCA importa de un artefacto.
|
||||
|
||||
### Cuando usar el termino
|
||||
|
||||
Usa "artefacto" cuando hablas de varios tipos a la vez o cuando la afirmacion aplica a todos:
|
||||
|
||||
- "Cada artefacto declara sus funciones del registry en su `.md`" (vale para apps y analyses).
|
||||
- "Los artefactos no se importan desde `functions/`."
|
||||
- "Esta regla aplica a cualquier artefacto desplegable" (apps + services).
|
||||
|
||||
Cuando hables de UN tipo concreto, usa el nombre concreto: "esta app...", "este analysis...". No abuses del termino paraguas — es para evitar listas, no para difuminar.
|
||||
|
||||
### Que NO es un artefacto
|
||||
|
||||
- `functions/`, `python/functions/`, `bash/functions/`, `frontend/functions/` — codigo reutilizable.
|
||||
- `types/`, `python/types/`, `frontend/types/` — tipos del registry.
|
||||
- `sources/` — repos externos clonados para extraer funciones (gitignored).
|
||||
- `temp/` — workspace efimero, ni siquiera versionado.
|
||||
- `subrepos/` — espejos de repos externos para referencia.
|
||||
|
||||
### Relacion con `pc_locations`
|
||||
|
||||
Los artefactos con presencia en disco (app, analysis, project, vault) ya estan unificados en `pc_locations` via la columna `entity_type`. Los **playgrounds** NO entran en `pc_locations` porque son hijos de otro artefacto y se mueven con el (no tienen identidad propia entre PCs).
|
||||
@@ -0,0 +1,60 @@
|
||||
## Capability groups: tags + paginas madre en docs/capabilities/
|
||||
|
||||
Un **capability group** es un cluster de >=3 funciones del registry que comparten un dominio operativo (ej. `notebook`, `metabase`, `deploy`). Cada grupo tiene un **tag plano** (sin prefijo) y una **pagina madre** en `docs/capabilities/<grupo>.md`. La pagina madre desbloquea el conjunto entero en un solo read.
|
||||
|
||||
### Para que existen
|
||||
|
||||
Sin grupos, Claude redescubre funciones via FTS5 una a una cada sesion ("¿como interactuo con Jupyter? ¿como subo deploy?"). Con grupos, Claude lee `docs/capabilities/<grupo>.md` y carga las 5-10 funciones del cluster con su ejemplo canonico — menos turnos perdidos en discovery.
|
||||
|
||||
### Convencion de tag
|
||||
|
||||
- **Slug del grupo** = tag plano. Ej: `notebook`, `metabase`, `android-emu`.
|
||||
- **No prefijos** (`cap:`, `group:`). Ya hay namespacing implicito porque convivirian con tags semanticos sueltos.
|
||||
- **Una funcion puede llevar varios tags de grupo** si pertenece a dos clusters (raro pero valido).
|
||||
- Filtro MCP: `mcp__registry__fn_search query="" tag="notebook"` lista el grupo.
|
||||
|
||||
### Cuando crear grupo nuevo
|
||||
|
||||
- **Minimo 3 funciones** afines. Con 2 no compensa pagina madre — quedan tags sueltos.
|
||||
- **Dominio operativo claro**: el grupo debe ser describible en 1 frase ("operar Jupyter colaborativo", "deploy via SSH+systemd").
|
||||
- **Frontera neta** con grupos existentes. Si solapa con otro -> reorganizar, no duplicar.
|
||||
|
||||
### Como crear grupo
|
||||
|
||||
1. Anadir el tag al frontmatter `.md` de >=3 funciones afines. `fn index` lo registra.
|
||||
2. Crear `docs/capabilities/<grupo>.md` con plantilla:
|
||||
- **Lista de funciones**: tabla `ID | firma corta | que hace`.
|
||||
- **Ejemplo canonico**: 1-2 bloques de codigo end-to-end con los IDs reales.
|
||||
- **Fronteras**: que NO cubre el grupo.
|
||||
- **Prerequisitos** y **notas** si aplica.
|
||||
3. Anadir fila al `docs/capabilities/INDEX.md`.
|
||||
4. Correr `fn doctor capabilities` para auditar drift.
|
||||
|
||||
### Auto-generacion
|
||||
|
||||
`fn doctor capabilities --update` (TBD) reescribe la tabla de funciones de cada pagina madre preservando bloques curated (`Ejemplo canonico`, `Fronteras`, `Notas`). Las secciones curated nunca se sobrescriben.
|
||||
|
||||
### Como Claude usa los grupos
|
||||
|
||||
Cuando una tarea cae en un dominio conocido:
|
||||
|
||||
1. `Read docs/capabilities/INDEX.md` para localizar grupo.
|
||||
2. `Read docs/capabilities/<grupo>.md` para cargar funciones + ejemplo.
|
||||
3. Solo si el grupo no cubre lo necesario, `mcp__registry__fn_search` para funciones sueltas.
|
||||
4. Si el grupo deberia cubrir pero falta funcion -> `fn-constructor` + tagear con el grupo en el frontmatter.
|
||||
|
||||
### Auditoria
|
||||
|
||||
```bash
|
||||
fn doctor capabilities # lista grupos + drift
|
||||
fn doctor capabilities --json # para agentes
|
||||
```
|
||||
|
||||
Comprueba:
|
||||
- Tag con N >=3 funciones pero sin pagina madre -> "tag huerfano".
|
||||
- Pagina madre sin tag respaldo -> "grupo fantasma".
|
||||
- Funcion con tag de grupo pero la pagina madre no la lista (autogen desfasada) -> "drift".
|
||||
|
||||
### Relacion con dominios
|
||||
|
||||
Los **dominios** del registry (`core`, `infra`, `finance`, `datascience`, `cybersecurity`, `shell`, `tui`, `pipelines`, `browser`) son taxonomia ortogonal — un grupo puede atravesar varios dominios (ej. `deploy` toca `infra` y `shell`). NO renombrar dominio a grupo ni viceversa.
|
||||
@@ -0,0 +1,263 @@
|
||||
## Estandarizacion de apps C++ del registry
|
||||
|
||||
**Fuentes autoritativas:**
|
||||
- `cpp/PATTERNS.md` — checklist y esqueleto del app shell (`fn::run_app`, AppConfig, panels, layouts, Settings, About).
|
||||
- `cpp/DESIGN_SYSTEM.md` — identidad visual (`fn_tokens`, ThemeMode, equivalencias `@fn_library` ↔ C++).
|
||||
|
||||
Esta regla NO duplica esos documentos — los señala como obligatorios y añade convenciones estructurales que no aparecen alli.
|
||||
|
||||
### Scaffolder canonico — OBLIGATORIO
|
||||
|
||||
**REGLA DURA:** crear apps C++ nuevas SIEMPRE con `fn run init_cpp_app <name> [--project <p>] [--desc "..."]`. NUNCA escribir `main.cpp` + `CMakeLists.txt` + `app.md` desde cero a mano en `cpp/apps/` ni `projects/*/apps/`. Tampoco copiar otra app y renombrar — la deriva entre patrones es lo que estamos eliminando.
|
||||
|
||||
Si el scaffolder no cubre un caso (ej. necesitas plantilla diferente, layout custom desde el primer dia), **modificas el scaffolder**, no escribes la app a mano. La plantilla canonica es codigo, no decoracion.
|
||||
|
||||
Razones:
|
||||
- Garantiza `cfg.about` + `cfg.log` + `cfg.panels` + framework defaults aplicados.
|
||||
- Genera frontmatter `app.md` valido (framework, dir_path, repo_url) para `fn index`.
|
||||
- Registra `add_subdirectory` en `cpp/CMakeLists.txt` (raiz o bloque `_DIR` para projects).
|
||||
- Crea repo Gitea `dataforge/<name>` con master + commit inicial.
|
||||
|
||||
Pipeline: `init_cpp_app_bash_pipelines`. Slash command equivalente: `/new-cpp-app`. Auditoria: `fn doctor cpp-apps`.
|
||||
|
||||
### 1. Ubicacion
|
||||
|
||||
| Caso | Donde vive |
|
||||
|---|---|
|
||||
| App independiente | `cpp/apps/<nombre>/` |
|
||||
| App de un proyecto | `projects/<proyecto>/apps/<nombre>/` |
|
||||
|
||||
NUNCA en `cpp/apps/<nombre>/` si pertenece a un proyecto, NUNCA fuera de `apps/` directamente. Ver `apps_location` en memoria + regla `apps_vs_functions.md`.
|
||||
|
||||
### 2. Estructura minima
|
||||
|
||||
```
|
||||
<app_dir>/
|
||||
CMakeLists.txt # usa add_imgui_app(target ...)
|
||||
app.md # frontmatter de registro (ver §4)
|
||||
main.cpp # entry: parseo de args + fn::run_app + render()
|
||||
[data.{h,cpp}] # opcional: capa de datos (DB / HTTP / archivos)
|
||||
[views.{h,cpp}] # opcional: composicion de paneles
|
||||
[<modulo>.{h,cpp}] # opcional: dominio especifico
|
||||
[vendor/] # opcional: deps no comunes (se prefieren las globales en cpp/vendor/)
|
||||
[.git/] # cada app es su propio repo Gitea (ver §6)
|
||||
```
|
||||
|
||||
**Reglas de split:**
|
||||
- `main.cpp` SIEMPRE — punto de entrada con `int main()` + `fn::run_app(...)` + funcion `render()`.
|
||||
- Si la app supera ~400 lineas en `main.cpp`, partir en `data.{h,cpp}` (carga/persistencia) + `views.{h,cpp}` (UI por panel).
|
||||
- Modulos especificos del dominio en archivos propios (`compiler.cpp` en `shaders_lab`, `data_http.cpp` en `registry_dashboard`).
|
||||
- NO crear archivos de "utilidades genericas" dentro de la app — eso va al registry como funcion (`cpp/functions/...`).
|
||||
|
||||
### 3. CMakeLists.txt
|
||||
|
||||
Patron canonico:
|
||||
|
||||
```cmake
|
||||
add_imgui_app(<target>
|
||||
main.cpp
|
||||
[extra_modules.cpp]
|
||||
# Funciones del registry usadas (paths absolutos):
|
||||
${CMAKE_SOURCE_DIR}/functions/<dominio>/<funcion>.cpp
|
||||
...
|
||||
)
|
||||
target_include_directories(<target> PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_link_libraries(<target> PRIVATE [SQLite::SQLite3] [imgui_node_editor] ...)
|
||||
|
||||
if(WIN32)
|
||||
set_target_properties(<target> PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||
endif()
|
||||
```
|
||||
|
||||
Reglas:
|
||||
- Usar SIEMPRE la macro `add_imgui_app(target ...)` — gestiona enlace con `fn_framework` y copia de TTFs.
|
||||
- Listar explicitamente cada `.cpp` del registry usado (no glob). Hace visible el grafo de dependencias.
|
||||
- NO listar `tokens.cpp`, `icon_font.cpp`, `app_settings.cpp`, `app_about.cpp`, `fps_overlay.cpp`, `panel_menu.cpp`, `app_menubar.cpp`, `layouts_menu.cpp`, `gl_loader.cpp`, `layout_storage.cpp` — viven en `fn_framework` y dan multiple-definition si se duplican.
|
||||
- En `WIN32`, marcar `WIN32_EXECUTABLE TRUE` para apps GUI (sin consola).
|
||||
|
||||
### 4. app.md (frontmatter)
|
||||
|
||||
Plantilla minima para apps C++:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: <name>
|
||||
lang: cpp
|
||||
domain: <gfx|tui|tools|infra|...>
|
||||
description: "Frase corta — lo que hace y por que existe."
|
||||
tags: [imgui, ...] # si es service, anadir 'service'
|
||||
uses_functions: # IDs del registry — el indexer NO deduce C++
|
||||
- <nombre>_cpp_<dominio>
|
||||
- ...
|
||||
uses_types: []
|
||||
framework: "imgui"
|
||||
entry_point: "main.cpp"
|
||||
dir_path: "cpp/apps/<name>" o "projects/<proyecto>/apps/<name>"
|
||||
repo_url: "https://gitea-.../dataforge/<name>"
|
||||
---
|
||||
```
|
||||
|
||||
Reglas:
|
||||
- `uses_functions` se rellena a mano con los IDs de las funciones del registry usadas en `CMakeLists.txt`. Auditar con: `sqlite3 registry.db "SELECT id FROM apps WHERE id='<id>';"` + revisar diffs.
|
||||
- `framework: "imgui"` siempre que use `fn::run_app`. Otros valores solo si la app NO usa el shell (raro).
|
||||
- `tags`: incluir `service` si es daemon de larga duracion (ver `function_tags.md`).
|
||||
- `repo_url` apunta al sub-repo en Gitea (ver §6).
|
||||
|
||||
### 5. Registro en `cpp/CMakeLists.txt`
|
||||
|
||||
Cada app nueva se registra al final de `cpp/CMakeLists.txt`:
|
||||
|
||||
```cmake
|
||||
# --- <app_name> ---
|
||||
if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/apps/<name>/CMakeLists.txt)
|
||||
add_subdirectory(apps/<name>)
|
||||
endif()
|
||||
```
|
||||
|
||||
Para apps en proyectos (fuera del arbol `cpp/`):
|
||||
|
||||
```cmake
|
||||
# --- <app_name> (lives in projects/<proj>/apps/) ---
|
||||
set(_<NAME>_DIR ${CMAKE_SOURCE_DIR}/../projects/<proj>/apps/<name>)
|
||||
if(EXISTS ${_<NAME>_DIR}/CMakeLists.txt)
|
||||
add_subdirectory(${_<NAME>_DIR} ${CMAKE_BINARY_DIR}/apps/<name>)
|
||||
endif()
|
||||
```
|
||||
|
||||
El `if(EXISTS ...)` hace el registro tolerante a apps no clonadas (cada app es sub-repo separado).
|
||||
|
||||
### 6. Sub-repo Gitea (TBD obligatorio)
|
||||
|
||||
Cada app C++ es su propio repo en `dataforge/<name>` con branch `master`. Esto significa:
|
||||
- El directorio `<app_dir>/` esta en el `.gitignore` de `fn_registry` (excepto `app.md`).
|
||||
- El propio directorio tiene `.git/` apuntando al sub-repo.
|
||||
- TBD obligatorio mientras se desarrolla la app: ver `apps_tbd.md`. Trabajar en `issue/<NNNN>-<slug>` o `quick/<slug>`, mergear a `master` con `--no-ff`.
|
||||
- Sync entre PCs y push/pull se gestionan con `/full-git-push` y `/full-git-pull`.
|
||||
|
||||
### 7. Convencion `local_files/` — separacion de distribuible vs estado local
|
||||
|
||||
**OBLIGATORIO**: TODA app coloca sus archivos escribibles bajo
|
||||
`<exe_dir>/local_files/`. Los archivos distribuibles (`.exe`, `.dll`,
|
||||
`.ttf`, `enrichers/`, `runtime/`) viven directos en `<exe_dir>/`.
|
||||
|
||||
```
|
||||
<exe_dir>/
|
||||
├── <app>.exe
|
||||
├── duckdb.dll, *.ttf, runtime/, enrichers/ ← read-only, ships con el zip
|
||||
└── local_files/ ← writable, per-PC
|
||||
├── imgui.ini ← gestionado por fn::run_app
|
||||
├── app_settings.ini ← gestionado por fn_ui::settings_*
|
||||
└── <lo que la app escriba> ← usar fn::local_path("nombre")
|
||||
```
|
||||
|
||||
`fn::run_app` lo gestiona automaticamente para `imgui.ini` y
|
||||
`app_settings.ini` y migra desde `<exe_dir>/` o `cwd` si vienen de
|
||||
una version previa.
|
||||
|
||||
Apps que escriban archivos extra (DBs, caches, proyectos del
|
||||
usuario) **DEBEN** usar `fn::local_path("nombre")` al construir
|
||||
sus paths. Ejemplo:
|
||||
|
||||
```cpp
|
||||
// MAL
|
||||
sqlite3_open("graph_explorer.db", &db);
|
||||
fopen("graph_explorer.ini", "r");
|
||||
|
||||
// BIEN
|
||||
sqlite3_open(fn::local_path("graph_explorer.db"), &db);
|
||||
fopen(fn::local_path("graph_explorer.ini"), "r");
|
||||
```
|
||||
|
||||
API en `cpp/framework/app_base.h`:
|
||||
- `fn::exe_dir()` — directorio del ejecutable.
|
||||
- `fn::local_dir()` — `<exe_dir>/local_files/`, creado on-demand.
|
||||
- `fn::local_path(name)` — `<local_dir>/<name>`.
|
||||
- `fn::migrate_to_local_files(names, n)` — mueve archivos viejos.
|
||||
|
||||
Beneficios:
|
||||
- Carpeta del .exe limpia para distribuir (zip portable).
|
||||
- Reset trivial (basta borrar `local_files/`).
|
||||
- Separacion clara para backup/sync (solo `local_files/` es propio del PC).
|
||||
|
||||
### 7.1 Anti-jitter automatico (AltSnap, tiling WMs)
|
||||
|
||||
`fn::run_app` aplica tres capas de proteccion contra jitter al mover la
|
||||
ventana con herramientas externas (AltSnap en Windows, snap-assist, tiling
|
||||
WMs). Activado por defecto, sin opt-in:
|
||||
|
||||
1. **GLFW pos/size callbacks** — `vp->Pos/Size` se sincronizan al instante
|
||||
con `glfwSetWindowPos/Size` (no espera al siguiente NewFrame).
|
||||
2. **Per-frame viewport sync** al inicio del main loop — cubre viewports
|
||||
secundarios (paneles drag-out) que la backend crea dinamicamente.
|
||||
3. **Win32 WndProc subclass** (`#ifdef _WIN32`) — observa `WM_ENTERSIZEMOVE`
|
||||
/ `WM_EXITSIZEMOVE` que AltSnap fakea alrededor de cada drag. Mientras
|
||||
el bracket esta abierto el main loop SKIPEA `render_fn` + `glfwSwapBuffers`,
|
||||
replicando el contrato del title-bar drag native (DefWindowProc bloquea
|
||||
el hilo, DWM compositor mueve el framebuffer existente).
|
||||
|
||||
Tests: `cpp/apps/altsnap_jitter_test/` corre dos fases:
|
||||
- `p1.sync` (cross-platform): drives `glfwSetWindowPos` cada frame, asserta
|
||||
`vp->Pos` sigue OS dentro de 1px.
|
||||
- `p2.altsnap` (Windows): worker thread fakea `WM_ENTERSIZEMOVE` +
|
||||
burst de `SetWindowPos(SWP_ASYNCWINDOWPOS)` + `WM_EXITSIZEMOVE`, asserta
|
||||
que `render()` no se llama durante el bracket.
|
||||
|
||||
Lanzar con `e2e_run_cpp_windows altsnap_jitter_test`.
|
||||
|
||||
NO hace falta nada en cada app — toda `fn::run_app` lo hereda. Si una app
|
||||
necesita renderizar incluso durante external move (caso raro: telemetria
|
||||
en vivo, video stream), tendria que evitar el bypass — actualmente no hay
|
||||
flag para desactivarlo (anadir `cfg.pause_on_external_sizemove = true` por
|
||||
default si surge necesidad).
|
||||
|
||||
### 8. Convenciones de runtime
|
||||
|
||||
Cumplir el checklist completo de `cpp/PATTERNS.md`. Resumen de lo que NUNCA debe aparecer en una app:
|
||||
|
||||
| Anti-patron | Sustituir por |
|
||||
|---|---|
|
||||
| `glfwInit()` en `main` | `fn::run_app(cfg, render)` |
|
||||
| `ImGui::StyleColorsDark()` | `cfg.theme = ThemeMode::FnDark` (default) |
|
||||
| `ImVec4(0.5,0.5,0.5,1)` | `fn_tokens::colors::*` |
|
||||
| `ImGui::Begin(u8"\xEF...")` | `ImGui::Begin(TI_HOME " ...")` |
|
||||
| Menubar inline cada frame | `cfg.panels` + `cfg.layouts_cb` |
|
||||
| About hardcoded en un panel | `cfg.about = {...}` |
|
||||
| `gl*` directo sin loader | `cfg.init_gl_loader = true` |
|
||||
| Tabla SQLite en la raiz del repo | `<app_dir>/<app>.db` (operations.db es solo para entities/relations/executions) |
|
||||
| `fopen("foo.ini", ...)` con path relativo | `fopen(fn::local_path("foo.ini"), ...)` (ver §7) |
|
||||
|
||||
### 8. Tests visuales (recomendado, no obligatorio)
|
||||
|
||||
Si la app tiene componentes que se quieren proteger contra regresiones visuales, anadir un demo en `cpp/apps/primitives_gallery/demos_<dominio>.cpp` que use los mismos componentes/funciones del registry. El sistema de capture-and-compare de `primitives_gallery --capture` funciona como golden-image gate (ver final de `cpp/PATTERNS.md`).
|
||||
|
||||
### 9. Decisiones que cada app debe tomar y documentar en su `app.md`
|
||||
|
||||
- `viewports`: `true` (default) si las ventanas pueden arrastrarse fuera del main; `false` si la app necesita estar siempre embebida.
|
||||
- `init_gl_loader`: `true` si llama `gl*` directo (renderers GPU custom como `graph_renderer`); `false` si solo usa ImGui/ImPlot.
|
||||
- `about` info: nombre, version (semver), descripcion 1 frase.
|
||||
- Persistencia: `<app>.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 `<exe_dir>/local_files/layouts.db` (tabla `imgui_layouts` +
|
||||
`layout_meta`) y persiste el `imgui.ini` serializado por nombre.
|
||||
|
||||
**Restore-on-open / save-on-close (1.1.0+):** al cerrar la app, el slot del
|
||||
layout activo se reescribe con el `imgui.ini` actual (los retoques de
|
||||
docking sobreviven). Al abrir, si habia un layout activo persistido en
|
||||
`layout_meta.last_active`, se carga en el primer frame. Si la app no usa
|
||||
named layouts (nunca clico Save/Apply), el comportamiento sigue siendo el
|
||||
de antes: `imgui.ini` es la unica fuente.
|
||||
|
||||
- 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 = "<algo>.db"` (relativo a
|
||||
`local_files/`).
|
||||
@@ -0,0 +1,165 @@
|
||||
## Migraciones de BBDD: nunca perder datos
|
||||
|
||||
**Regla absoluta:** todo cambio de schema en SQLite (apps con `kanban.db`, `operations.db` propia, registry.db, etc.) DEBE ir en un archivo de migración versionado. Nunca borrar/recrear tablas, nunca cambiar tipos sin proceso seguro, nunca confiar en "borra el .db y vuelve a empezar".
|
||||
|
||||
### Por que
|
||||
|
||||
- Las apps almacenan **datos vivos** (cards, entities, executions, assertions, columns, sessions).
|
||||
- Borrar = perder horas/dias/semanas de trabajo del usuario.
|
||||
- Lo que es trivial en dev (`rm operations.db`) es destructivo en produccion (deploys + sync entre PCs).
|
||||
- Sync entre PCs (`fn sync`, `/full-git-pull`) trae bases de datos de otros equipos: si tu schema asume tabla recreada, los datos del otro PC desaparecen.
|
||||
|
||||
### Patrones obligatorios
|
||||
|
||||
#### 1. Archivos numerados en `migrations/`
|
||||
|
||||
Cada cambio de schema = un archivo nuevo `migrations/NNN_<accion>.sql`. Numeracion zero-padded de 3 digitos. Nombre descriptivo.
|
||||
|
||||
```
|
||||
apps/<app>/migrations/
|
||||
001_init.sql # CREATE TABLE inicial (no se modifica nunca)
|
||||
002_add_stickers.sql # ALTER TABLE cards ADD COLUMN stickers
|
||||
003_add_assignees.sql # ALTER TABLE cards ADD COLUMN assignee_id
|
||||
004_create_lock_history.sql # CREATE TABLE card_lock_history
|
||||
...
|
||||
```
|
||||
|
||||
#### 2. Solo operaciones aditivas seguras
|
||||
|
||||
| Operacion | Seguro | Notas |
|
||||
|---|---|---|
|
||||
| `CREATE TABLE IF NOT EXISTS` | si | idempotente |
|
||||
| `CREATE INDEX IF NOT EXISTS` | si | idempotente |
|
||||
| `ALTER TABLE ... ADD COLUMN` | si | aditivo, default obligatorio |
|
||||
| `INSERT INTO ... ON CONFLICT IGNORE` | si | seed data idempotente |
|
||||
| `DROP TABLE` | NO | destructivo |
|
||||
| `DROP COLUMN` | NO | destructivo (SQLite < 3.35 ni siquiera lo soporta) |
|
||||
| `ALTER TABLE ... RENAME COLUMN` | precaucion | rompe codigo viejo si rollback |
|
||||
| `ALTER TABLE ... DROP/ALTER constraint` | NO sin backup | requiere recreate-and-copy |
|
||||
|
||||
Si necesitas cambiar tipo, eliminar columna, o cambiar PK: hacer **migracion en pasos** (Branch by Abstraction):
|
||||
1. Crear nueva columna/tabla con la forma deseada (migration N).
|
||||
2. App escribe en ambas (migration N+1, codigo).
|
||||
3. Backfill de datos viejos (migration N+2, script).
|
||||
4. App lee solo de la nueva (migration N+3, codigo).
|
||||
5. Eliminar la vieja (migration N+4, despues de tener backups verificados).
|
||||
|
||||
Cada paso = una rama TBD corta + commit + verificacion. Nunca un solo PR que rompa lectores.
|
||||
|
||||
#### 3. Aplicacion idempotente al arrancar
|
||||
|
||||
La app aplica todas las migraciones en orden al iniciar. Patron canonico (Go):
|
||||
|
||||
```go
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
func applyMigrations(conn *sql.DB) error {
|
||||
files, err := fs.Glob(migrationsFS, "migrations/*.sql")
|
||||
if err != nil { return err }
|
||||
sort.Strings(files)
|
||||
for _, f := range files {
|
||||
b, err := migrationsFS.ReadFile(f)
|
||||
if err != nil { return err }
|
||||
if _, err := conn.Exec(string(b)); err != nil {
|
||||
// SQLite ALTER TABLE ADD COLUMN no es idempotente nativamente.
|
||||
// Si ya existe, ignorar el error de "duplicate column".
|
||||
if !strings.Contains(err.Error(), "duplicate column") &&
|
||||
!strings.Contains(err.Error(), "already exists") {
|
||||
return fmt.Errorf("%s: %w", f, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
Alternativa: tabla `_migrations` con las versiones aplicadas (mas robusta para schemas grandes). Para apps pequeñas (kanban, operations.db), bastan los archivos numerados + `IF NOT EXISTS` / catch de "duplicate column".
|
||||
|
||||
#### 4. Migracion + cambios en codigo en el mismo commit
|
||||
|
||||
Cuando añades una columna:
|
||||
- `migrations/NNN_<accion>.sql` (nueva)
|
||||
- `db.go` (lee/escribe la columna)
|
||||
- `types.ts` (frontend type)
|
||||
- Tests
|
||||
|
||||
Todo en el mismo commit/rama. Si solo mergeas la migracion pero no el codigo, otros PCs aplican la migracion al sync y luego el codigo viejo no la usa. OK. Si mergeas el codigo sin la migracion, la app peta al arrancar en otros PCs. Mal. **Migracion antes que codigo en el orden de archivos** (no de tiempo).
|
||||
|
||||
#### 5. Tests sobre la migracion
|
||||
|
||||
Cada migracion debe tener test que:
|
||||
- Arranca con DB vacia → aplica todas → verifica schema.
|
||||
- Arranca con DB en estado N-1 (datos previos) → aplica migracion N → verifica que los datos se conservan.
|
||||
|
||||
Esto detecta migraciones destructivas antes de mergear.
|
||||
|
||||
### Que NO hacer
|
||||
|
||||
| Anti-patron | Consecuencia |
|
||||
|---|---|
|
||||
| Borrar `*.db` durante dev y commitear "schema actualizado" | Otros PCs pierden datos al sync. |
|
||||
| Modificar `001_init.sql` para añadir columnas | Las DBs ya creadas no se actualizan. Datos divergentes. |
|
||||
| `DROP TABLE x; CREATE TABLE x ...` | Borra todo lo que el usuario tenga. |
|
||||
| Usar `ensureColumns` sin archivo SQL paralelo | El cambio de schema vive solo en codigo Go, no auditable, no migrable manualmente. |
|
||||
| Cambiar tipo de columna in-place | SQLite necesita recreate-and-copy. Asume que pierde datos si no se hace bien. |
|
||||
| "fn index" como solucion para regenerar registry.db | OK para `registry.db` (regenerable). NUNCA para `operations.db`, `kanban.db`, etc. |
|
||||
|
||||
### Casos especiales
|
||||
|
||||
#### registry.db (raiz del fn_registry)
|
||||
|
||||
`registry.db` SE PUEDE regenerar con `fn index` desde los `.go` y `.md`. Para cambios de schema del registry: actualizar `registry/migrations.go` o el codigo de creacion + `fn index`. NO hace falta archivo de migracion porque la fuente de verdad son los `.md`/`.go`. Excepcion: tablas con datos vivos (`proposals`, `pc_locations`) — esas SI requieren migracion preservando datos.
|
||||
|
||||
#### operations.db (por app)
|
||||
|
||||
Cada app tiene su `operations.db` con entities/relations/executions. Schema definido en `fn_operations/`. Cambios al schema → archivo de migracion en `fn_operations/migrations/` aplicado al abrir la BD. Idempotente.
|
||||
|
||||
#### apps con BD propia (kanban, etc.)
|
||||
|
||||
Mismo patron: `apps/<app>/migrations/NNN_*.sql`, embebido y aplicado al arrancar.
|
||||
|
||||
### Comandos utiles
|
||||
|
||||
```bash
|
||||
# Ver schema actual
|
||||
sqlite3 apps/kanban/operations.db ".schema"
|
||||
|
||||
# Ver columnas de una tabla
|
||||
sqlite3 apps/kanban/operations.db "PRAGMA table_info(cards);"
|
||||
|
||||
# Backup antes de migracion arriesgada
|
||||
sqlite3 apps/kanban/operations.db ".backup apps/kanban/operations.db.bak.$(date +%Y%m%d)"
|
||||
|
||||
# Aplicar una migracion manual (si la app no esta corriendo)
|
||||
sqlite3 apps/kanban/operations.db < apps/kanban/migrations/00X_<accion>.sql
|
||||
|
||||
# Listar archivos de migracion en orden
|
||||
ls apps/kanban/migrations/*.sql | sort
|
||||
```
|
||||
|
||||
### Resumen
|
||||
|
||||
- Cada cambio de schema = archivo numerado nuevo en `migrations/`.
|
||||
- Aditivo siempre que se pueda. Destructivo solo en pasos verificados con backup.
|
||||
- App aplica migraciones al arrancar, idempotente.
|
||||
- Migracion + codigo + tests en el mismo commit.
|
||||
- Nunca borrar `.db` para "arreglar" schema. Nunca modificar migraciones existentes.
|
||||
|
||||
### Estado retroactivo (2026-05-09)
|
||||
|
||||
Inventario de BDs del ecosistema y conformidad con la regla:
|
||||
|
||||
| Repo / App | BD | `migrations/` | Estado |
|
||||
|---|---|---|---|
|
||||
| `registry/` | `registry.db` | si (11 archivos) | ✓ |
|
||||
| `fn_operations/` | `operations.db` por app | si (4 archivos) | ✓ |
|
||||
| `apps/kanban/` | `operations.db` (kanban) | si (5 archivos: 001 init, 002 stickers, 003 columns_extras, 004 cards_extras, 005 history_actor) | ✓ |
|
||||
| `apps/deploy_server/` | `operations.db` (deploys) | si (2 archivos: 001 init, 002 target_extras) | ✓ |
|
||||
| `apps/dag_engine/store/` | DB del dag_engine | si (001_init) | ✓ |
|
||||
| `projects/element_agents/.../shell/memory/` | memoria del agente | si (001_init) | ✓ |
|
||||
| `projects/osint_graph/apps/graph_explorer/` | DBs C++ inline (project_manager, layout_store, jobs, node_groups) | NO | **pendiente** — refactor C++ multi-archivo, mover schema inline a `migrations/*.sql` aplicado al abrir cada DB. |
|
||||
|
||||
Las apps marcadas ✓ usan el patron canonico `embed.FS + applyMigrations()` (Go) o equivalente. La C++ pendiente requiere ronda dedicada — tracker via issue cuando se aborde.
|
||||
|
||||
`apps/kanban/db.go::ensureColumns` se mantiene como **backstop idempotente** para DBs muy antiguas creadas antes del refactor de migraciones. NO añadir columnas nuevas alli — siempre via archivo SQL.
|
||||
@@ -0,0 +1,42 @@
|
||||
## Delegacion: spawn fn-constructor en vez de escribir inline
|
||||
|
||||
**REGLA DURA.** Si vas a escribir logica reutilizable inline en un artefacto (app, analysis, playground) o heredoc, STOP y delega a `fn-constructor`. La misma sesion debe crear + usar la funcion. No acumular huerfanas.
|
||||
|
||||
### Cuando un patron es candidato a funcion
|
||||
|
||||
- Aparece >=2 veces en esta sesion o en heredocs recientes.
|
||||
- Firma generica (no depende de tipos internos del artefacto).
|
||||
- 1 responsabilidad clara (CRUD, parse, transform, http call, formato fijo, etc.).
|
||||
- No es one-liner idiomatico de stdlib (`time.Now().UTC().Format(...)` queda fuera).
|
||||
|
||||
### Flujo obligatorio (mismo turno)
|
||||
|
||||
1. **Detectar**. Si vas a escribir >=5 lineas de logica reutilizable inline -> STOP.
|
||||
2. **Spawn `fn-constructor` inmediato** via `Agent(subagent_type="fn-constructor", ...)`:
|
||||
- **Sin preguntar al usuario** (autorizado por defecto).
|
||||
- Si hay >1 funcion independiente -> una sola llamada al Agent tool con **N tool_use blocks paralelos** en el mismo mensaje. NO serializar.
|
||||
3. **Tagear con grupo de capacidad** al menos UN tag de grupo (`notebook`, `metabase`, `deploy`, etc.). Ver `capability_groups.md`.
|
||||
4. **`fn index`** para registrar.
|
||||
5. **Importar + invocar en el mismo turno** — no dejar funcion huerfana recien creada.
|
||||
6. **Auto-verificar** con `fn doctor uses-functions` y `fn doctor unused` si tocas >=3 funciones nuevas.
|
||||
|
||||
### Anti-patrones auditables
|
||||
|
||||
| Anti-patron | Consecuencia | Sustituir por |
|
||||
|---|---|---|
|
||||
| Escribir helper inline en artefacto en vez de delegar | Reinvento por sesion | Spawn fn-constructor |
|
||||
| Crear N funciones serialmente | Latencia x N | Multiples `Agent()` en mismo mensaje |
|
||||
| Crear funcion y no usarla en el turno | Huerfana desde dia 1 (`calls_90d=0`) | Importar + invocar antes de cerrar turno |
|
||||
| Crear funcion sin tag de grupo | Imposible descubrir en bloque proxima sesion | Anadir tag de grupo (capability group) |
|
||||
| Reescribir en heredoc logica que ya existe | Capitalizacion perdida | `mcp__registry__fn_search` antes de escribir |
|
||||
|
||||
### Excepciones
|
||||
|
||||
- **Logica de dominio especifica del artefacto** (CRUD de tabla concreta, layout de UI, flujo unico de la app) -> queda en el artefacto. Solo lo reutilizable se delega.
|
||||
- **Stub temporal con `not implemented`**: aceptable si la dependencia externa no esta disponible. Documentar en `.md` (ver `stubs.md`).
|
||||
|
||||
### Telemetria
|
||||
|
||||
Cada `code_writes` + `calls` se registra en `call_monitor/operations.db` (issue 0085). Vista `session_capability_growth` mide ratio creadas vs usadas por sesion. Hook `UserPromptSubmit` inyecta `CAPABILITY-GROWTH: created_this_session=X used=Y orphan=Z` en cada turno.
|
||||
|
||||
Si `orphan>0` al cerrar la sesion -> revisar: o la funcion era especulativa (no debio crearse) o falta integrarla en el codigo del artefacto.
|
||||
@@ -0,0 +1,134 @@
|
||||
## Deploy de apps a VPS remotos
|
||||
|
||||
### Arquitectura
|
||||
|
||||
El sistema de deploy usa SSH + systemd + rsync. No Docker, no Kubernetes.
|
||||
|
||||
- **Conexiones SSH** → `~/.ssh/config` (alias, IP, user, key). Ya hay funciones CRUD: `ssh_config_read`, `ssh_config_find`, `ssh_config_parse`.
|
||||
- **Config de deploy** → `apps/deploy_server/operations.db` tabla `deploy_targets` (app, host, remote_dir, build_cmd, port, health_path, env).
|
||||
- **Logs de deploy** → misma BD, tabla `deploy_logs` (app, host, status, trigger, duration_ms, error).
|
||||
|
||||
### App: `deploy_server` (`apps/deploy_server/`)
|
||||
|
||||
CLI + servidor HTTP. Binario: `deploy_server`. Build: `CGO_ENABLED=1 go build -o deploy_server .`
|
||||
|
||||
```bash
|
||||
cd apps/deploy_server
|
||||
|
||||
# Gestionar targets
|
||||
./deploy_server target add --app <app> --host <ssh_alias> --port <N> --health /path --build "comando" [--user deploy] [--env '{"K":"V"}']
|
||||
./deploy_server target list
|
||||
./deploy_server target remove <app>
|
||||
|
||||
# Setup inicial (primera vez, crea dirs + systemd unit)
|
||||
./deploy_server setup <app> --host <ssh_alias>
|
||||
|
||||
# Deploy continuo (build local → rsync → restart → health check)
|
||||
./deploy_server deploy <app> [--host <ssh_alias>]
|
||||
|
||||
# Estado del servicio remoto
|
||||
./deploy_server status <app>
|
||||
./deploy_server status --all
|
||||
|
||||
# Servidor webhook (auto-deploy en cada push a Gitea)
|
||||
./deploy_server serve --port 9090
|
||||
```
|
||||
|
||||
### Funciones del registry involucradas
|
||||
|
||||
| Función | Qué hace | Purity |
|
||||
|---|---|---|
|
||||
| `rsync_deploy_bash_infra` | rsync local→remoto con exclusiones | impure |
|
||||
| `systemd_generate_unit_go_infra` | Genera texto .service | **pure** |
|
||||
| `systemd_install_go_infra` | Sube unit + daemon-reload + enable + start | impure |
|
||||
| `systemd_restart_go_infra` | Reinicia servicio remoto | impure |
|
||||
| `systemd_status_go_infra` | Estado + logs de servicio remoto | impure |
|
||||
| `vps_setup_app_go_infra` | Crea dirs + usuario en VPS | impure |
|
||||
| `gitea_create_webhook_bash_infra` | Crea webhook push en Gitea | impure |
|
||||
| `setup_vps_app_go_infra` | Pipeline: setup completo primera vez | impure |
|
||||
| `deploy_app_remote_go_infra` | Pipeline: deploy continuo | impure |
|
||||
|
||||
Tipo: `DeployConfig_go_infra` — struct con toda la config de deploy.
|
||||
|
||||
### Workflow para un agente
|
||||
|
||||
Cuando el usuario diga **"sube esta app a este VPS"** o **"deploya X en Y"**:
|
||||
|
||||
#### 1. Verificar que el host SSH existe
|
||||
|
||||
```bash
|
||||
grep "^Host " ~/.ssh/config
|
||||
# Si no existe el alias, añadirlo:
|
||||
# Usar ssh_config_add_entry o editar ~/.ssh/config directamente
|
||||
```
|
||||
|
||||
#### 2. Verificar conectividad
|
||||
|
||||
```bash
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=5 <alias> true
|
||||
```
|
||||
|
||||
#### 3. Registrar el target en deploy_server
|
||||
|
||||
```bash
|
||||
cd apps/deploy_server
|
||||
# Build deploy_server si no existe el binario
|
||||
CGO_ENABLED=1 go build -o deploy_server .
|
||||
|
||||
./deploy_server target add \
|
||||
--app <nombre_app> \
|
||||
--host <ssh_alias> \
|
||||
--port <puerto> \
|
||||
--health <path_o_vacio> \
|
||||
--build "CGO_ENABLED=0 GOOS=linux go build -o <binario> ." \
|
||||
--user deploy
|
||||
```
|
||||
|
||||
#### 4. Setup inicial
|
||||
|
||||
```bash
|
||||
./deploy_server setup <app> --host <ssh_alias>
|
||||
```
|
||||
|
||||
Esto crea dirs en `/opt/apps/<app>/`, sube el código, genera el unit systemd e instala el servicio.
|
||||
|
||||
#### 5. Deploys posteriores
|
||||
|
||||
```bash
|
||||
./deploy_server deploy <app>
|
||||
```
|
||||
|
||||
Build local → rsync → restart systemd → health check.
|
||||
|
||||
#### 6. Auto-deploy con webhook (opcional)
|
||||
|
||||
```bash
|
||||
# Lanzar servidor
|
||||
./deploy_server serve --port 9090
|
||||
|
||||
# Crear webhook en Gitea
|
||||
source bash/functions/infra/gitea_create_webhook.sh
|
||||
gitea_create_webhook "<owner>" "<repo>" "http://<ip_deploy_server>:9090/webhook/push" "<secret>"
|
||||
```
|
||||
|
||||
### Requisitos en el VPS
|
||||
|
||||
- SSH accesible con key auth (configurado en `~/.ssh/config` local)
|
||||
- El usuario SSH debe tener **sudo sin password** para: `systemctl`, `mv` a `/etc/systemd/system/`, `mkdir` en `/opt/apps/`, `useradd`, `chown`
|
||||
- `rsync` instalado en el VPS
|
||||
- Puerto del servicio abierto en el firewall del VPS
|
||||
|
||||
### Builds por lenguaje
|
||||
|
||||
| Lenguaje | Build command típico |
|
||||
|---|---|
|
||||
| Go | `CGO_ENABLED=0 GOOS=linux go build -o <nombre> .` |
|
||||
| Go + SQLite | `CGO_ENABLED=1 GOOS=linux go build -tags fts5 -o <nombre> .` |
|
||||
| Python | No build — rsync sube los .py, systemd ejecuta `python3 main.py` |
|
||||
| Bash | No build — rsync sube los .sh, systemd ejecuta `bash main.sh` |
|
||||
|
||||
Para Go con CGO (SQLite), el VPS debe tener `gcc` y `libc-dev`, o cross-compilar con `CGO_ENABLED=0` si la app no usa SQLite.
|
||||
|
||||
### Exclusiones de rsync
|
||||
|
||||
El deploy excluye automáticamente: `.git`, `operations.db*`, `*.exe`, `node_modules`, `.venv`, `__pycache__`, `build/`, `*.db-shm`, `*.db-wal`, `registry.db`.
|
||||
@@ -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 <app_id>` 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/<app>")
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,191 @@
|
||||
## Feature flags: enviar codigo incompleto a master sin romperlo
|
||||
|
||||
Doctrina oficial de **trunk-based development**: master siempre desplegable. Cuando una feature no cabe en una sola rama corta, o cuando hay WIP que no esta terminado pero el resto si, **el codigo viaja detras de un flag OFF**. Asi master sigue verde y el codigo a medio terminar no llega a usuarios reales.
|
||||
|
||||
Refs: [trunkbaseddevelopment.com/feature-flags/](https://trunkbaseddevelopment.com/feature-flags/), [trunkbaseddevelopment.com/branch-by-abstraction/](https://trunkbaseddevelopment.com/branch-by-abstraction/).
|
||||
|
||||
### Cuando usar feature flag
|
||||
|
||||
| Situacion | Accion |
|
||||
|---|---|
|
||||
| Feature multi-issue (`0015a`, `0015b`, `0015c`) que llevan dias | Cada sub-issue mergea con flag OFF. Ultimo sub-issue activa flag. |
|
||||
| Refactor grande tipo "Branch by Abstraction" (ej. cambiar driver DB) | Crear abstraccion + impl nueva con flag. Eliminar antigua + flag al final. |
|
||||
| Cambio con riesgo en produccion que necesita rollback rapido | Flag para apagar sin redeploy. |
|
||||
| Despliegue gradual (un PC primero, luego todos) | Flag por PC/usuario/grupo. |
|
||||
| WIP detectado al cerrar otra rama | Envolver el codigo a medias en flag OFF, mergear, terminar despues. |
|
||||
|
||||
### Cuando NO usar feature flag
|
||||
|
||||
- Bug fix autocontenido → mergear directo, sin flag.
|
||||
- Refactor que cabe en una rama corta → directo.
|
||||
- Docs, comments, type signatures → directo.
|
||||
- Codigo que no compila o no pasa tests → **NO viaja a master, ni con flag**. Flag protege codigo terminado, no roto.
|
||||
|
||||
### Flag != WIP
|
||||
|
||||
- **WIP**: codigo a medias, no compila o no testea. NO va a master.
|
||||
- **Flag**: codigo terminado y testeado, pero no expuesto al usuario. SI va a master.
|
||||
|
||||
Si hay 80% terminado y 20% pendiente: completar al menos un slice vertical funcional (compila, pasa tests, se puede activar end-to-end), mergear con flag OFF, dejar el 20% para otra rama. NO mergear el 20% sin proteger.
|
||||
|
||||
### Archivo de flags
|
||||
|
||||
`dev/feature_flags.json` en la raiz del repo (registry o app). Formato canonico:
|
||||
|
||||
```json
|
||||
{
|
||||
"flags": {
|
||||
"<flag-name>": {
|
||||
"enabled": false,
|
||||
"issue": "0063",
|
||||
"description": "Descripcion 1 linea de la feature",
|
||||
"added": "2026-05-08",
|
||||
"enabled_at": null
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Cuando se activa: cambiar `enabled: true` y rellenar `enabled_at` con fecha. Cuando la feature ya es estable y no necesita rollback (semanas/meses despues): borrar el flag y todas sus ramas condicionales del codigo. **Los flags caducan**; documentar fecha de revision para evitar que se acumulen.
|
||||
|
||||
### Patron por stack
|
||||
|
||||
#### Go (apps/services)
|
||||
|
||||
Cargar flags al arrancar. Patron simple — hashmap en memoria + helper `Enabled(name)`:
|
||||
|
||||
```go
|
||||
// pkg/flags/flags.go (puro hasta donde se pueda)
|
||||
package flags
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type Flag struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Issue string `json:"issue"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type Flags struct{ Flags map[string]Flag `json:"flags"` }
|
||||
|
||||
func Parse(b []byte) (Flags, error) {
|
||||
var f Flags
|
||||
err := json.Unmarshal(b, &f)
|
||||
return f, err
|
||||
}
|
||||
|
||||
func (f Flags) Enabled(name string) bool {
|
||||
flag, ok := f.Flags[name]
|
||||
return ok && flag.Enabled
|
||||
}
|
||||
```
|
||||
|
||||
Uso:
|
||||
|
||||
```go
|
||||
if flags.Enabled("kanban-stickers") {
|
||||
registerStickerRoutes(router)
|
||||
}
|
||||
```
|
||||
|
||||
Para flags en frontend embebido: serializar a `/api/flags` y leer desde el cliente (ver TS).
|
||||
|
||||
#### TypeScript / React
|
||||
|
||||
Inyectar en build (Vite) o exponer endpoint `/api/flags`:
|
||||
|
||||
```ts
|
||||
// src/flags.ts
|
||||
let cache: Record<string, boolean> | null = null;
|
||||
|
||||
export async function loadFlags(): Promise<Record<string, boolean>> {
|
||||
if (cache) return cache;
|
||||
const res = await fetch("/api/flags");
|
||||
const data = await res.json();
|
||||
cache = Object.fromEntries(Object.entries(data.flags).map(([k, v]: [string, any]) => [k, !!v.enabled]));
|
||||
return cache;
|
||||
}
|
||||
|
||||
export function isEnabled(name: string): boolean {
|
||||
return !!(cache?.[name]);
|
||||
}
|
||||
```
|
||||
|
||||
Render condicional:
|
||||
|
||||
```tsx
|
||||
{isEnabled("kanban-stickers") && <StickerToolbar ... />}
|
||||
```
|
||||
|
||||
Para flags en build-time (constantes del bundle), usar `import.meta.env.VITE_FLAG_X` o un plugin Vite que reemplace simbolos.
|
||||
|
||||
#### Bash / pipelines
|
||||
|
||||
Lectura directa con `jq`:
|
||||
|
||||
```bash
|
||||
ENABLED=$(jq -r '.flags["my-feature"].enabled' dev/feature_flags.json)
|
||||
if [ "$ENABLED" = "true" ]; then
|
||||
run_new_path
|
||||
else
|
||||
run_legacy_path
|
||||
fi
|
||||
```
|
||||
|
||||
#### Python
|
||||
|
||||
```python
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
def flags() -> dict:
|
||||
return json.loads(Path("dev/feature_flags.json").read_text())["flags"]
|
||||
|
||||
def enabled(name: str) -> bool:
|
||||
f = flags().get(name)
|
||||
return bool(f and f.get("enabled"))
|
||||
|
||||
if enabled("nuevo-pipeline"):
|
||||
run_new()
|
||||
else:
|
||||
run_legacy()
|
||||
```
|
||||
|
||||
### Branch by Abstraction (caso especial)
|
||||
|
||||
Para cambios grandes (ej. swap iBatis → Hibernate, swap libreria, swap protocolo):
|
||||
|
||||
1. **Abstraer**: crear interfaz que envuelve la implementacion antigua. Master sigue verde con la antigua. Mergear.
|
||||
2. **Implementar nueva**: bajo la misma interfaz, detras de flag OFF. Tests para ambas. Mergear.
|
||||
3. **Activar**: flip flag a ON en commit pequeño. Si rompe, flip OFF de inmediato.
|
||||
4. **Eliminar antigua**: borrar codigo legacy + flag + abstraccion. Mergear.
|
||||
|
||||
Cada paso es un merge corto, master nunca esta roto, hay rollback en cada punto.
|
||||
|
||||
### Reglas operativas
|
||||
|
||||
- **Un flag = un proposito**. Si necesitas dos toggles independientes, usa dos flags.
|
||||
- **Flag bool por defecto**. Si necesitas A/B/C, sigue siendo bool por nombre (`my-feature-v2`, `my-feature-v3`).
|
||||
- **Tests con flag ON y OFF**. CI corre ambos paths cuando el flag toca codigo critico.
|
||||
- **Documenta en el issue**: que flag protege que codigo, cuando se va a activar, cuando se va a borrar.
|
||||
- **No anidar flags**. Si una rama esta detras de dos flags, simplifica.
|
||||
- **Borra el flag**. Cuando la feature lleva semanas activa sin rollback, eliminar el flag es trabajo real, no opcional.
|
||||
|
||||
### Anti-patrones
|
||||
|
||||
| Anti-patron | Por que es malo |
|
||||
|---|---|
|
||||
| `if (flag) { ... } else { ... }` esparcido por 30 archivos | Imposible de borrar. Usar inyeccion / strategy pattern. |
|
||||
| Flag que lleva 6 meses ON sin borrar | Deuda tecnica. Borrar el flag y simplificar. |
|
||||
| Flag para WIP que no compila | Master roto. Eso no es flag, es WIP — no debe estar en master. |
|
||||
| Flag condicional sobre tipos / esquemas DB | Migrations son irreversibles. No se "apaga" una columna. Usar branch-by-abstraction sobre la lectura/escritura, no sobre el schema. |
|
||||
| Flag con nombre del autor o del issue (`lucas-experiment`, `flag-0063`) | Sin contexto al releerlo. Nombrarlo por la feature: `kanban-stickers`. |
|
||||
|
||||
### Comandos relacionados
|
||||
|
||||
- `/git-branch` — crea rama desde master.
|
||||
- `/git-push` — merge --no-ff + push.
|
||||
- Para registrar / activar un flag: editar `dev/feature_flags.json` directamente y commitear con el codigo correspondiente. No hay CLI dedicada todavia.
|
||||
@@ -0,0 +1,78 @@
|
||||
## fn doctor: diagnostico del registry y artefactos
|
||||
|
||||
`fn doctor` es el entrypoint unico para auditar la salud del sistema de forma read-only. Compone funciones del registry (`functions/infra/`) y formatea su salida. No modifica nada.
|
||||
|
||||
### Cuando usar
|
||||
|
||||
- Despues de un deploy: confirmar que servicios siguen vivos y artefactos intactos.
|
||||
- Despues de `git pull` o `fn sync`: detectar drift entre BD y disco.
|
||||
- Antes de `fn index` masivo: confirmar que apps Go/Py siguen declarando bien sus deps.
|
||||
- Periodicamente (cron): listar funciones del registry sin consumidores para limpiar.
|
||||
- Como gate antes de crear proposals: si `fn doctor` esta verde, las metricas del bucle reactivo son fiables.
|
||||
|
||||
### Comandos
|
||||
|
||||
```bash
|
||||
fn doctor # Corre TODOS los checks (artefacts + services + sync + uses-functions + unused + cpp-apps)
|
||||
fn doctor artefacts # Solo artefactos: git/venv/app.md/upstream
|
||||
fn doctor services # Solo apps con tag 'service' + systemctl + puerto
|
||||
fn doctor sync # Solo drift pc_locations BD vs disco local
|
||||
fn doctor uses-functions # Solo audit imports reales vs uses_functions
|
||||
fn doctor unused # Solo funciones huerfanas del registry
|
||||
fn doctor cpp-apps # Conformidad C++ con cpp/PATTERNS.md (cfg.about/log, no app_menubar manual, no DockSpace duplicado)
|
||||
|
||||
fn doctor --json # Salida JSON (cualquier subcomando) — para agentes/scripts
|
||||
```
|
||||
|
||||
### Mapeo subcomando → funcion del registry
|
||||
|
||||
| Subcomando | Funcion |
|
||||
|---|---|
|
||||
| `artefacts` | `artefact_doctor_go_infra` |
|
||||
| `services` | `services_status_go_infra` |
|
||||
| `sync` | `pc_locations_drift_go_infra` |
|
||||
| `uses-functions` | `audit_uses_functions_go_infra` |
|
||||
| `unused` | `find_unused_functions_go_infra` |
|
||||
| `cpp-apps` | `audit_cpp_apps_go_infra` |
|
||||
|
||||
Cada subcomando es un wrapper fino. Toda la logica vive en la funcion. Si quieres usar la salida en otro programa Go, importa la funcion directamente.
|
||||
|
||||
### Salida
|
||||
|
||||
Texto humano por defecto (tabwriter). `--json` produce array/objeto serializable para `jq`, agentes o pipes.
|
||||
|
||||
### Idempotente y seguro
|
||||
|
||||
- Read-only: ningun subcomando escribe, mata procesos ni cambia estado.
|
||||
- `services` abre conexiones TCP a `127.0.0.1:<port>` con timeout 500ms — no genera trafico saliente.
|
||||
- `artefacts` ejecuta `git rev-parse @{u}` con timeout 3s por artefacto.
|
||||
|
||||
### Acciones complementarias (NO son `fn doctor`)
|
||||
|
||||
`fn doctor` solo diagnostica. Las acciones derivadas son verbos separados:
|
||||
|
||||
| Si `fn doctor` reporta... | Accion |
|
||||
|---|---|
|
||||
| `directory_missing` | Marcar `pc_locations.status='missing'` o re-clonar via `/full-git-pull` |
|
||||
| `git_not_initialized` | `gitea_create_repo_bash_infra` + `ensure_repo_synced_bash_infra` |
|
||||
| `venv_broken_path` | `cd <analysis_dir> && rm -rf .venv && uv sync` |
|
||||
| `service active=inactive` | `systemctl --user start <unit>` o investigar logs |
|
||||
| `port not listening` | `port_kill_bash_infra <port>` (si zombie) y relanzar |
|
||||
| `missing_in_app_md` | Editar `app.md` y añadir el ID a `uses_functions` |
|
||||
| `unused` (funcion huerfana) | Decidir: usar, deprecar (tag), o borrar |
|
||||
| `manual_app_menubar_call` | Borrar `fn_ui::app_menubar(...)` del render — el framework ya lo dibuja |
|
||||
| `manual_DockSpaceOverViewport_*` | Borrar la llamada o setear `cfg.auto_dockspace = false` si la app gestiona docking propio |
|
||||
| `missing_cfg_about` / `missing_cfg_log` | Anadir `cfg.about = {...}` / `cfg.log = {"<name>.log", 1}` antes de `fn::run_app` |
|
||||
| `app.md_missing_*` | Regenerar via plantilla del scaffolder (`/new-cpp-app`) o anadir campos a mano |
|
||||
| Backup viejo | `backup_all_bash_pipelines ~/backups/fn_registry` |
|
||||
|
||||
### Para agentes
|
||||
|
||||
Patron recomendado tras una accion no trivial (deploy, sync, mass edit):
|
||||
|
||||
```bash
|
||||
fn doctor --json > /tmp/doctor.json
|
||||
# Agente parsea JSON, decide si crear proposals o avisar al humano
|
||||
```
|
||||
|
||||
Si el agente quiere actuar sobre los hallazgos, abre proposals con `fn proposal add` referenciando los IDs afectados — NO toca artefactos directamente sin aprobacion humana.
|
||||
@@ -9,3 +9,19 @@ El sistema de UI es Mantine v9. Todos los componentes de @fn_library wrappean co
|
||||
**Iconos:** Se usa `@tabler/icons-react` (el set nativo de Mantine), no lucide-react.
|
||||
|
||||
**Layout:** Se usan los componentes de layout de Mantine: `Group`, `Stack`, `Grid`, `Flex`, `SimpleGrid`, `AppShell`, `Container`, `Box`, `Paper`.
|
||||
|
||||
**AppShell.Navbar / AppShell.Aside (gotchas v9):**
|
||||
|
||||
- NO override `position` via `style` (ej. `style={{ position: "relative" }}`). Mantine aplica `position: fixed` con CSS class; si lo pisas, el slot cae al flow normal y empuja el resto del layout abajo (root altura 2x).
|
||||
- Para anclar children `position: absolute` (drag handle, badge flotante), el `position: fixed` del propio slot ya actua como containing block — no necesitas relative.
|
||||
- Por defecto el navbar **empuja** el main (anade `padding-inline-start: navbar-width`). Para **overlay** (navbar tapa main):
|
||||
```tsx
|
||||
<AppShell styles={{ main: { paddingInlineStart: 0 } }}>
|
||||
```
|
||||
Idem `paddingInlineEnd: 0` para aside overlay.
|
||||
- Si quieres backdrop dimming + click-outside-close: usa `<Drawer position="left">` en lugar de `AppShell.Navbar`.
|
||||
- **Memoizar configs**: `header`/`navbar`/`aside`/`styles` aceptan objetos. Si el componente padre se re-renderiza cada N (tick, ws, etc.), los objetos literales se recrean y Mantine regenera el `<style>` inline. Wrap con `useMemo([deps])`:
|
||||
```tsx
|
||||
const navbarCfg = useMemo(() => ({ width, breakpoint: "md", collapsed: { ... } }), [width, navOpen]);
|
||||
<AppShell navbar={navbarCfg} ...>
|
||||
```
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
## Function growth + self-documenting capability
|
||||
|
||||
Dos doctrinas hermanas. Una define **como deben ser** las funciones (auto-descubribles y lanzables sin segunda lectura). La otra define **como crece** el registry (no inflando funciones — promoviendo composiciones a pipelines).
|
||||
|
||||
Issue 0087.
|
||||
|
||||
---
|
||||
|
||||
### Parte A — `.md` autosuficiente (contrato OBLIGATORIO)
|
||||
|
||||
Cuando Claude (o un humano) encuentra una funcion via FTS / fuzzy match / capability page / TOP block, el `.md` debe bastar para **lanzarla sin abrir el codigo**. Esto es lo que hace que descubrir = lanzar y elimina el coste del second lookup.
|
||||
|
||||
**Secciones obligatorias** en cada `.md` del registry (functions + pipelines + types con uso practico):
|
||||
|
||||
| Seccion | Contenido | Tamaño |
|
||||
|---|---|---|
|
||||
| Frontmatter | `name`, `signature`, `params` (con `desc` por param), `output`, `tags`, `uses_functions`, etc. Lo de hoy. | — |
|
||||
| `## Ejemplo` | Bloque de codigo lanzable con args **concretos**. Copiar+pegar produce ejecucion real. NO placeholders abstractos. | 3-10 lineas |
|
||||
| `## Cuando usarla` | 1-2 frases con triggers: "cuando hagas X / antes de Y / si necesitas Z". Verbos imperativos. Ayuda al fuzzy match y a Claude a saber sin leer el codigo. | 1-3 lineas |
|
||||
| `## Gotchas` | Problemas conocidos / no-go cases. Obligatoria para funciones impuras o con efectos (Windows-side, red, FS write, GPU). Omisible para funciones puras triviales. | 0-5 puntos |
|
||||
| `## Capability growth log` | Solo SI la funcion ha crecido. Una linea por version: `v1.1.0 (YYYY-MM-DD) — anade --build flag para skip build`. No se rellena en v1.0.0. | crece con el tiempo |
|
||||
|
||||
**Anti-patrones del .md:**
|
||||
|
||||
- Ejemplo con `<arg1>`, `<arg2>` placeholders abstractos — NO. Ejemplos con valores reales (`registry_dashboard`, `/home/lucas/...`).
|
||||
- "Cuando usarla" vacio o "ver descripcion arriba" — NO. Frase nueva con trigger explicito.
|
||||
- `notes` lleno + `## Gotchas` vacio cuando la funcion tiene efectos — mover de `notes` a `## Gotchas`.
|
||||
- Capability growth log inventado (sin que la funcion haya cambiado) — NO. Solo se rellena cuando hay version bump real.
|
||||
|
||||
**Verificacion** (TBD: convertir a check de `fn doctor`): cada .md de `functions/`/`pipelines/` debe tener `## Ejemplo` y `## Cuando usarla`. `## Gotchas` obligatoria solo si `purity: impure`. `## Capability growth log` libre.
|
||||
|
||||
---
|
||||
|
||||
### Parte B — Crecimiento por composicion (no por inflado)
|
||||
|
||||
**Principio:** una funcion que hace bien UNA cosa NO necesita crecer. Anadir params "por si acaso" la hace peor (Inner Platform Effect). Lo que crece es el **registry**: pipelines nuevos que componen funciones existentes.
|
||||
|
||||
#### Ejemplo del principio
|
||||
|
||||
- **Hoy:** Claude para hacer una transferencia bancaria llama `bank_login` -> `bank_list_accounts` -> `bank_make_transfer`. 3 calls, 3 decisiones, 3 puntos de fallo.
|
||||
- **Manana:** pipeline `bank_transfer_oneshot(account, amount, target)` que compone las 3 internamente. 1 call, 1 decision.
|
||||
|
||||
Misma capacidad, 3x menos pasos. **Esto es lo que multiplica la velocidad de Claude**, no anadir flags a `bank_login`.
|
||||
|
||||
#### Como se promueve una composicion
|
||||
|
||||
Senal detectable en `call_monitor.operations.db`: secuencia A→B(→C) con
|
||||
|
||||
- **Mismo session_id**.
|
||||
- **Intervalo entre calls < N segundos** (default 30s).
|
||||
- **Occurrences > K** (default 5) en ventana de **D dias** (default 30).
|
||||
- **Success rate > S** (default 0.9 — falla < 10%).
|
||||
- **No existe ya un pipeline** que la cubra (validar con FTS sobre `uses_functions`).
|
||||
|
||||
Cuando se cumple → **proposal `new_pipeline`** con evidencia (sequence_ids, session_ids, occurrence count). Humano (o `fn-orquestador` autonomo) decide promover.
|
||||
|
||||
#### Implementacion (issue 0087 tanda A)
|
||||
|
||||
- `call_monitor sequences --detect` subcomando: escanea `calls` table, agrupa por session+window, computa secuencias, upserta en tabla `function_sequences`.
|
||||
- Cron diario que ejecuta el detector + genera proposals automaticas.
|
||||
- Visible en Monitor tab del `registry_dashboard`: sub-tab "Promotion candidates".
|
||||
|
||||
#### Cuando SI inflar una funcion
|
||||
|
||||
Casos legitimos para anadir feature a una funcion existente:
|
||||
|
||||
1. **Generalizar firma** sin romper consumidores (anadir param opcional con default sensato).
|
||||
2. **Mejor manejo de error** (mensajes mas claros, retry sensible).
|
||||
3. **Default mas inteligente** (autodetectar lo que antes era arg obligatorio).
|
||||
4. **Eliminar gotcha conocido** (fix de bug que estaba en `## Gotchas`).
|
||||
|
||||
NO infles para casos hipoteticos. NO anadas params "por flexibilidad". Si dudas, separa la responsabilidad en una funcion nueva o un pipeline.
|
||||
|
||||
#### Capability growth log — cuando se rellena
|
||||
|
||||
- Se rellena **solo cuando la funcion crece** (alguno de los 4 casos arriba).
|
||||
- Cada bump de `version` -> 1 linea en `## Capability growth log` con fecha y resumen 1-frase.
|
||||
- Una funcion estable de hace 6 meses puede seguir en v1.0.0 sin log: indica madurez, no abandono.
|
||||
- Telemetria (call_monitor) decide si una funcion estable es huerfana (`calls_90d=0`) o usada-y-buena (`calls_30d>10, error_rate<0.05`). Las primeras se deprecan; las segundas se respetan.
|
||||
|
||||
---
|
||||
|
||||
### Parte C — Output de discovery
|
||||
|
||||
Cuando un mecanismo de discovery (fuzzy match / FRESH hook / TOP block / capability page) surfacea una funcion, el payload **minimo** es:
|
||||
|
||||
```
|
||||
<id> → <signature> → <ejemplo de 1 linea>
|
||||
```
|
||||
|
||||
Ejemplo concreto:
|
||||
```
|
||||
redeploy_cpp_app_windows_bash_pipelines
|
||||
./fn run redeploy_cpp_app_windows registry_dashboard /path/to/app [--build]
|
||||
use: tras compilar cpp/build/windows, antes de smoke test manual
|
||||
```
|
||||
|
||||
Si Claude necesita mas (gotchas, params completos, codigo), un `mcp__registry__fn_show <id>` adicional. Pero el primer hit ya basta para el 80% de casos.
|
||||
|
||||
---
|
||||
|
||||
### Parte D — Relacion con otras reglas
|
||||
|
||||
- [[registry_first]] dice CUANDO buscar/usar/delegar. Esta regla dice **COMO** debe ser la funcion para que esa busqueda valga.
|
||||
- [[ids_naming]] hace ID predictible. Esta regla hace metadata predictible.
|
||||
- [[delegation]] dice cuando spawnar fn-constructor. Esta regla es lo que fn-constructor debe producir.
|
||||
- [[capability_groups]] agrupa funciones afines. Las paginas madre de cada grupo deben respetar el mismo contrato self-doc (mejor con su propio ejemplo end-to-end por grupo).
|
||||
|
||||
### Resumen TL;DR
|
||||
|
||||
1. Cada `.md` autosuficiente: Ejemplo + Cuando usarla + Gotchas (si impura) + Growth log (si crecio).
|
||||
2. Las funciones que hacen bien una cosa NO necesitan crecer.
|
||||
3. El registry crece **promoviendo composiciones repetidas a pipelines**, no inflando funciones.
|
||||
4. Telemetria de `call_monitor` detecta secuencias candidatas y abre proposals automaticas.
|
||||
5. Discovery devuelve siempre: `id + signature + 1-line example`. Resto on-demand.
|
||||
@@ -0,0 +1,30 @@
|
||||
## KISS en proyectos y apps
|
||||
|
||||
**Mantener proyectos (`projects/`) y apps (`apps/`, `projects/*/apps/`) simples**. La complejidad no solicitada es deuda — cada línea, cada dependencia y cada herramienta externa se justifican o no entran.
|
||||
|
||||
### Reglas
|
||||
|
||||
1. **Preferir herramientas ya presentes en el sistema o en el registry** antes que paquetes/CLI externos.
|
||||
- ¿Lo hace `git` / `bash` / una función del registry? Úsalo.
|
||||
- Antes de añadir una dependencia nueva, buscar en `registry.db` (FTS5) si ya existe algo similar.
|
||||
|
||||
2. **Cuestionar cada nueva herramienta externa**. Antes de instalarla preguntar:
|
||||
- ¿Qué problema concreto resuelve que NO podemos resolver con lo que ya tenemos?
|
||||
- ¿El coste (instalar, mantener, aprender, conflictos con nuestro flujo) compensa el beneficio real?
|
||||
- ¿Qué pasa si el proyecto upstream se abandona / rompe compatibilidad?
|
||||
|
||||
3. **Sin abstracciones ni features especulativas**. No generalizar "por si acaso". Tres líneas similares son mejores que una abstracción prematura.
|
||||
|
||||
4. **Ser consciente del flujo de trabajo actual**. Si algo funciona bien con `git` / submódulos / `fn` CLI, no lo sustituyas por una herramienta que prometa "mejorarlo" sin evidencia de mejora concreta en tu contexto.
|
||||
|
||||
5. **Escritura de apps**: una responsabilidad clara, layout mínimo (`main.*`, `app.md`, y lo estrictamente necesario), sin config ni estructuras que no se usen hoy.
|
||||
|
||||
### Caso aprendido (GitButler)
|
||||
|
||||
Se probó GitButler (virtual branches) pensando en paralelizar trabajo. Resultado:
|
||||
- Bugs con submódulos (git submodule add + gitlinks) — commits vacíos o contenido cruzado.
|
||||
- Auto-commits con el texto del chat como commit message.
|
||||
- Pre-commit hook que bloquea `git commit` directo y exige otro CLI (`but`).
|
||||
- Un binario externo de 37 MB + un plugin en Claude Code + skill propio + hooks en `settings.json`.
|
||||
|
||||
Al volver a `git` + ramas normales + `fn` CLI: cero fricción, commits limpios, submódulos funcionan. **Lección**: antes de adoptar una capa nueva, medir la fricción real actual. Si no la hay, no vale la pena añadir complejidad.
|
||||
@@ -0,0 +1,58 @@
|
||||
## Playgrounds: prototipos rapidos dentro de un artefacto
|
||||
|
||||
Un **playground** es un mini-artefacto efimero que vive **dentro** de otro artefacto (analysis, app o project) y reutiliza su entorno. Sirve para probar visualmente una idea (webapp, demo, dashboard, ejercicio interactivo) antes de decidir si se promueve a app independiente.
|
||||
|
||||
Ejemplo canonico: `projects/osint_graph/analysis/gliner_glirel_tuning/playground/` — server FastAPI + index.html + JS vendored que reutiliza el `.venv` del analisis padre para visualizar las recetas del notebook 08 con UI interactiva.
|
||||
|
||||
### Estructura
|
||||
|
||||
```
|
||||
<artefacto_padre>/
|
||||
playground/ # Un solo playground por padre (si necesitas mas, usa subdirs)
|
||||
server.py | server.go | ... # Punto de entrada (single-file preferido)
|
||||
index.html # UI si la hay
|
||||
static/ # JS/CSS vendored (no node_modules ni pnpm)
|
||||
server.log # Log local (gitignorable)
|
||||
```
|
||||
|
||||
Si el playground crece a varios subdirs/modulos, ya no es playground — promover a app.
|
||||
|
||||
### Reglas
|
||||
|
||||
1. **Hereda el entorno del padre**. NO crea su propio `.venv`, `package.json`, ni dependencias. Si el padre es un analysis Python, usa `../.venv/bin/python3`. Si el padre es una app Go, comparte el `go.mod`.
|
||||
2. **NO se indexa**. No tiene `app.md`, no aparece en `registry.db`, no tiene entrada en `pc_locations`.
|
||||
3. **NO tiene repo propio**. Vive dentro del repo Gitea del artefacto padre y se mueve con el.
|
||||
4. **Single-file preferido**. Un `server.py` o `main.go` con todo dentro. Si hace falta partir, considera promover a app.
|
||||
5. **Vendor deps front**. JS/CSS como `.min.js` en `static/`, sin `node_modules`. Si necesitas pnpm/vite, ya no es playground.
|
||||
6. **Reutiliza funciones del registry** igual que el padre — `sys.path` al `python/functions`, importar paquetes, etc.
|
||||
7. **Ciclo de vida**: vive mientras la idea esta cruda. Una vez probada, dos caminos:
|
||||
- **Promover a app** (extraer logica reutilizable como funciones del registry, crear `app.md`, mover a `apps/`).
|
||||
- **Borrar** sin contemplaciones si el experimento no llevo a nada.
|
||||
|
||||
### Cuando NO usar playground
|
||||
|
||||
- Si necesitas correr en un VPS / tener systemd / health check → es un app + service, no playground.
|
||||
- Si la idea ya esta clara y el codigo va a sobrevivir meses → arrancar como app desde el primer dia ahorra una migracion.
|
||||
- Si necesitas operations.db, assertions, o el bucle reactivo → es app.
|
||||
- Si el padre seria un proyecto entero solo para contener el playground → probablemente sea app standalone con `tags: [prototype]`.
|
||||
|
||||
### Relacion con `temp/`
|
||||
|
||||
| Cuando | Donde |
|
||||
|---|---|
|
||||
| Idea suelta sin contexto, prueba de API, snippet desechable | `temp/<lo_que_sea>/` (gitignored, sin contexto) |
|
||||
| Prototipo ligado a un analysis/app/proyecto que reutiliza su entorno | `<padre>/playground/` (versionado con el padre) |
|
||||
| Codigo que sobrevive y se reutiliza en otros sitios | extraer a `functions/` |
|
||||
| Aplicacion ejecutable con identidad propia | `apps/` o `projects/<p>/apps/<a>/` |
|
||||
|
||||
`temp/` es para cosas sin padre. Playground es para cosas con padre. Si dudas entre los dos, empieza en `temp/` y mueve a `playground/` cuando quede claro de que artefacto depende.
|
||||
|
||||
### Lanzar un playground
|
||||
|
||||
Sin convencion fija — depende del stack. El propio `server.py` o un README en el playground documenta como arrancarlo. Ejemplo del playground de OSINT:
|
||||
|
||||
```bash
|
||||
cd projects/osint_graph/analysis/gliner_glirel_tuning/playground
|
||||
../.venv/bin/python3 server.py
|
||||
# http://localhost:7878
|
||||
```
|
||||
@@ -0,0 +1,88 @@
|
||||
## Projects: apps, analysis y vaults bajo un tema comun
|
||||
|
||||
Un project agrupa apps, analyses y vaults relacionados. Vive en `projects/{nombre}/` con esta estructura:
|
||||
|
||||
```
|
||||
projects/{nombre}/
|
||||
project.md # Frontmatter obligatorio (name, description, tags)
|
||||
apps/ # Apps del proyecto (cada una con app.md)
|
||||
{app_name}/
|
||||
app.md
|
||||
...
|
||||
analysis/ # Analyses del proyecto (cada uno con analysis.md)
|
||||
{analysis_name}/
|
||||
analysis.md
|
||||
.venv/
|
||||
notebooks/
|
||||
run-jupyter-lab.sh
|
||||
...
|
||||
vaults/ # Datos del proyecto
|
||||
vault.yaml # Manifest de vaults (nombre, descripcion, path, tags)
|
||||
{vault_name} -> /abs/path # Symlinks a directorios reales de datos
|
||||
```
|
||||
|
||||
### Reglas
|
||||
|
||||
- `project.md` sigue el template de `docs/templates/project.md` — campos: `name`, `description`, `tags`, `repo_url`
|
||||
- `analysis.md` sigue el template de `docs/templates/analysis.md` — `dir_path` debe apuntar a `projects/{nombre}/analysis/{tema}/`
|
||||
- `vault.yaml` lista los vaults con nombre, descripcion, path absoluto y tags
|
||||
- Los vaults reales viven fuera del repo (ej: `~/vaults/{nombre}/`) con symlinks en el proyecto
|
||||
- `fn index` escanea `projects/*/` y setea `project_id` automaticamente en apps, analyses y vaults
|
||||
- Apps y analyses sueltos (sin proyecto) siguen en `apps/` y `analysis/` en la raiz
|
||||
|
||||
### Raiz vs proyecto
|
||||
|
||||
| Ubicacion | Para que |
|
||||
|-----------|---------|
|
||||
| `apps/` | Apps independientes que no pertenecen a ningun proyecto |
|
||||
| `analysis/` | Analyses independientes |
|
||||
| `projects/{nombre}/apps/` | Apps de un proyecto — `project_id` se setea automaticamente |
|
||||
| `projects/{nombre}/analysis/` | Analyses de un proyecto — `project_id` se setea automaticamente |
|
||||
|
||||
### Crear un proyecto nuevo
|
||||
|
||||
```bash
|
||||
# 1. Crear estructura
|
||||
mkdir -p projects/{nombre}/{apps,analysis,vaults}
|
||||
|
||||
# 2. Crear project.md con frontmatter
|
||||
fn add -k project # genera template
|
||||
|
||||
# 3. Crear vault (datos fuera del repo, symlink dentro)
|
||||
mkdir -p ~/vaults/{vault_name}/{raw,processed,exports}
|
||||
ln -s ~/vaults/{vault_name} projects/{nombre}/vaults/{vault_name}
|
||||
# Crear vault.yaml con la entrada
|
||||
|
||||
# 4. Crear analysis dentro del proyecto (un solo comando; ya indexa)
|
||||
fn run init_jupyter_analysis --project {nombre} {nombre_analysis} --desc "..." [paquetes...]
|
||||
|
||||
# 5. Verificar
|
||||
fn show {nombre} # verifica el project y sus componentes
|
||||
|
||||
# NUNCA: crear el analisis en analysis/ y luego mv al proyecto.
|
||||
# Al mover se rompe el .venv (paths hardcodeados en activate).
|
||||
# Si ya te paso: cd projects/{nombre}/analysis/{tema} && rm -rf .venv && uv sync
|
||||
```
|
||||
|
||||
### Consultas utiles
|
||||
|
||||
```sql
|
||||
-- Listar proyectos
|
||||
SELECT id, description FROM projects;
|
||||
|
||||
-- Analysis de un proyecto
|
||||
SELECT id, name, dir_path FROM analysis WHERE project_id = 'app_turismo';
|
||||
|
||||
-- Vaults de un proyecto
|
||||
SELECT id, name, path, symlink FROM vaults WHERE project_id = 'app_turismo';
|
||||
|
||||
-- Apps de un proyecto
|
||||
SELECT id, name, dir_path FROM apps WHERE project_id = 'app_turismo';
|
||||
|
||||
-- Todo lo que pertenece a un proyecto
|
||||
SELECT 'analysis' as tipo, id, name FROM analysis WHERE project_id = ?
|
||||
UNION ALL
|
||||
SELECT 'vault', id, name FROM vaults WHERE project_id = ?
|
||||
UNION ALL
|
||||
SELECT 'app', id, name FROM apps WHERE project_id = ?;
|
||||
```
|
||||
@@ -0,0 +1,147 @@
|
||||
## Como invocar funciones del registry — patrones canonicos
|
||||
|
||||
Toda invocacion del agente al registry sigue uno de **tres patrones**. Cualquier otro patron es antipatron auditable. Las invocaciones se loguean en `projects/fn_monitoring/apps/call_monitor/operations.db` (issue 0085) para alimentar el bucle reactivo.
|
||||
|
||||
### Patrones canonicos
|
||||
|
||||
| Caso | Patron | Cuando |
|
||||
|---|---|---|
|
||||
| **Inspeccionar** (buscar, leer codigo, ver dependencias, listar dominios, leer proposals) | `mcp__registry__fn_search` / `fn_show` / `fn_code` / `fn_uses` / `fn_list_domains` / `fn_proposal` | SIEMPRE para descubrimiento, lectura de codigo, exploracion. |
|
||||
| **Ejecutar** UNA funcion/pipeline con sus args | `mcp__registry__fn_run <id> [args]` (preferido) o `./fn run <id> [args]` (fallback CLI) | ID conocido + args planos. Despacho automatico por lenguaje. |
|
||||
| **Componer** ad-hoc multi-funcion con logica intermedia | Heredoc `python/.venv/bin/python3 - <<'PYEOF' ... PYEOF` IMPORTANDO funciones del registry | Solo si hay loops/conditionals/dispatch entre N funciones. Las funciones del registry **se importan**, no se reescriben. |
|
||||
|
||||
### Antipatrones prohibidos (audit-targeted)
|
||||
|
||||
| Patron | Razon | Sustituir por |
|
||||
|---|---|---|
|
||||
| `sqlite3 registry.db "SELECT ..."` para buscar funciones/tipos | Salta MCP, FTS5 gotchas, sin trazabilidad | `mcp__registry__fn_search` |
|
||||
| `sqlite3 registry.db "SELECT ... FROM proposals"` | Mismo problema | `mcp__registry__fn_proposal` |
|
||||
| `python -c "import metabase; dir(metabase)"` para descubrir helpers | Fuente de verdad = registry, no `__init__.py` | `mcp__registry__fn_search "metabase"` + `mcp__registry__fn_show <id>` |
|
||||
| Heredoc que reescribe logica que ya existe como funcion del registry | Reinvento + perdida de capitalizacion | Buscar primero; si falta, delegar a `fn-constructor` (no escribir inline) |
|
||||
| `client._http.request(...)` saltando wrapper del registry | Salta validacion del wrapper y telemetria | Usar wrapper; si firma incompleta, `fn proposal add --kind improve_function` |
|
||||
| Scripts en `temp/` para composiciones que se repiten >2 veces | Codigo perdido + sin monitoreo | Pipeline en `python/functions/pipelines/` o `bash/functions/pipelines/` |
|
||||
| `from <pkg> import *` en heredoc | Imposible identificar funciones usadas | Imports explicitos `from <domain> import <name1>, <name2>` |
|
||||
|
||||
### Excepciones autorizadas para `sqlite3` directo
|
||||
|
||||
Casos donde el MCP no aplica y `sqlite3 registry.db` es legitimo:
|
||||
|
||||
- Introspeccion de schema: `.schema`, `.tables`, `PRAGMA table_info(...)`, `PRAGMA index_list(...)`.
|
||||
- Agregaciones: `COUNT(*)`, `GROUP BY`, `SUM(...)`, `AVG(...)`.
|
||||
- JOINs custom entre tablas que el MCP no expone (`functions JOIN unit_tests ON ...`).
|
||||
- Columnas que el MCP no devuelve (rare; preferir proponer ampliacion del MCP).
|
||||
|
||||
El hook `PreToolUse` (`.claude/scripts/hook_registry_mcp.sh`) ya deja pasar estas excepciones y solo avisa cuando ve `sqlite3 registry.db "SELECT ..."` plano.
|
||||
|
||||
### Excepcion: hooks e infraestructura de telemetria (issue 0087)
|
||||
|
||||
Los **hooks** (`PreToolUse`, `PostToolUse`, `UserPromptSubmit`, etc.) y los **binarios de infraestructura** que sirven al agente (`fn_match`, `fn doctor`, `call_monitor`) **pueden leer `registry.db` directo** via `sqlite3` o `database/sql` con conexion read-only. NO estan sujetos a la regla MCP-first porque:
|
||||
|
||||
- No son acciones del agente — son inspeccion automatizada del entorno.
|
||||
- El MCP requiere tool invocation por Claude; un hook no puede invocar tools.
|
||||
- Latencia objetivo (50-200ms) incompatible con round-trip MCP.
|
||||
|
||||
**Restricciones:**
|
||||
- SOLO lectura. Conexion debe abrirse con `?mode=ro` o `?_query_only=1`.
|
||||
- NUNCA escritura a `registry.db` desde hooks.
|
||||
- Si un hook necesita escribir (cache, telemetria propia), usa su propia DB (`operations.db` del app de hooks, o `~/.fn_hooks/cache.db`).
|
||||
|
||||
Esta excepcion es **explicita y acotada** — no aplica al agente, que sigue regido por la regla MCP-first.
|
||||
|
||||
### Verificacion previa — `fn doctor`
|
||||
|
||||
Antes de empezar trabajo no trivial sobre el registry, ejecutar `fn doctor` para confirmar que el ecosistema esta sano:
|
||||
|
||||
- Artefactos OK (sin `git_not_initialized`, `venv_broken_path`, etc.).
|
||||
- Services activos cuando se necesiten (`sqlite_api`, `registry_api`, `registry_mcp`).
|
||||
- Sin drift `pc_locations` vs disco.
|
||||
- Sin drift `uses_functions` vs imports reales.
|
||||
|
||||
Si `fn doctor` reporta `service inactive` para `registry_mcp.service`, el MCP estara siendo invocado en modo stdio por Claude Code (normal); el systemd unit solo aplica al modo HTTP. Si el binario no responde, rebuild: `cd apps/registry_mcp && CGO_ENABLED=1 go build -tags fts5 -o registry_mcp .`.
|
||||
|
||||
### Tools MCP disponibles
|
||||
|
||||
| Tool | Lectura/escritura | Gating |
|
||||
|---|---|---|
|
||||
| `fn_search` | read | siempre on |
|
||||
| `fn_show` | read | siempre on |
|
||||
| `fn_code` | read | siempre on |
|
||||
| `fn_uses` | read | siempre on |
|
||||
| `fn_list_domains` | read | siempre on |
|
||||
| `fn_proposal` | read | siempre on |
|
||||
| `fn_doctor` | read | siempre on |
|
||||
| `fn_run` | execute (mutating side-effects) | requiere `--enable-run` |
|
||||
| `fn_create_function` | write | requiere `--enable-write` |
|
||||
|
||||
### Heredoc Python — convenciones obligatorias
|
||||
|
||||
Cuando el caso 3 (composicion) sea inevitable:
|
||||
|
||||
1. **Imports explicitos** desde paquetes del registry. Nunca `import *`.
|
||||
2. **No reescribir** la firma de una funcion del registry — importarla.
|
||||
3. **Args via env vars o stdin JSON**, nunca interpolacion shell directa (inyeccion).
|
||||
4. **Output a stdout JSON** cuando vaya a ser consumido por el siguiente paso.
|
||||
5. **Si el heredoc supera ~30 lineas**, extraer a `python/functions/pipelines/`. El monitor avisara automaticamente cuando un patron similar se repita >5 veces.
|
||||
|
||||
### Trazabilidad — bucle reactivo
|
||||
|
||||
Cada evento alimenta a `call_monitor.db` (event-log append-only) y se rollupea en una vista `function_stats` con contadores por funcion del registry. Tablas event-log:
|
||||
|
||||
| Tabla | Captura |
|
||||
|---|---|
|
||||
| `calls` | Cada invocacion (heredoc/mcp/fn_run): function_id, tool_used, duration_ms, success, error_class, args_hash |
|
||||
| `code_writes` | Cada Edit/Write sobre archivo del registry: function_id, session_id, lines_added/removed |
|
||||
| `test_runs` | Cada `go test`/`pytest` que toca codigo del registry: function_id, test_id, passed, duration_ms |
|
||||
| `e2e_runs_fn` | Cada check `e2e_checks` de app que usa la funcion: function_id, app_id, check_id, passed |
|
||||
| `violations` | Antipatron detectado: rule_id, session_id, command_snippet, severity |
|
||||
| `patterns` | Heredocs clusterizados: pattern_hash, session_ids[], occurrences, representative_snippet |
|
||||
| `sessions` | session_id, cwd, started_at, ended_at, health_score, mcp_ratio |
|
||||
|
||||
Vista agregada `function_stats` por `function_id`:
|
||||
|
||||
- **Uso:** `calls_total`, `calls_24h/7d/30d/90d`, `last_used_at`
|
||||
- **Errores:** `errors_total`, `error_rate`, `last_error_class`, `last_error_ts`
|
||||
- **Performance:** `mean_duration_ms`, `p95_duration_ms`
|
||||
- **Codigo:** `writes_count`, `last_write_at`
|
||||
- **Tests:** `tests_total`, `tests_failed`, `test_fail_rate`, `last_test_failed_at`
|
||||
- **E2E:** `e2e_total`, `e2e_failed`, `e2e_fail_rate`, `consumer_apps_count`
|
||||
- **Salud:** `violations_caused`
|
||||
|
||||
Assertions derivadas → proposals automaticas:
|
||||
|
||||
| Regla | Threshold | Proposal |
|
||||
|---|---|---|
|
||||
| Huerfana absoluta | `calls_90d=0 AND writes_count=0` | `deprecate_function` |
|
||||
| Bug prioritario | `error_rate>0.1 AND calls_7d>5` | `improve_function` (bug) |
|
||||
| Regresion performance | `p95_24h > 1.5 * p95_30d` | `improve_function` (perf) |
|
||||
| Test flaky | `test_fail_rate>0.1 AND tests_total>10` | `improve_function` (flaky) |
|
||||
| Wrapper saltado | `violations_caused>3` | `improve_function` (API gap) |
|
||||
| Patron inline sin funcion | `patterns.occurrences>5 AND no match FTS` | `new_function` con snippet |
|
||||
| Blast radius alto | `e2e_fail_rate>0 AND consumer_apps_count>=3` | `improve_function` (critical) |
|
||||
|
||||
Datos sensibles: solo `args_hash`, NUNCA valores concretos. Snippets de error redactados via allowlist.
|
||||
|
||||
### Capas de monitorizacion (issue 0085)
|
||||
|
||||
Cobertura por capa, no todas activas a la vez:
|
||||
|
||||
| # | Capa | Activacion | Cobertura |
|
||||
|---|---|---|---|
|
||||
| 1 | Hook PostToolUse Bash | siempre (settings.local.json) | mcp, fn_cli_run, edit_registry, violations |
|
||||
| 2 | Wrapper Python `registry_telemetry` | `FN_TELEMETRY=1` env var | heredocs + notebooks Jupyter |
|
||||
| 3 | Wrapper Bash `telemetry_prelude.sh` | `source` explicito o `FN_TELEMETRY=1` | heredoc bash + apps bash |
|
||||
| 4 | Interceptor en `fn run` | siempre (binario Go) | duration/error real de invocacion CLI |
|
||||
| 5 | `fn doctor copied-code` | comando manual / cron | drift estatico: codigo copiado en apps |
|
||||
| 6 | `function_versions` + snapshot | poblado por `fn index` + edit-hook | historial de versiones |
|
||||
| 7-8 | Build-tag Go / macro C++ | opt-in por app | runtime de app (futuro) |
|
||||
|
||||
**Boundary:** monitorizamos al **agente** y a **invocaciones canonicas**. Runtime de apps Go/C++ compiladas queda fuera. Compensar con tests + `e2e_checks` (issue 0068).
|
||||
|
||||
### Que NO se monitoriza
|
||||
|
||||
- Funcion Go/C++ llamada internamente por app ya compilada.
|
||||
- Funcion ejecutada por systemd timer / cron / Dagu sin pasar por `fn run`.
|
||||
- Sub-agente (`Agent` tool) — sus tools no propagan a hook del padre.
|
||||
- Service de produccion recibiendo HTTP.
|
||||
|
||||
**Implicacion:** una funcion con `calls_90d=0` puede ser huerfana real O usada en runtime invisible. Antes de proponer `deprecate_function`, cruzar con `consumer_apps_count > 0` (e2e) o con `fn doctor uses-functions` (declaraciones estaticas).
|
||||
@@ -0,0 +1,50 @@
|
||||
## Registry-first: reutilizar antes que escribir, delegar antes que escribir inline
|
||||
|
||||
**OBLIGATORIO para todos los artefactos** (apps, analyses, projects, playgrounds, services). El registry existe para que las apps se compongan a partir de funciones probadas. No respetar esto convierte cada app en una isla con codigo duplicado y bugs unicos.
|
||||
|
||||
### Flujo obligatorio antes de escribir codigo en un artefacto
|
||||
|
||||
1. **Consultar registry.db con FTS5** para encontrar funciones existentes que cubran el caso. No es opcional. Buscar por `name`, `description`, `tags`, `signature`, `code` y `params_schema`. Probar varios sinonimos (`http`, `serve`, `router`; `id`, `uuid`, `random_hex`; etc.).
|
||||
|
||||
2. **Reutilizar lo que existe**. Importar la funcion del registry y declararla en `uses_functions` del `app.md`. NO reescribir logica inline cuando ya hay una funcion.
|
||||
|
||||
3. **Si falta una pieza reutilizable → delegar a `fn-constructor`** (subagent_type `fn-constructor`). NO escribir la funcion inline en el artefacto. El agente construye la funcion en su sitio (`functions/{domain}/`, `python/functions/{domain}/`, etc.) con `.go/.py/.sh/.ts` + `.md` correctos, tests, y respetando las reglas de pureza/firma.
|
||||
|
||||
4. **Solo despues** se escribe el codigo del artefacto, que orquesta funciones del registry y aporta unicamente la logica especifica del dominio (CRUD de tablas concretas, layout de UI, flujo de la app).
|
||||
|
||||
### Que va al registry vs que va al artefacto
|
||||
|
||||
| Tipo de codigo | Donde |
|
||||
|---|---|
|
||||
| Logica reutilizable, primitiva, generica (parser, helper http, abrir SQLite, generar IDs, formatear timestamps con politica fija, middleware, etc.) | `functions/{domain}/` — delegar a `fn-constructor` si no existe |
|
||||
| Composicion de varias funciones del registry para un flujo concreto | `pipelines` (registry) o codigo del artefacto segun reusabilidad |
|
||||
| Schema SQL especifico del artefacto | Migraciones del artefacto |
|
||||
| Handlers HTTP que solo hacen sentido para este artefacto (ej. `/api/board` de un kanban) | Codigo del artefacto, pero usando `http_json_response_go_infra`, `http_parse_body_go_infra`, etc. del registry |
|
||||
| Layout/components especificos de la UI del artefacto | Codigo del artefacto, pero consumiendo componentes de `frontend/functions/ui/` (`@fn_library`) |
|
||||
|
||||
Regla practica: **si dos artefactos ya hacen o haran lo mismo, es funcion del registry**. One-liners idiomaticos de la stdlib (`time.Now().UTC().Format(...)`) NO necesitan ser registry — se ven en cualquier sitio. Pero un patron como "abrir SQLite con WAL + foreign keys + ping" SI (y por eso existe `sqlite_open_go_infra`).
|
||||
|
||||
### Cuando delegar a `fn-constructor`
|
||||
|
||||
Delegar SIEMPRE que se necesite una funcion reutilizable que no existe. El prompt del subagente debe incluir:
|
||||
- Lenguaje, dominio, nombre propuesto.
|
||||
- Firma esperada (params + return).
|
||||
- Pureza (`pure` o `impure`).
|
||||
- Una breve descripcion del proposito y del comportamiento.
|
||||
- Si hay funciones similares en el registry, listarlas para evitar duplicados.
|
||||
|
||||
El agente construye la funcion siguiendo las reglas del registry (`purity.md`, `ids_naming.md`, `types_in_signatures.md`, etc.) y deja `fn index` listo para ejecutar.
|
||||
|
||||
### Auditoria
|
||||
|
||||
Despues de implementar el artefacto, verificar que `uses_functions` del `app.md` (o equivalente) declara TODAS las funciones del registry consumidas. Esto se puede cruzar con los `import` reales del codigo:
|
||||
|
||||
```bash
|
||||
# Para Go:
|
||||
grep -rh '"fn-registry/functions/' apps/<app>/ | sort -u
|
||||
# Cada paquete importado tiene que tener al menos una funcion declarada en uses_functions.
|
||||
```
|
||||
|
||||
### Por que esta regla
|
||||
|
||||
Sin esta regla cada app reinventa: helpers SQLite, middleware HTTP, generacion de IDs, parsers, validadores, formateo de fechas. El registry pierde su razon de ser. Con esta regla, una funcion bien hecha se reutiliza en N apps; un bug se arregla una vez; la velocidad de cada app nueva crece a medida que el registry crece.
|
||||
@@ -0,0 +1,35 @@
|
||||
## uses_functions
|
||||
|
||||
Cuando un .cpp llama a otra funcion del registry, el `.md` del CONSUMIDOR
|
||||
debe anadir la dependencia a `uses_functions`. El indexer NO lo deduce
|
||||
automaticamente para C++ (parser no trivial).
|
||||
|
||||
Como auditar (funciones huerfanas):
|
||||
sqlite3 registry.db "SELECT id FROM functions WHERE lang='cpp' AND uses_functions='[]';"
|
||||
|
||||
Como auditar (drift entre `CMakeLists.txt` y `app.md`):
|
||||
- Cruzar los `${CMAKE_SOURCE_DIR}/functions/<dom>/<name>.cpp` listados en el
|
||||
`CMakeLists.txt` con el `uses_functions` del `app.md`. Cada `.cpp` linkado
|
||||
debe aparecer como `<name>_cpp_<dom>` en el `.md`. Excepciones: ver mas abajo.
|
||||
|
||||
Convencion:
|
||||
- **Framework code** (`cpp/framework/app_base.cpp`) — no esta indexado.
|
||||
- **Funciones bundled en `fn_framework`** — son funciones del registry cuyo
|
||||
`.cpp` se compila dentro del static lib `fn_framework` (lista en
|
||||
`cpp/CMakeLists.txt`, target `add_library(fn_framework STATIC ...)`):
|
||||
`tokens`, `icon_font`, `app_settings`, `app_about`, `fps_overlay`,
|
||||
`panel_menu`, `app_menubar`, `layouts_menu`, `logger`, `log_window`,
|
||||
`gl_loader`, `layout_storage`, `selectable_text`. Las apps las usan
|
||||
transitivamente (incluyen `core/logger.h`, llaman `fn_log::log_info`),
|
||||
pero NO listan estos `.cpp` en su `CMakeLists.txt` (multiple-definition)
|
||||
ni los declaran en `uses_functions` del `app.md`. Excepcion: si una app
|
||||
toca una API que no este en fn_framework (raro), declara la dep.
|
||||
- **TU adicional de un parent function** (ej. `graph_labels_select.cpp` que
|
||||
va con `graph_labels.cpp`) — desde 2026-05-04 se registra como entrada
|
||||
propia con su `.md` (ver ADR 0003). El parent declara la nueva entrada
|
||||
en su `uses_functions`. Las apps que enlazan ambos `.cpp` listan ambas
|
||||
IDs en `uses_functions` del `app.md`.
|
||||
- **Apps** (`apps/`, `cpp/apps/`, `projects/*/apps/`) son leaves del grafo:
|
||||
declaran `uses_functions` en `app.md` pero ninguna funcion del registry
|
||||
las cita.
|
||||
- DEMO_ONLY en `primitives_gallery` se etiqueta `notes: scaffolding/demo`.
|
||||
Executable
+159
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Extract Claude Design "standalone" HTML exports.
|
||||
|
||||
Claude Design packs the whole React app as base64+gzip blobs inside
|
||||
<script type="__bundler/manifest"> tags. This script decompresses them
|
||||
and writes each asset (JSX, CSS, fonts) to a target directory.
|
||||
|
||||
Usage:
|
||||
python3 extract_design_bundle.py <path/to/export.html> <output_dir>
|
||||
|
||||
The output dir will contain:
|
||||
data.jsx (if detected by header comment)
|
||||
fn_library_emu.jsx (lib emulation)
|
||||
charts_emu.jsx (charts emulation)
|
||||
app.jsx (main tree)
|
||||
<uuid>.<ext> (anything else — fonts, unknown js)
|
||||
manifest.json (summary of all assets: uuid, mime, bytes, filename)
|
||||
|
||||
JSX files are named heuristically from their leading comment. If names
|
||||
cannot be inferred from headers, they keep their uuid prefix.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import gzip
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
MIME_TO_EXT = {
|
||||
"text/javascript": "js",
|
||||
"application/javascript": "js",
|
||||
"text/babel": "jsx",
|
||||
"application/json": "json",
|
||||
"text/css": "css",
|
||||
"image/svg+xml": "svg",
|
||||
"font/woff2": "woff2",
|
||||
"font/woff": "woff",
|
||||
"text/html": "html",
|
||||
}
|
||||
|
||||
# Order matters: first matching hint wins. Put MORE SPECIFIC patterns first.
|
||||
HEADER_HINTS = [
|
||||
("charts_emu.jsx", [r"Emulaci(ó|o)n de @fn_library/\{", r"LineChart, AreaChart, BarChart"]),
|
||||
("fn_library_emu.jsx", [r"Emulaci(ó|o)n visual de @fn_library"]),
|
||||
("data.jsx", [r"mock data \(determinista\)", r"window\.\w+Data\s*="]),
|
||||
("app.jsx", [r"ReactDOM\.createRoot", r"arbol principal", r"function App\s*\("]),
|
||||
]
|
||||
|
||||
|
||||
def pick_name(content: str, used_names: set[str]) -> str | None:
|
||||
head = content[:2000]
|
||||
for name, patterns in HEADER_HINTS:
|
||||
if name in used_names:
|
||||
continue
|
||||
if any(re.search(p, head, re.IGNORECASE) for p in patterns):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def grab_script(html: str, kind: str) -> str | None:
|
||||
m = re.search(
|
||||
r'<script type="__bundler/' + kind + r'">\s*(.*?)\s*</script>',
|
||||
html, re.DOTALL,
|
||||
)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def extract(html_path: pathlib.Path, out_dir: pathlib.Path) -> dict:
|
||||
html = html_path.read_text(encoding="utf-8")
|
||||
|
||||
manifest_raw = grab_script(html, "manifest")
|
||||
if not manifest_raw:
|
||||
raise SystemExit(f"No <script type='__bundler/manifest'> found in {html_path}")
|
||||
manifest = json.loads(manifest_raw)
|
||||
|
||||
ext_raw = grab_script(html, "ext_resources")
|
||||
ext_resources = json.loads(ext_raw) if ext_raw else []
|
||||
id_map = {e["uuid"]: e.get("id", e["uuid"]) for e in ext_resources}
|
||||
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
summary = []
|
||||
used_names: set[str] = set()
|
||||
|
||||
# First pass: decode all assets and collect jsx blobs (so we can name them by header hint)
|
||||
decoded: list[tuple[str, str, bytes]] = [] # (uuid, mime, bytes)
|
||||
for uuid, entry in manifest.items():
|
||||
raw = base64.b64decode(entry["data"])
|
||||
if entry.get("compressed"):
|
||||
raw = gzip.decompress(raw)
|
||||
decoded.append((uuid, entry.get("mime", "application/octet-stream"), raw))
|
||||
|
||||
# Second pass: write files with heuristic names for known jsx
|
||||
for uuid, mime, raw in decoded:
|
||||
ext = MIME_TO_EXT.get(mime, "bin")
|
||||
filename = None
|
||||
|
||||
# Heuristic for JSX / JS that represents the app
|
||||
if ext in ("jsx", "js"):
|
||||
try:
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
name = pick_name(text, used_names)
|
||||
if name:
|
||||
filename = name
|
||||
used_names.add(name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not filename:
|
||||
# Fall back to ext_resources id if present, or uuid
|
||||
base = id_map.get(uuid, uuid)
|
||||
safe = re.sub(r"[^A-Za-z0-9._-]", "_", base)[:80]
|
||||
filename = f"{safe}.{ext}"
|
||||
|
||||
path = out_dir / filename
|
||||
# Avoid collisions
|
||||
i = 2
|
||||
while path.exists():
|
||||
stem = path.stem
|
||||
path = out_dir / f"{stem}_{i}.{ext}"
|
||||
i += 1
|
||||
path.write_bytes(raw)
|
||||
summary.append({
|
||||
"uuid": uuid,
|
||||
"mime": mime,
|
||||
"bytes": len(raw),
|
||||
"filename": path.name,
|
||||
})
|
||||
|
||||
(out_dir / "manifest.json").write_text(
|
||||
json.dumps({"source": str(html_path), "assets": summary}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return {"assets": summary, "out": str(out_dir)}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print(__doc__)
|
||||
sys.exit(2)
|
||||
html = pathlib.Path(sys.argv[1])
|
||||
out = pathlib.Path(sys.argv[2])
|
||||
if not html.exists():
|
||||
sys.exit(f"Input not found: {html}")
|
||||
result = extract(html, out)
|
||||
print(f"✓ Extracted {len(result['assets'])} assets to {result['out']}")
|
||||
print(f" Manifest: {out}/manifest.json")
|
||||
print()
|
||||
# Short preview per asset
|
||||
for a in result["assets"]:
|
||||
print(f" {a['mime']:28s} {a['bytes']:>8} B {a['filename']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+243
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env bash
|
||||
# PostToolUse hook: registra cada invocacion del agente en
|
||||
# projects/fn_monitoring/apps/call_monitor/operations.db (issue 0085b).
|
||||
#
|
||||
# Identifica tool, extrae function_id cuando es posible, clasifica el patron
|
||||
# (mcp_*, fn_cli_run, heredoc_py, sqlite_direct, edit_registry, ...) y
|
||||
# detecta antipatrones para registrar violations.
|
||||
#
|
||||
# NUNCA bloquea la herramienta. Falla silenciosamente si la BD no esta lista.
|
||||
# Solo guarda args_hash, jamas valores concretos.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---- Resolve registry root (walks up from cwd looking for registry.db) ----
|
||||
resolve_root() {
|
||||
local d="${PWD}"
|
||||
while [ "$d" != "/" ]; do
|
||||
if [ -f "$d/registry.db" ]; then
|
||||
printf '%s' "$d"
|
||||
return 0
|
||||
fi
|
||||
d=$(dirname "$d")
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
ROOT=$(resolve_root) || exit 0
|
||||
DB="$ROOT/projects/fn_monitoring/apps/call_monitor/operations.db"
|
||||
|
||||
# Si la BD aun no existe, el hook no hace nada (esperando init).
|
||||
[ -f "$DB" ] || exit 0
|
||||
|
||||
# ---- Read stdin JSON ----
|
||||
INPUT=$(cat)
|
||||
if [ -z "$INPUT" ]; then exit 0; fi
|
||||
|
||||
# Required jq presence
|
||||
command -v jq >/dev/null 2>&1 || exit 0
|
||||
command -v sqlite3 >/dev/null 2>&1 || exit 0
|
||||
|
||||
TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // ""')
|
||||
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // ""')
|
||||
TS=$(date -u +%s)
|
||||
|
||||
# Tool response success/error
|
||||
SUCCESS=1
|
||||
ERROR_CLASS=""
|
||||
ERROR_SNIPPET=""
|
||||
RESP_IS_ERROR=$(printf '%s' "$INPUT" | jq -r 'if (.tool_response | type) == "object" then (.tool_response.is_error // false) else false end')
|
||||
if [ "$RESP_IS_ERROR" = "true" ]; then
|
||||
SUCCESS=0
|
||||
ERROR_SNIPPET=$(printf '%s' "$INPUT" | jq -r 'if (.tool_response | type) == "object" then (.tool_response.error // .tool_response.content // "") else "" end' | head -c 240 | tr '\n' ' ')
|
||||
fi
|
||||
|
||||
# args_hash: sha256 truncado del tool_input (sin valores)
|
||||
ARGS_HASH=$(printf '%s' "$INPUT" | jq -c '.tool_input // {}' | sha256sum | cut -c1-16)
|
||||
|
||||
# Helpers SQL
|
||||
sql_escape() { printf '%s' "$1" | sed "s/'/''/g"; }
|
||||
|
||||
insert_call() {
|
||||
local fn_id="$1" tool_used="$2" duration_ms="${3:-0}" snippet="${4:-}"
|
||||
local fn_esc tu_esc ec_esc es_esc sid_esc ah_esc snip_esc
|
||||
# Politica issue 0087: command_snippet solo se rellena cuando function_id
|
||||
# esta vacio. Si la call golpea una funcion del registry, su ID y
|
||||
# tool_used bastan; no duplicamos el comando.
|
||||
if [ -n "$fn_id" ]; then snippet=""; fi
|
||||
# Redact common secrets antes de persistir
|
||||
snippet=$(printf '%s' "$snippet" \
|
||||
| sed -E 's/(password|token|secret|api[_-]?key|bearer)([[:space:]]*[=:][[:space:]]*)[^[:space:]]+/\1\2<REDACTED>/Ig' \
|
||||
| head -c 200)
|
||||
fn_esc=$(sql_escape "$fn_id")
|
||||
tu_esc=$(sql_escape "$tool_used")
|
||||
ec_esc=$(sql_escape "$ERROR_CLASS")
|
||||
es_esc=$(sql_escape "$ERROR_SNIPPET")
|
||||
sid_esc=$(sql_escape "$SESSION_ID")
|
||||
ah_esc=$(sql_escape "$ARGS_HASH")
|
||||
snip_esc=$(sql_escape "$snippet")
|
||||
sqlite3 "$DB" "INSERT INTO calls (session_id, function_id, tool_used, args_hash, duration_ms, success, error_class, error_snippet, command_snippet, ts) VALUES ('$sid_esc','$fn_esc','$tu_esc','$ah_esc',$duration_ms,$SUCCESS,'$ec_esc','$es_esc','$snip_esc',$TS);" 2>/dev/null || true
|
||||
}
|
||||
|
||||
insert_code_write() {
|
||||
local fn_id="$1" file_path="$2" added="${3:-0}" removed="${4:-0}"
|
||||
local fn_esc fp_esc sid_esc
|
||||
fn_esc=$(sql_escape "$fn_id")
|
||||
fp_esc=$(sql_escape "$file_path")
|
||||
sid_esc=$(sql_escape "$SESSION_ID")
|
||||
sqlite3 "$DB" "INSERT INTO code_writes (session_id, function_id, file_path, lines_added, lines_removed, ts) VALUES ('$sid_esc','$fn_esc','$fp_esc',$added,$removed,$TS);" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Snapshot a function version row when an edit lands on a registry file.
|
||||
# Uses sha256 of file bytes as content_hash (separate namespace from index source).
|
||||
insert_edit_version() {
|
||||
local fn_id="$1" abs_path="$2"
|
||||
[ -f "$abs_path" ] || return 0
|
||||
command -v sha256sum >/dev/null 2>&1 || return 0
|
||||
local hash
|
||||
hash=$(sha256sum "$abs_path" 2>/dev/null | awk '{print $1}')
|
||||
[ -z "$hash" ] && return 0
|
||||
local fn_esc h_esc
|
||||
fn_esc=$(sql_escape "$fn_id")
|
||||
h_esc=$(sql_escape "$hash")
|
||||
sqlite3 "$DB" "INSERT OR IGNORE INTO function_versions (function_id, content_hash, version, snapped_at, source, lines_added, lines_removed) VALUES ('$fn_esc','$h_esc','',$TS,'edit_hook',0,0);" 2>/dev/null || true
|
||||
}
|
||||
|
||||
insert_violation() {
|
||||
local rule_id="$1" fn_id="$2" snippet="$3" severity="${4:-warning}"
|
||||
local r_esc fn_esc sn_esc sev_esc sid_esc
|
||||
r_esc=$(sql_escape "$rule_id")
|
||||
fn_esc=$(sql_escape "$fn_id")
|
||||
sn_esc=$(sql_escape "$(printf '%s' "$snippet" | head -c 240 | tr '\n' ' ')")
|
||||
sev_esc=$(sql_escape "$severity")
|
||||
sid_esc=$(sql_escape "$SESSION_ID")
|
||||
sqlite3 "$DB" "INSERT INTO violations (session_id, rule_id, function_id, command_snippet, severity, ts) VALUES ('$sid_esc','$r_esc','$fn_esc','$sn_esc','$sev_esc',$TS);" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ---- Derive function_id from registry file path ----
|
||||
# Matches paths under functions/<domain>/<name>.<ext>, python/functions/<domain>/<name>.py,
|
||||
# bash/functions/<domain>/<name>.sh, frontend/functions/<domain>/<name>.ts(x)
|
||||
derive_fn_id_from_path() {
|
||||
local p="$1"
|
||||
[ -z "$p" ] && return 1
|
||||
case "$p" in
|
||||
functions/*/*.go|*/functions/*/*.go)
|
||||
local dom name
|
||||
dom=$(printf '%s' "$p" | sed -E 's|.*functions/([^/]+)/.*|\1|')
|
||||
name=$(printf '%s' "$p" | sed -E 's|.*functions/[^/]+/([^/.]+)\..*|\1|')
|
||||
[ -n "$dom" ] && [ -n "$name" ] && printf '%s_go_%s' "$name" "$dom" && return 0 ;;
|
||||
python/functions/*/*.py)
|
||||
local dom name
|
||||
dom=$(printf '%s' "$p" | sed -E 's|python/functions/([^/]+)/.*|\1|')
|
||||
name=$(printf '%s' "$p" | sed -E 's|python/functions/[^/]+/([^/.]+)\..*|\1|')
|
||||
[ -n "$dom" ] && [ -n "$name" ] && printf '%s_py_%s' "$name" "$dom" && return 0 ;;
|
||||
bash/functions/*/*.sh)
|
||||
local dom name
|
||||
dom=$(printf '%s' "$p" | sed -E 's|bash/functions/([^/]+)/.*|\1|')
|
||||
name=$(printf '%s' "$p" | sed -E 's|bash/functions/[^/]+/([^/.]+)\..*|\1|')
|
||||
[ -n "$dom" ] && [ -n "$name" ] && printf '%s_bash_%s' "$name" "$dom" && return 0 ;;
|
||||
frontend/functions/*/*.ts|frontend/functions/*/*.tsx)
|
||||
local dom name
|
||||
dom=$(printf '%s' "$p" | sed -E 's|frontend/functions/([^/]+)/.*|\1|')
|
||||
name=$(printf '%s' "$p" | sed -E 's|frontend/functions/[^/]+/([^/.]+)\..*|\1|')
|
||||
[ -n "$dom" ] && [ -n "$name" ] && printf '%s_ts_%s' "$name" "$dom" && return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---- Dispatch by tool ----
|
||||
case "$TOOL_NAME" in
|
||||
mcp__registry__fn_search)
|
||||
insert_call "" "mcp_fn_search"
|
||||
;;
|
||||
mcp__registry__fn_show)
|
||||
ID=$(printf '%s' "$INPUT" | jq -r '.tool_input.id // ""')
|
||||
insert_call "$ID" "mcp_fn_show"
|
||||
;;
|
||||
mcp__registry__fn_code)
|
||||
ID=$(printf '%s' "$INPUT" | jq -r '.tool_input.id // ""')
|
||||
insert_call "$ID" "mcp_fn_code"
|
||||
;;
|
||||
mcp__registry__fn_uses)
|
||||
ID=$(printf '%s' "$INPUT" | jq -r '.tool_input.id // ""')
|
||||
insert_call "$ID" "mcp_fn_uses"
|
||||
;;
|
||||
mcp__registry__fn_run)
|
||||
ID=$(printf '%s' "$INPUT" | jq -r '.tool_input.id // ""')
|
||||
insert_call "$ID" "mcp_fn_run"
|
||||
;;
|
||||
mcp__registry__fn_list_domains)
|
||||
insert_call "" "mcp_fn_list_domains"
|
||||
;;
|
||||
mcp__registry__fn_proposal)
|
||||
insert_call "" "mcp_fn_proposal"
|
||||
;;
|
||||
mcp__registry__fn_doctor)
|
||||
insert_call "" "mcp_fn_doctor"
|
||||
;;
|
||||
mcp__registry__fn_create_function)
|
||||
insert_call "" "mcp_fn_create_function"
|
||||
;;
|
||||
|
||||
Edit|Write|MultiEdit)
|
||||
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // ""')
|
||||
ABS_PATH="$FILE_PATH"
|
||||
# Make path relative to root if absolute and inside root
|
||||
case "$FILE_PATH" in
|
||||
"$ROOT"/*) FILE_PATH="${FILE_PATH#$ROOT/}" ;;
|
||||
/*) ABS_PATH="$FILE_PATH" ;;
|
||||
*) ABS_PATH="$ROOT/$FILE_PATH" ;;
|
||||
esac
|
||||
FN_ID=$(derive_fn_id_from_path "$FILE_PATH" || true)
|
||||
if [ -n "$FN_ID" ]; then
|
||||
insert_code_write "$FN_ID" "$FILE_PATH" 0 0
|
||||
insert_call "$FN_ID" "edit_registry"
|
||||
insert_edit_version "$FN_ID" "$ABS_PATH"
|
||||
fi
|
||||
;;
|
||||
|
||||
Bash)
|
||||
CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""')
|
||||
CMD_HEAD=$(printf '%s' "$CMD" | head -c 200 | tr '\n' ' ')
|
||||
|
||||
# Classify
|
||||
TOOL_USED="bash_other"
|
||||
FN_ID=""
|
||||
|
||||
if printf '%s' "$CMD" | grep -qE '(^|[[:space:]])\./fn[[:space:]]+run[[:space:]]+'; then
|
||||
TOOL_USED="fn_cli_run"
|
||||
FN_ID=$(printf '%s' "$CMD" | sed -nE 's/.*\.\/fn[[:space:]]+run[[:space:]]+([A-Za-z0-9_]+).*/\1/p' | head -n1)
|
||||
elif printf '%s' "$CMD" | grep -qE 'python/\.venv/bin/python3[[:space:]]+-[[:space:]]+<<'; then
|
||||
TOOL_USED="heredoc_py"
|
||||
elif printf '%s' "$CMD" | grep -qE 'sqlite3[[:space:]][^|]*\bregistry\.db\b'; then
|
||||
TOOL_USED="sqlite_direct"
|
||||
fi
|
||||
|
||||
insert_call "$FN_ID" "$TOOL_USED" 0 "$CMD_HEAD"
|
||||
|
||||
# ---- Violation rules ----
|
||||
# 1. sqlite3 directo SELECT sobre registry.db (excepto schema/pragma/count/join)
|
||||
if [ "$TOOL_USED" = "sqlite_direct" ]; then
|
||||
if ! printf '%s' "$CMD" | grep -qiE '(\.schema|\.tables|PRAGMA[[:space:]]+(table_info|index_list)|COUNT\(|GROUP[[:space:]]+BY|JOIN[[:space:]])'; then
|
||||
insert_violation "sqlite3_registry_select" "" "$CMD_HEAD" "warning"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. python -c "import X; dir(X)"
|
||||
if printf '%s' "$CMD" | grep -qE 'python[3]?[[:space:]]+-c[[:space:]]+["'\''].*import.*(dir|help)\('; then
|
||||
insert_violation "python_dir_inspect" "" "$CMD_HEAD" "info"
|
||||
fi
|
||||
|
||||
# 3. from <pkg> import * (en heredoc python)
|
||||
if [ "$TOOL_USED" = "heredoc_py" ]; then
|
||||
if printf '%s' "$CMD" | grep -qE 'from[[:space:]]+[A-Za-z0-9_.]+[[:space:]]+import[[:space:]]+\*'; then
|
||||
insert_violation "import_star_in_heredoc" "" "$CMD_HEAD" "warning"
|
||||
fi
|
||||
if printf '%s' "$CMD" | grep -qE 'client\._http\.request\('; then
|
||||
insert_violation "client_http_request_direct" "" "$CMD_HEAD" "warning"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
Executable
+121
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
# UserPromptSubmit hook: inyecta capacidades calientes (TOP/FRESH/PIPELINES)
|
||||
# del registry como additionalContext en cada turno del usuario.
|
||||
#
|
||||
# Cache: ~/.cache/fn_registry/capabilities.txt (TTL 1h).
|
||||
# Fuente: `./fn doctor capabilities --emit-claude-md` desde la raiz del repo.
|
||||
#
|
||||
# NUNCA bloquea: si algo falla, emite contexto vacio y sale 0.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
CACHE_DIR="${HOME}/.cache/fn_registry"
|
||||
CACHE_FILE="${CACHE_DIR}/capabilities.txt"
|
||||
TTL_SECONDS=3600
|
||||
|
||||
# Resolve registry root (walks up from cwd, fallback CLAUDE_PROJECT_DIR)
|
||||
resolve_root() {
|
||||
local d="${PWD}"
|
||||
while [ "$d" != "/" ]; do
|
||||
if [ -f "$d/registry.db" ] && [ -x "$d/fn" ]; then
|
||||
printf '%s' "$d"
|
||||
return 0
|
||||
fi
|
||||
d=$(dirname "$d")
|
||||
done
|
||||
if [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -f "${CLAUDE_PROJECT_DIR}/registry.db" ]; then
|
||||
printf '%s' "${CLAUDE_PROJECT_DIR}"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Consume stdin (UserPromptSubmit payload) — we don't need it but keep stdin clean
|
||||
cat >/dev/null 2>&1 || true
|
||||
|
||||
ROOT=$(resolve_root) || exit 0
|
||||
mkdir -p "$CACHE_DIR" 2>/dev/null || exit 0
|
||||
|
||||
# Cache freshness check
|
||||
need_refresh=1
|
||||
if [ -f "$CACHE_FILE" ]; then
|
||||
now=$(date +%s)
|
||||
mtime=$(stat -c %Y "$CACHE_FILE" 2>/dev/null || stat -f %m "$CACHE_FILE" 2>/dev/null || echo 0)
|
||||
age=$((now - mtime))
|
||||
if [ "$age" -lt "$TTL_SECONDS" ]; then
|
||||
need_refresh=0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$need_refresh" -eq 1 ]; then
|
||||
# Regenerate: call fn doctor capabilities --emit-claude-md and process
|
||||
raw=$("$ROOT/fn" doctor capabilities --emit-claude-md 2>/dev/null || true)
|
||||
if [ -z "$raw" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract top 5 from each section using awk.
|
||||
# Sections detected by "## ... Top" / "## ... Fresh" / "## ... Pipelines".
|
||||
line=$(printf '%s\n' "$raw" | awk '
|
||||
BEGIN { sec=""; n_top=0; n_fresh=0; n_pipe=0; }
|
||||
/^## .*Top 20/ { sec="TOP"; next }
|
||||
/^## .*Fresh/ { sec="FRESH"; next }
|
||||
/^## .*Pipelines/ { sec="PIPE"; next }
|
||||
/^## / { sec=""; next }
|
||||
/^- `/ {
|
||||
# extract first backticked token
|
||||
s = $0
|
||||
sub(/^- `/, "", s)
|
||||
i = index(s, "`")
|
||||
if (i == 0) next
|
||||
id = substr(s, 1, i-1)
|
||||
if (sec == "TOP" && n_top < 5) { tops[n_top++] = id }
|
||||
if (sec == "FRESH" && n_fresh < 5) { fresh[n_fresh++] = id }
|
||||
if (sec == "PIPE" && n_pipe < 5) { pipes[n_pipe++] = id }
|
||||
}
|
||||
END {
|
||||
out = "CAPABILITIES (cache 1h):"
|
||||
if (n_top > 0) {
|
||||
line = " TOP: " tops[0]
|
||||
for (i=1; i<n_top; i++) line = line ", " tops[i]
|
||||
out = out "\n" line
|
||||
}
|
||||
if (n_fresh > 0) {
|
||||
line = " FRESH (7d): " fresh[0]
|
||||
for (i=1; i<n_fresh; i++) line = line ", " fresh[i]
|
||||
out = out "\n" line
|
||||
}
|
||||
if (n_pipe > 0) {
|
||||
line = " PIPELINES: " pipes[0]
|
||||
for (i=1; i<n_pipe; i++) line = line ", " pipes[i]
|
||||
out = out "\n" line
|
||||
}
|
||||
print out
|
||||
}
|
||||
')
|
||||
|
||||
if [ -z "$line" ]; then
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' "$line" >"$CACHE_FILE" 2>/dev/null || exit 0
|
||||
fi
|
||||
|
||||
# Emit cached content as additionalContext
|
||||
if [ ! -s "$CACHE_FILE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ctx=$(cat "$CACHE_FILE")
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq -n --arg ctx "$ctx" '{
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "UserPromptSubmit",
|
||||
additionalContext: $ctx
|
||||
}
|
||||
}'
|
||||
else
|
||||
# Fallback: print raw text (Claude Code prints stdout as context too)
|
||||
printf '%s\n' "$ctx"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
# PostToolUse hook: gate "tag de capability group obligatorio" tras crear/modificar
|
||||
# funciones del registry. Issue 0086 paso 9/gate.
|
||||
#
|
||||
# Comportamiento:
|
||||
# - Detecta .md de funciones (functions/, python/functions/, bash/functions/,
|
||||
# frontend/functions/, cpp/functions/) modificados en los ultimos 60s.
|
||||
# - Lee frontmatter `tags:` y verifica si al menos uno coincide con un capability
|
||||
# group declarado en docs/capabilities/INDEX.md.
|
||||
# - Si NO hay match -> emite additionalContext con la lista de funciones afectadas.
|
||||
# - NUNCA bloquea. Solo warning visible.
|
||||
#
|
||||
# Salida JSON consumida por Claude Code:
|
||||
# { "hookSpecificOutput": { "hookEventName": "PostToolUse",
|
||||
# "additionalContext": "..." } }
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
resolve_root() {
|
||||
local d="${PWD}"
|
||||
while [ "$d" != "/" ]; do
|
||||
if [ -f "$d/registry.db" ]; then
|
||||
printf '%s' "$d"
|
||||
return 0
|
||||
fi
|
||||
d=$(dirname "$d")
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
ROOT=$(resolve_root) || exit 0
|
||||
INDEX="$ROOT/docs/capabilities/INDEX.md"
|
||||
|
||||
# Si no existe el INDEX aun, no hay grupos definidos -> nada que verificar.
|
||||
[ -f "$INDEX" ] || exit 0
|
||||
|
||||
# Consume stdin (sin parsear — no necesitamos session_id para este gate)
|
||||
cat >/dev/null
|
||||
|
||||
# Solo correr si hay jq disponible
|
||||
command -v jq >/dev/null 2>&1 || exit 0
|
||||
|
||||
# 1. Cargar lista de capability groups desde el INDEX.
|
||||
# Formato esperado en INDEX.md: | [name](name.md) | N | descripcion |
|
||||
CAP_GROUPS=$(grep -oE '\[[a-z][a-z0-9_-]*\]\([a-z][a-z0-9_-]*\.md\)' "$INDEX" \
|
||||
| sed -E 's/^\[([^]]+)\].*/\1/' \
|
||||
| sort -u)
|
||||
|
||||
[ -z "$CAP_GROUPS" ] && exit 0
|
||||
|
||||
# 2. Encontrar .md de funciones modificados en ultimos 60s.
|
||||
RECENT=$(find "$ROOT/functions" "$ROOT/python/functions" "$ROOT/bash/functions" \
|
||||
"$ROOT/frontend/functions" "$ROOT/cpp/functions" \
|
||||
-maxdepth 4 -type f -name '*.md' -mmin -1 2>/dev/null || true)
|
||||
|
||||
[ -z "$RECENT" ] && exit 0
|
||||
|
||||
# 3. Para cada .md reciente: extraer tags del frontmatter, comparar con groups.
|
||||
MISSING=""
|
||||
while IFS= read -r mdfile; do
|
||||
[ -z "$mdfile" ] && continue
|
||||
# Extrae el bloque entre los dos `---` del inicio
|
||||
front=$(awk '/^---$/{c++; next} c==1 {print} c>=2 {exit}' "$mdfile" 2>/dev/null || true)
|
||||
[ -z "$front" ] && continue
|
||||
|
||||
# tags: [a, b, c] o tags:\n - a\n - b
|
||||
tags_inline=$( { printf '%s\n' "$front" | grep -E '^tags:[[:space:]]*\[' | head -1 \
|
||||
| sed -E 's/^tags:[[:space:]]*\[(.*)\].*$/\1/' \
|
||||
| tr ',' '\n' | sed -E 's/^[[:space:]"]+|[[:space:]"]+$//g'; } || true )
|
||||
|
||||
tags_block=$( { printf '%s\n' "$front" | awk '
|
||||
/^tags:[[:space:]]*$/ {intag=1; next}
|
||||
intag && /^[[:space:]]*-[[:space:]]/ {sub(/^[[:space:]]*-[[:space:]]*/, ""); print; next}
|
||||
intag && !/^[[:space:]]/ {intag=0}
|
||||
' | sed -E 's/^[[:space:]"]+|[[:space:]"]+$//g'; } || true )
|
||||
|
||||
tags=$( { printf '%s\n%s\n' "$tags_inline" "$tags_block" | grep -v '^$'; } || true )
|
||||
|
||||
matched=0
|
||||
while IFS= read -r g; do
|
||||
[ -z "$g" ] && continue
|
||||
if printf '%s\n' "$tags" | grep -qx "$g"; then
|
||||
matched=1
|
||||
break
|
||||
fi
|
||||
done <<< "$CAP_GROUPS"
|
||||
|
||||
if [ "$matched" -eq 0 ]; then
|
||||
rel="${mdfile#$ROOT/}"
|
||||
MISSING="${MISSING}${rel}\n"
|
||||
fi
|
||||
done <<< "$RECENT"
|
||||
|
||||
# 4. Si hay funciones sin tag de grupo, emitir aviso.
|
||||
if [ -n "$MISSING" ]; then
|
||||
CAP_GROUPS_CSV=$(printf '%s' "$CAP_GROUPS" | tr '\n' ',' | sed 's/,$//')
|
||||
WARN="CAPABILITY-GAP (issue 0086): funcion(es) recien tocada(s) sin tag de capability group: $(printf '%b' "$MISSING" | tr '\n' ' ')"
|
||||
WARN+="| Grupos disponibles: ${CAP_GROUPS_CSV}. Anade al menos uno al frontmatter \`tags:\` y corre \`./fn index\`. Si la funcion no encaja en ningun grupo existente, considera crear grupo nuevo (>=3 funciones) o dejarla con tag plano (no de grupo)."
|
||||
jq -n --arg ctx "$WARN" '{
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PostToolUse",
|
||||
additionalContext: $ctx
|
||||
}
|
||||
}'
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+133
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse hook: sugiere funciones del registry cuando un comando Bash
|
||||
# inline probablemente reinventa una funcion existente (issue 0087).
|
||||
#
|
||||
# Llama a `./fn match "<cmd>"` con timeout 200ms. Si encaja con alta
|
||||
# confianza, imprime un <system-reminder> a stderr para que Claude Code
|
||||
# lo lea como recordatorio. NUNCA bloquea la tool — exit 0 siempre.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---- Always exit 0, no matter what ----
|
||||
trap 'exit 0' ERR
|
||||
|
||||
# ---- Resolve registry root (walks up from cwd) ----
|
||||
resolve_root() {
|
||||
local d="${PWD}"
|
||||
while [ "$d" != "/" ]; do
|
||||
if [ -f "$d/registry.db" ]; then
|
||||
printf '%s' "$d"
|
||||
return 0
|
||||
fi
|
||||
d=$(dirname "$d")
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
ROOT=$(resolve_root) || exit 0
|
||||
FN_BIN="$ROOT/fn"
|
||||
[ -x "$FN_BIN" ] || exit 0
|
||||
|
||||
# ---- Read stdin JSON ----
|
||||
command -v jq >/dev/null 2>&1 || exit 0
|
||||
INPUT=$(cat)
|
||||
[ -z "$INPUT" ] && exit 0
|
||||
|
||||
TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null || echo "")
|
||||
[ "$TOOL_NAME" = "Bash" ] || exit 0
|
||||
|
||||
CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "")
|
||||
[ -z "$CMD" ] && exit 0
|
||||
|
||||
# Single-line for matching against denylist patterns
|
||||
CMD_FLAT=$(printf '%s' "$CMD" | tr '\n' ' ')
|
||||
|
||||
# ---- Denylist (skip antes de llamar fn match para ahorrar el invoke) ----
|
||||
|
||||
# Comandos demasiado cortos -> trivial
|
||||
CMD_LEN=${#CMD_FLAT}
|
||||
[ "$CMD_LEN" -lt 20 ] && exit 0
|
||||
|
||||
# Trivial single-utility commands
|
||||
case "$CMD_FLAT" in
|
||||
"ls"|"ls "*|"cd"|"cd "*|"pwd"|"pwd "*|"cat"|"cat "*|"echo"|"echo "*)
|
||||
exit 0 ;;
|
||||
"grep"|"grep "*|"head"|"head "*|"tail"|"tail "*|"wc"|"wc "*)
|
||||
exit 0 ;;
|
||||
"mkdir"|"mkdir "*|"rm"|"rm "*|"mv"|"mv "*|"cp"|"cp "*)
|
||||
exit 0 ;;
|
||||
"git"|"git "*)
|
||||
exit 0 ;;
|
||||
"go"|"go "*)
|
||||
# go build / go test corrientes — el agente ya los maneja
|
||||
exit 0 ;;
|
||||
esac
|
||||
|
||||
# Comandos que ya usan el registry: ./fn ..., fn run ..., mcp__registry__*
|
||||
if printf '%s' "$CMD_FLAT" | grep -qE '(^|[[:space:]])\./fn([[:space:]]|$)'; then
|
||||
exit 0
|
||||
fi
|
||||
if printf '%s' "$CMD_FLAT" | grep -qE '(^|[[:space:]])fn[[:space:]]+(run|search|show|code|uses|doctor|index|match|list|add|proposal|sync|ops|check)'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Pure-cd (movement only, no logic)
|
||||
if printf '%s' "$CMD_FLAT" | grep -qE '^[[:space:]]*cd[[:space:]]+[^&|;]+$'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---- Llamar fn match con timeout 200ms ----
|
||||
command -v timeout >/dev/null 2>&1 || exit 0
|
||||
|
||||
# Truncar el comando a algo razonable para fn match (evitar args huge)
|
||||
CMD_TRUNC=$(printf '%s' "$CMD_FLAT" | head -c 500)
|
||||
|
||||
MATCH_JSON=$(timeout 0.2 "$FN_BIN" match "$CMD_TRUNC" --format json --top 3 2>/dev/null) || exit 0
|
||||
[ -z "$MATCH_JSON" ] && exit 0
|
||||
|
||||
# ---- Parsear JSON ----
|
||||
HIGH_CONF=$(printf '%s' "$MATCH_JSON" | jq -r '.high_confidence // false' 2>/dev/null || echo "false")
|
||||
TOP_ID=$(printf '%s' "$MATCH_JSON" | jq -r '.top[0].id // ""' 2>/dev/null || echo "")
|
||||
TOP_SCORE=$(printf '%s' "$MATCH_JSON" | jq -r '.top[0].score // 0' 2>/dev/null || echo "0")
|
||||
TOP_SIG=$(printf '%s' "$MATCH_JSON" | jq -r '.top[0].signature // ""' 2>/dev/null || echo "")
|
||||
TOP_SNIP=$(printf '%s' "$MATCH_JSON" | jq -r '.top[0].snippet // ""' 2>/dev/null || echo "")
|
||||
|
||||
[ -z "$TOP_ID" ] && exit 0
|
||||
|
||||
# Trigger condition: (high_confidence==true OR score>=0.85) AND score>=0.6
|
||||
# - high_confidence requires top1/top2 gap > 1.5 (set por fn match)
|
||||
# - score>=0.85 cubre matches muy fuertes donde el gap es modesto
|
||||
SCORE_HI=$(awk -v s="$TOP_SCORE" 'BEGIN{ print (s+0 >= 0.85) ? "1" : "0" }')
|
||||
SCORE_MIN=$(awk -v s="$TOP_SCORE" 'BEGIN{ print (s+0 >= 0.6) ? "1" : "0" }')
|
||||
|
||||
[ "$SCORE_MIN" = "1" ] || exit 0
|
||||
if [ "$HIGH_CONF" != "true" ] && [ "$SCORE_HI" != "1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Truncar snippet a 100 chars y limpiar saltos de linea
|
||||
SNIP_SHORT=$(printf '%s' "$TOP_SNIP" | tr '\n' ' ' | head -c 100)
|
||||
|
||||
# Formatear score con 2 decimales
|
||||
SCORE_FMT=$(awk -v s="$TOP_SCORE" 'BEGIN{ printf "%.2f", s+0 }')
|
||||
|
||||
# ---- Emitir <system-reminder> a stderr ----
|
||||
cat >&2 <<EOF
|
||||
<system-reminder>FUZZY-MATCH (issue 0087): your Bash command may already be a function.
|
||||
USE: ./fn run $TOP_ID -> $TOP_SIG
|
||||
SNIPPET: $SNIP_SHORT
|
||||
Confidence: $SCORE_FMT. If you proceed inline, the violation will be logged.
|
||||
</system-reminder>
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
|
||||
# Test manual:
|
||||
# echo '{"tool_name":"Bash","tool_input":{"command":"taskkill.exe /IM registry_dashboard.exe /F"},"session_id":"test"}' \
|
||||
# | bash .claude/scripts/hook_fn_match.sh
|
||||
#
|
||||
# Casos silenciosos:
|
||||
# echo '{"tool_name":"Bash","tool_input":{"command":"ls -la"},"session_id":"test"}' \
|
||||
# | bash .claude/scripts/hook_fn_match.sh
|
||||
# echo '{"tool_name":"Bash","tool_input":{"command":"./fn run filter_slice_go_core 1 2 3"},"session_id":"test"}' \
|
||||
# | bash .claude/scripts/hook_fn_match.sh
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
# UserPromptSubmit hook: recordatorio compacto de patrones canonicos del registry.
|
||||
# Inyectado como additionalContext en cada turno del usuario.
|
||||
# Issue 0085 (hardening 2).
|
||||
#
|
||||
# NUNCA bloquea. Solo printf de additionalContext.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Resolve registry root (walks up from cwd)
|
||||
resolve_root() {
|
||||
local d="${PWD}"
|
||||
while [ "$d" != "/" ]; do
|
||||
if [ -f "$d/registry.db" ]; then
|
||||
printf '%s' "$d"
|
||||
return 0
|
||||
fi
|
||||
d=$(dirname "$d")
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
ROOT=$(resolve_root) || exit 0
|
||||
|
||||
# Read input, extract session_id (UserPromptSubmit payload includes it)
|
||||
INPUT=$(cat)
|
||||
SESSION_ID=""
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // ""' 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# Count current pending proposals + recent violations for situational awareness
|
||||
PROPOSALS_PENDING="?"
|
||||
VIOLATIONS_24H="?"
|
||||
CALLS_24H="?"
|
||||
CAP_CREATED=0
|
||||
CAP_USED=0
|
||||
CAP_ORPHAN=0
|
||||
|
||||
if command -v sqlite3 >/dev/null 2>&1; then
|
||||
REG="$ROOT/registry.db"
|
||||
MON="$ROOT/projects/fn_monitoring/apps/call_monitor/operations.db"
|
||||
[ -f "$REG" ] && PROPOSALS_PENDING=$(sqlite3 "$REG" "SELECT COUNT(*) FROM proposals WHERE status='pending'" 2>/dev/null || echo "?")
|
||||
if [ -f "$MON" ]; then
|
||||
VIOLATIONS_24H=$(sqlite3 "$MON" "SELECT COUNT(*) FROM violations WHERE ts >= CAST(strftime('%s','now','-1 day') AS INTEGER)" 2>/dev/null || echo "?")
|
||||
CALLS_24H=$(sqlite3 "$MON" "SELECT COUNT(*) FROM calls WHERE ts >= CAST(strftime('%s','now','-1 day') AS INTEGER)" 2>/dev/null || echo "?")
|
||||
if [ -n "$SESSION_ID" ]; then
|
||||
sid_esc=$(printf '%s' "$SESSION_ID" | sed "s/'/''/g")
|
||||
CAP_CREATED=$(sqlite3 "$MON" "SELECT COUNT(*) FROM session_capability_growth WHERE session_id='$sid_esc'" 2>/dev/null || echo 0)
|
||||
CAP_USED=$(sqlite3 "$MON" "SELECT COUNT(*) FROM session_capability_growth WHERE session_id='$sid_esc' AND calls_in_session>0" 2>/dev/null || echo 0)
|
||||
CAP_ORPHAN=$(sqlite3 "$MON" "SELECT COUNT(*) FROM session_capability_growth WHERE session_id='$sid_esc' AND calls_in_session=0" 2>/dev/null || echo 0)
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
REMINDER="REGISTRY-FIRST (issue 0085 telemetry active): "
|
||||
REMINDER+="Inspect → mcp__registry__fn_search/show/code/uses/proposal. "
|
||||
REMINDER+="Execute one fn → mcp__registry__fn_run or ./fn run. "
|
||||
REMINDER+="Compose multi-fn → heredoc python IMPORTANDO del registry. "
|
||||
REMINDER+="NUNCA sqlite3 registry.db directo (salvo schema/PRAGMA/COUNT/JOIN). "
|
||||
REMINDER+="NUNCA reescribir inline logica que ya es funcion. "
|
||||
REMINDER+="Si patron se repite >2x → propose nueva funcion via fn-constructor. "
|
||||
REMINDER+="Estado: pending_proposals=${PROPOSALS_PENDING} violations_24h=${VIOLATIONS_24H} calls_24h=${CALLS_24H}. "
|
||||
REMINDER+="CAPABILITY-GROWTH (issue 0086): created_this_session=${CAP_CREATED} used=${CAP_USED} orphan=${CAP_ORPHAN}. Si orphan>0 -> integra la funcion en el codigo o documenta por que se quedo huerfana. "
|
||||
REMINDER+="Comando autocheck: /fn_claude."
|
||||
|
||||
jq -n --arg ctx "$REMINDER" '{
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "UserPromptSubmit",
|
||||
additionalContext: $ctx
|
||||
}
|
||||
}'
|
||||
Executable
+28
@@ -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
|
||||
+27
-1
@@ -1,6 +1,8 @@
|
||||
# SQLite index — journal/wal temporales
|
||||
# SQLite index — regenerable con `fn index` + completable con `fn sync`
|
||||
registry.db
|
||||
registry.db-journal
|
||||
registry.db-wal
|
||||
registry.db-shm
|
||||
|
||||
# operations.db — datos vivos, cada app genera el suyo con fn ops init
|
||||
**/operations.db
|
||||
@@ -37,12 +39,34 @@ python/.venv/
|
||||
apps/*/
|
||||
analysis/*/
|
||||
|
||||
# Projects (each is its own git repo, only project.md templates are versioned)
|
||||
projects/*/
|
||||
|
||||
# Vaults — data stores (symlinks, dirs, files); only vault.yaml manifest is versioned
|
||||
vaults/*/
|
||||
!vaults/vault.yaml
|
||||
|
||||
# Node / pnpm
|
||||
**/node_modules/
|
||||
|
||||
# Sources — repos externos clonados (solo se versiona el manifest)
|
||||
sources/*/
|
||||
|
||||
# Subrepos — mirrors/espejos externos (cada uno su propio git remote)
|
||||
subrepos/*/
|
||||
|
||||
# External — symlinks a repos ajenos (ej: repo_Claude con skills/commands)
|
||||
external/
|
||||
|
||||
# Worktrees — git worktrees para issues paralelos (parallel-fix-issues)
|
||||
worktrees/
|
||||
|
||||
# Claude runtime locks
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
# Temp — workspace efimero para pruebas rapidas (APIs, scripts, analisis)
|
||||
temp/
|
||||
|
||||
# C++ build artifacts
|
||||
cpp/build/
|
||||
|
||||
@@ -55,3 +79,5 @@ Thumbs.db
|
||||
|
||||
broken_paths.txt
|
||||
imgui.ini
|
||||
prompts/
|
||||
kotlin/functions/ui/
|
||||
|
||||
+11
-2
@@ -8,6 +8,15 @@
|
||||
[submodule "cpp/vendor/tracy"]
|
||||
path = cpp/vendor/tracy
|
||||
url = https://github.com/wolfpld/tracy.git
|
||||
[submodule "/home/lucas/fn_registry/cpp/vendor/glfw"]
|
||||
path = /home/lucas/fn_registry/cpp/vendor/glfw
|
||||
[submodule "cpp/vendor/glfw"]
|
||||
path = cpp/vendor/glfw
|
||||
url = https://github.com/glfw/glfw.git
|
||||
[submodule "cpp/vendor/implot3d"]
|
||||
path = cpp/vendor/implot3d
|
||||
url = https://github.com/brenocq/implot3d.git
|
||||
[submodule "cpp/vendor/sdl3"]
|
||||
path = cpp/vendor/sdl3
|
||||
url = https://github.com/libsdl-org/SDL.git
|
||||
[submodule "emsdk"]
|
||||
path = emsdk
|
||||
url = https://github.com/emscripten-core/emsdk.git
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"registry": {
|
||||
"command": "./apps/registry_mcp/registry_mcp",
|
||||
"args": ["--enable-run", "--enable-write"]
|
||||
}
|
||||
}
|
||||
}
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
# Changelog
|
||||
|
||||
Todos los cambios notables de `fn_registry` se documentan aquí.
|
||||
|
||||
Formato basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/). Al no haber releases semver formales, las entradas se ordenan por fecha.
|
||||
|
||||
Para contexto detallado del trabajo diario ver `docs/diary/`. Para decisiones arquitecturales ver `docs/adr/`.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## 2026-05-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Issue 0086 — Monitor tab del `registry_dashboard`** (sub-repo `dataforge/registry_dashboard`). Pestaña `Monitor` primera y por defecto del TabBar, landing del bucle reactivo construir->ejecutar->recopilar->analizar->mejorar.
|
||||
- 7 KPIs (Calls / MCP / Reg % / Errors / Violations / Copies / Versions) filtradas por ventana temporal (1h/24h/7d/30d/All).
|
||||
- Sub-tab `Recent Executions` con columnas When/Function/Tool/ms/OK/Error. Columna Function muestra `$ <snippet>` en gris cuando `function_id` vacio, hover tooltip con comando completo. Checkbox `Only registry functions` filtra por `function_id != ''`.
|
||||
- Sub-tab `Failed Functions` (5a) — subset filtrado a registry-functions fallidas, columnas When/Function/Tool/Error class/Error snippet, function_id en rojo.
|
||||
- Live scatter `duracion (ms)` vs `time`: eje X auto-scroll a `now`, ventana configurable (1m/5m/15m/1h/6h) independiente del filtro de KPIs, eje Y dinamico `0..max(visible)+500ms`. Hora local (`UseLocalTime`). Series ok/error en verde/rojo. Hover sobre punto = tooltip Function/Tool/Duration/Error.
|
||||
- Indicador `live`/`offline` con timestamp del ultimo evento WS.
|
||||
- **WebSocket live stream sqlite_api -> registry_dashboard** (sub-repo `dataforge/sqlite_api`). Endpoint `GET /api/events/call_monitor`. Hub global con subscribers; ticker arranca solo con >=1 subscriber (cero overhead si nadie mira). Cliente recibe snapshot inicial (KPIs + 100 ultimas filas + watermark) y luego deltas `id > watermark`. Cliente puede mandar `{watermark: N}` para resumir tras reconexion.
|
||||
- **WS client C++** hand-rolled RFC6455 en `ws_client.{h,cpp}` (~330 LOC) en el dashboard. Localhost-only (no TLS). Thread propio, reconnect exponencial 0.5s->8s, FIN/text/ping/pong/close handling, queue thread-safe drenada cada frame.
|
||||
- **Migration 007 `command_snippet` en `calls`** (`projects/fn_monitoring/apps/call_monitor/migrations/007_calls_command_snippet.sql`). Aditiva, idempotente. Llena por hook `hook_call_monitor.sh` solo cuando `function_id == ''`. Redactado de `password=`/`token=`/`secret=`/`api_key=`/`bearer=`. Truncado 200 chars.
|
||||
- **Issue 0087 — Capability Discovery Acceleration**. Modelo 5 capas + 7 piezas (ver `dev/issues/0087-*.md`).
|
||||
- **`fn match`** (`cmd/fn/match.go`) — subcommand fuzzy-FTS5 que dado un comando devuelve top-N funciones del registry candidates. Latencia 6-7ms. Output JSON con `score` (normalizado top=1.0) + `raw_score` (absoluto pre-normalizacion) + `high_confidence` gate (`raw_score >= 4.0 AND top1.raw/top2.raw > 1.5`).
|
||||
- **`fn doctor capabilities --emit-claude-md`** (`cmd/fn/doctor.go` + `functions/infra/emit_capabilities_md.go`) — emite bloque markdown con secciones TOP 20 (por `calls_total`), Fresh 7d, Pipelines top 5. Fallback si `call_monitor.operations.db` ausente.
|
||||
- **`call_monitor sequences --detect [--propose]`** (`projects/fn_monitoring/apps/call_monitor/sequences.go` + `migrations/006_function_sequences.sql`). Detecta secuencias A->B(->C) en `calls` (same session, gap < 30s, occ >= 5, sess >= 2, success_rate >= 0.9) y abre proposals `new_pipeline` automaticamente.
|
||||
- **Hook `PreToolUse` `hook_fn_match.sh`** — denylist + `fn match` con timeout 0.2s. Inyecta `<system-reminder>FUZZY-MATCH: USE ./fn run <id>` cuando confidence alta. Latencia 113ms trigger / 32ms denylist. Registrado en `.claude/settings.local.json` (Bash matcher).
|
||||
- **Hook `UserPromptSubmit` `hook_capabilities_inject.sh`** — cache 1h en `~/.cache/fn_registry/capabilities.txt`. Emite JSON `hookSpecificOutput.additionalContext` con linea compacta `CAPABILITIES: TOP / FRESH / PIPELINES`. Latencia cold 33ms / warm 18ms.
|
||||
- **Timer systemd user** `call_monitor_sequences.timer` (OnCalendar 0/6h) + `.service` oneshot ejecutando `call_monitor sequences --detect --propose --report`. Versionado en `projects/fn_monitoring/apps/call_monitor/systemd/`.
|
||||
- **3 funciones nuevas grupo `cpp-windows`** + pagina madre `docs/capabilities/cpp-windows.md`:
|
||||
- `launch_cpp_app_windows_bash_infra` — `cmd.exe`/`PowerShell Start-Process` para lanzar exe en Windows desde WSL2.
|
||||
- `is_cpp_app_running_windows_bash_infra` — `tasklist.exe /FI` con exit code 0/1 + stdout `RUNNING: PID=N MEM=K` o `NOT_RUNNING`.
|
||||
- `redeploy_cpp_app_windows_bash_pipelines` — pipeline build? + deploy + launch + verify en 1 invocacion. Reemplaza ~6 commands manuales.
|
||||
- **ADR 0004 `docs/adr/0004-telemetry-driven-capability-growth.md`** — formaliza el bucle telemetria -> proposal -> capability group -> discovery acceleration como motor de crecimiento del registry.
|
||||
- **Regla `.claude/rules/function_growth_and_self_docs.md`** (entry #30 en `INDEX.md`) — contrato `.md` autosuficiente (Ejemplo + Cuando usarla + Gotchas + Growth log) + crecimiento del registry por promocion de composiciones, NO por inflado de funciones individuales.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`.claude/CLAUDE.md` Norte ampliado** — 4o objetivo `PROMOVER COMPOSICIONES A PIPELINES` (el registry crece por composicion, no por inflado). Linea sobre auto-discovery zero-second-lookup.
|
||||
- **`.claude/rules/registry_calls.md`** — clausula nueva: hooks e infraestructura de telemetria (`fn_match`, `fn doctor`, `call_monitor`) pueden leer `registry.db` directo con conexion read-only. NO sujeto a regla MCP-first (no son acciones del agente).
|
||||
- **`/fn_claude` command** mejorado con objetivos del Monitor + interpretacion de `FUZZY-MATCH` hint + `CAPABILITIES` line + threshold semantica.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`launch_cpp_app_windows` quoting bug** — `cmd.exe /c "cd /d \"$dir\" && start ..."` rompia con paths Windows (el `\"` final se interpretaba como escape de comilla -> string sin cerrar -> "Windows cannot find \\"). Fix: reescribir a `powershell.exe -Command "Start-Process -FilePath ... -WorkingDirectory ..."` (single-quote PowerShell es literal, sin procesar `\` ni `$`).
|
||||
- **`fn match high_confidence` siempre true** — debido a normalizacion `top=1.0`. Fix: añadir `raw_score` preservado pre-normalizacion + gate dual `raw_score >= 4.0 AND top1.raw/top2.raw > 1.5`. Threshold 4.0 tuneado contra 14 patrones del analysis `domain_coverage_gaps` (~93% precision).
|
||||
|
||||
## 2026-05-07
|
||||
|
||||
### Added
|
||||
|
||||
- **`fn doctor` CLI** (`cmd/fn/doctor.go`) — entrypoint unico read-only para diagnostico del registry y artefactos. Subcomandos: `artefacts` (git/venv/app.md/upstream), `services` (apps tag service + systemctl + puerto), `sync` (drift `pc_locations` BD vs disco), `uses-functions` (imports reales vs declarados en `app.md`), `unused` (funciones sin consumidores). Flag `--json` para agentes/scripts. Cada subcomando es wrapper fino sobre una funcion del registry.
|
||||
- `.claude/rules/fn_doctor.md` — regla 23 en `INDEX.md`. Documenta cuando usar, mapeo subcomando → funcion del registry, y acciones derivadas (que hacer cuando reporta un drift).
|
||||
- `bash/functions/infra/backup_sqlite_db` (`backup_sqlite_db_bash_infra`, **impure**) — snapshot atomico de SQLite via `VACUUM INTO`. Mas seguro que `cp` con escrituras concurrentes.
|
||||
- `bash/functions/infra/rotate_backups` (`rotate_backups_bash_infra`, **impure**) — retention rsnapshot-style `daily.N/weekly.M/monthly.K`.
|
||||
- `bash/functions/infra/wait_for_http` (`wait_for_http_bash_infra`, **impure**) — poll URL hasta 2xx con timeout, util en deploys/smoke tests.
|
||||
- `bash/functions/infra/wait_for_port` (`wait_for_port_bash_infra`, **impure**) — poll TCP host:puerto. Usa `nc` o `/dev/tcp` builtin (sin deps).
|
||||
- `bash/functions/infra/port_kill` (`port_kill_bash_infra`, **impure**) — mata proceso(s) escuchando un puerto. Idempotente, fallback `KILL` tras `TERM`.
|
||||
- `bash/functions/infra/tail_journal` (`tail_journal_bash_infra`, **impure**) — wrapper `journalctl` con auto-deteccion `--user` vs sistema, prioridad y `--since`.
|
||||
- `bash/functions/infra/pre_commit_hook_install` (`pre_commit_hook_install_bash_infra`, **impure**) — instala hook que llama `scan_secrets_in_dirty_bash_cybersecurity` antes de cada commit. Idempotente con marca `fn_registry-pre-commit-v1`.
|
||||
- `functions/infra/notify_telegram` (`notify_telegram_go_infra`, **impure**) — envia mensaje a chat Telegram via Bot API. Trunca >4096 chars.
|
||||
- `functions/infra/artefact_doctor` (`artefact_doctor_go_infra`, **impure**) — audita salud de cada app/analysis: dir existe, `.git` presente, manifest parseable, `.venv` valido (analyses), upstream configurado.
|
||||
- `functions/infra/services_status` (`services_status_go_infra`, **impure**) — apps con tag `service` + `systemctl is-active` (user/system) + puerto declarado en notes/description + check TCP localhost.
|
||||
- `functions/infra/pc_locations_drift` (`pc_locations_drift_go_infra`, **impure**) — detecta drift `pc_locations` BD vs disco para el PC actual (`~/.fn_pc`). Tres tipos: `missing_on_disk`, `untracked_on_disk`, `status_should_be_active`.
|
||||
- `functions/infra/audit_uses_functions` (`audit_uses_functions_go_infra`, **impure**) — para cada app Go/Py compara imports reales contra `uses_functions` del `app.md`. Reporta `missing_in_app_md` y `unused_in_app_md`. Heuristica documentada (puede dar falsos positivos en `unused`).
|
||||
- `functions/infra/find_unused_functions` (`find_unused_functions_go_infra`, **impure**) — funciones del registry sin consumidores en otras funciones, apps o analyses. Pipelines sin tag `launcher` tambien aparecen.
|
||||
- `bash/functions/pipelines/backup_all` (`backup_all_bash_pipelines`, **impure**, tag `launcher`) — orquesta `backup_sqlite_db` + `rotate_backups` sobre `registry.db`, cada `apps/*/operations.db`, y rsync `--link-dest` para vaults declarados en `projects/*/vaults/vault.yaml`.
|
||||
|
||||
### Changed
|
||||
|
||||
- `.claude/CLAUDE.md` — seccion CLI ampliada con comandos `fn doctor [subcommand] [--json]` y enlace a la regla.
|
||||
- `.claude/rules/INDEX.md` — anadida fila 23 para `fn_doctor.md`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `functions/infra/pc_locations_drift.go` — `filepath.Join(absoluto, absoluto)` producia paths corruptos cuando `dir_path` ya era absoluto (caso comun: filas `pc_locations` traen path absoluto al disco del PC). Fix: chequear `filepath.IsAbs` antes de unir. Sintoma previo: todos los artefactos reportados como `missing_on_disk` aunque existieran.
|
||||
- `go.mod` — `golang.org/x/net` movido a deps directas (`go mod tidy` tras anadir `notify_telegram`).
|
||||
|
||||
### Notes
|
||||
|
||||
- Hallazgo de la primera ejecucion `fn doctor uses-functions`: 7/12 apps con drift real (`auto_metabase`, `dag_engine`, `deploy_server`, `docker_tui`, `kanban`, `metabase_registry`, `script_navegador`). Pendiente sincronizar sus `app.md` con los imports reales en sesion futura.
|
||||
- `fn doctor unused` muestra muchas funciones core sin consumidores aun (`compose2_go_core`, `curry2_go_core`, etc.). Esperado: el registry crece antes que las apps que las consuman.
|
||||
|
||||
## 2026-05-04
|
||||
|
||||
### Added
|
||||
|
||||
- `cpp/functions/viz/graph_labels_select` (`graph_labels_select_cpp_viz`, **pure**) — TU separado de `graph_labels` con los helpers puros `graph_compute_degrees` y `graph_labels_select` (frustum cull + always_for_* + top-N por `size * (degree+1)`). Vive en su propio archivo para que los tests unitarios lo cubran sin abrir ImGui.
|
||||
- `cpp/functions/viz/graph_viewport_selection` (`graph_viewport_selection_cpp_viz`, **pure**) — TU separado de `graph_viewport` con `clear_selection`, `is_selected`, `add_to_selection`, `toggle_selection`. Mantienen sincronizados `state.selection` y `nodes[i].flags & NF_SELECTED`.
|
||||
- `cpp/functions/viz/graph_types` (`graph_types_cpp_viz`, **pure**) — TU de implementacion de `GraphData::update_bounds()` y `GraphData::find_node_by_user_data()`. Pareja obligatoria del header del tipo (`graph_types.h` indexado en `types/viz/`).
|
||||
- `cpp/apps/chart_demo/app.md` — la demo de primitivos viz (line/scatter/bar/heatmap) ahora aparece en el registry como `chart_demo_cpp_viz`.
|
||||
- `cpp/apps/shaders_lab/app.md` — el live GLSL playground con DAG ahora tiene `app.md` propio (antes solo existia entrada legacy en BD sin `.md` en disco).
|
||||
|
||||
### Changed
|
||||
|
||||
- `registry/indexer.go` — el indexer ahora escanea tambien `<lang>/apps/*/app.md` (mismo patron que ya usaba para `<lang>/functions/` y `<lang>/types/`). Antes solo veia `apps/` y `projects/*/apps/` — las apps en `cpp/apps/` quedaban invisibles. `./fn index` reporta 17 apps (antes 15).
|
||||
- `cpp/functions/viz/graph_labels.md` — `signature` reducida a `graph_labels_draw` y `graph_labels_draw_at` (los helpers puros pasan a entrada propia). `uses_functions` apunta a la nueva entrada `graph_labels_select_cpp_viz`.
|
||||
- `cpp/functions/viz/graph_viewport.md` — `uses_functions` añade `graph_viewport_selection_cpp_viz`.
|
||||
- `projects/osint_graph/apps/graph_explorer/app.md` — `uses_functions` sincronizado con `CMakeLists.txt`: ahora declara las 23 funciones del registry que enlaza (antes 15). Añadidas: `graph_viewport_selection`, `graph_labels_select`, `graph_types`, `graph_spatial_hash`, `button`, `icon_button`, `badge`, `empty_state`.
|
||||
- `projects/fn_monitoring/apps/registry_dashboard/app.md` — `uses_functions` sincronizado con `CMakeLists.txt` (21 deps, antes 9). Añadidas: `badge`, `button`, `empty_state`, `icon_button`, `modal_dialog`, `page_header`, `process_runner`, `process_state_machine`, `select`, `text_input`, `toast`, `toolbar`, `tree_view`. Removido: `fps_overlay` (vive en `fn_framework`, no se declara).
|
||||
|
||||
### Decisions
|
||||
|
||||
- ADR `0003-orphan-tu-as-separate-function-entry.md` — cuando una funcion del registry necesita partir su `.cpp` en varios TUs por testabilidad o separacion ImGui-vs-puro, cada TU adicional se registra como entrada propia con su `.md` en lugar de extender `file_path` para listar varios archivos. El parent declara la nueva entrada en `uses_functions`. Razon: el indexer asume `1 .cpp = 1 .md`; un `file_path` multi-archivo rompe la convencion y deja apps nuevas sin saber que TUs enlazar.
|
||||
|
||||
### Added — sesion NER+RE para graph_explorer (tarde, 980 → 990 funciones)
|
||||
|
||||
**18 funciones nuevas** sobre el ecosistema NER+RE, en dos rondas de `fn-constructor`:
|
||||
|
||||
Ronda 1 — extraccion de relaciones (mREBEL/REBEL/MarianMT):
|
||||
- `python/functions/datascience/parse_rebel_output.py` (pure) — parser wire `<triplet>` REBEL/mREBEL.
|
||||
- `python/functions/datascience/align_relations_to_entities.py` (pure) — string-match aligner.
|
||||
- `python/functions/datascience/mrebel_load_model.py` (impure, **CC BY-NC-SA 4.0 — NO comercial**).
|
||||
- `python/functions/datascience/mrebel_base_load_model.py` (impure, misma licencia).
|
||||
- `python/functions/datascience/rebel_load_model.py` (impure, **Apache 2.0**, EN-only).
|
||||
- `python/functions/datascience/marianmt_es_en_load_model.py` (impure) — Helsinki-NLP/opus-mt-es-en.
|
||||
- `python/functions/datascience/translate_es_to_en.py` (impure) — wrapper traduccion frase a frase.
|
||||
- `python/functions/datascience/extract_relations_mrebel.py` (impure) — pipeline mREBEL frase-a-frase + alineamiento.
|
||||
- 21 tests pytest verdes.
|
||||
|
||||
Ronda 2 — pipeline GLiNER2 + OpenIE schema-less + composicion (tarde):
|
||||
- `python/functions/core/clean_pdf_text.py` (pure) — limpia artefactos PyPDF2.
|
||||
- `python/functions/core/chunk_with_overlap.py` (pure) — sliding window con avance forzado.
|
||||
- `python/functions/core/merge_entity_aliases.py` (pure) — coreferencia normalize+substring.
|
||||
- `python/functions/core/filter_relations_by_entity_types.py` (pure) — post-filter typed.
|
||||
- `python/functions/core/aggregate_extraction_results.py` (pure) — dedupe + Counter sobre N chunks.
|
||||
- `python/functions/datascience/gliner2_load_model.py` (impure, **Apache 2.0**) — `fastino/gliner2-large-v1`.
|
||||
- `python/functions/datascience/extract_graph_gliner2.py` (impure) — wrapper schema + threshold + include_confidence.
|
||||
- `python/functions/datascience/spacy_es_load_model.py` (impure) — `es_core_news_md` cacheado.
|
||||
- `python/functions/datascience/extract_triples_spacy_es.py` (impure) — OpenIE schema-less ES por reglas de dependencia (verbo del texto = predicado).
|
||||
- `python/functions/pipelines/extract_graph_from_text.py` (impure pipeline) — composicion E2E: chunk → extract_graph_gliner2 (×N) → aggregate → filter typed → merge aliases → grafo final.
|
||||
- 39 tests pytest verdes.
|
||||
|
||||
### Added — analysis `gliner_glirel_tuning`
|
||||
|
||||
`projects/osint_graph/analysis/gliner_glirel_tuning/` — investigacion empirica de modelos NER/RE. **9 notebooks** ejecutados:
|
||||
|
||||
| # | Notebook | Hallazgo clave |
|
||||
|---|---|---|
|
||||
| 01 | `01_gliner_glirel_tuning.ipynb` | Calibracion de thresholds GLiNER+GLiREL |
|
||||
| 02 | `02_e2e_spanish_graph.ipynb` | E2E texto ES — descubrimiento del fail de GLiREL en castellano |
|
||||
| 03 | `03_mrebel_vs_glirel.ipynb` | mREBEL gana a GLiREL pero CC BY-NC-SA |
|
||||
| 04 | `04_gliner2_winner.ipynb` ⭐ | **GLiNER2 (Apache 2.0, NER+RE joint, 340M)** elegido como motor principal |
|
||||
| 05 | `05_long_text_and_pdf.ipynb` | Pipeline PDF E2E sobre `politica_proteccion_datos.pdf` (BBVA, 89.882 chars) |
|
||||
| 06 | `06_improvements.ipynb` | Threshold 0.3 (vs default 0.5) → +187% relaciones; coref reduce 18% aislados |
|
||||
| 07 | `07_nuextract_vs_gliner2.ipynb` | NuExtract GPU 2.6× mas lento, calidad similar — descartado por defecto |
|
||||
| 08 | `08_improving_gliner2.ipynb` | snake_case verbal labels + post-filter typed = mejor combo |
|
||||
| 09 | `09_spacy_es_openie.ipynb` | spaCy ES dep-rules: schema-less, predicado = verbo del texto |
|
||||
|
||||
### Added — vault `osint_nlp_models`
|
||||
|
||||
`projects/osint_graph/vaults/osint_nlp_models` (symlink a `~/vaults/osint_nlp_models/`):
|
||||
- `models/` — fichas de gliner, glirel, mrebel, gliner2, candidates a probar.
|
||||
- `decisions/` — 3 ADRs cortos del 2026-05-04 (mrebel-over-glirel mañana, gliner2-over-mrebel tarde, license-constraint).
|
||||
- `benchmarks/corpus_v1.md` + `results_log.csv` (15 filas de experimentos).
|
||||
- `test_documents/politica_proteccion_datos.pdf` (PDF de BBVA copiado para reproducibilidad).
|
||||
|
||||
### Added — playground HTML
|
||||
|
||||
`projects/osint_graph/analysis/gliner_glirel_tuning/playground/`:
|
||||
- `server.py` — FastAPI con GLiNER2 cacheado, endpoints `GET /` (HTML) y `POST /extract` (texto → grafo).
|
||||
- `index.html` — UI: textarea, KPIs (nodos/aristas/tiempo), grafo Sigma.js, JSON exportable.
|
||||
- `static/sigma.min.js` + `graphology.umd.min.js` (servidos localmente para evitar bloqueo CDN por extensiones tipo MetaMask/SES).
|
||||
|
||||
Stack aplicado por el server:
|
||||
1. snake_case verbal labels (`works_at`, `ceo_of`, `headquartered_in`, `agreement_with`...)
|
||||
2. threshold 0.3 (configurable)
|
||||
3. chunking automatico > 1500 chars
|
||||
4. post-filter typed (`(person, organization)` validos por relacion)
|
||||
5. coreferencia normalize+substring
|
||||
6. layout server-side via `networkx.spring_layout`
|
||||
7. render Sigma.js (sin fisica → sin loops de ResizeObserver)
|
||||
|
||||
### Added — issues
|
||||
|
||||
- `dev/issues/0050-jupyter-exec-collab-client-failure.md` — bug `jupyter_exec` con cliente colaborativo + workaround documentado.
|
||||
- `projects/osint_graph/apps/graph_explorer/issues/0041-split-confidence-thresholds.md` — split `confidence_threshold` en `entity_threshold` + `relation_threshold`.
|
||||
- `projects/osint_graph/apps/graph_explorer/issues/0042-gliner2-unified-extractor.md` ⭐ — sustituir GLiREL por GLiNER2 en `extract_graph_hybrid`. Reemplaza 0042-mrebel.
|
||||
- `projects/osint_graph/apps/graph_explorer/issues/0042-mrebel-relation-extractor.md.superseded` — version mREBEL del 0042 archivada al ganar GLiNER2.
|
||||
|
||||
### Changed
|
||||
|
||||
- `cpp/CMakeLists.txt` — `_GE_DIR` y `_DASH_DIR` sobreescribibles via `-D<...>=<path>` para builds en worktrees (commit `e72d6364`). Habilita `parallel-fix-issues` sobre apps C++.
|
||||
- `python/functions/datascience/glirel_load_model.py` — workaround compat `huggingface_hub` 1.x: classmethod monkey-patch idempotente para inyectar `proxies`/`resume_download` que el HF nuevo dejo de pasar (commit `3b3378cf`).
|
||||
- Sub-repo `dataforge/graph_explorer` master local: merges `--no-ff` de `issue/0035e-polish-and-tests` (commit `f614a51`) + `issue/0013-paste-extract-panel` (commit `2a49c2b`). 125/125 tests pytest verdes. **Sin push aun** — pendiente confirmacion + validacion Windows.
|
||||
|
||||
### Fixed (bugs encontrados + raiz + fix)
|
||||
|
||||
| Bug | Raiz | Fix |
|
||||
|---|---|---|
|
||||
| `chunk_with_overlap` bucle infinito | Frase mas larga que `max_chars`, no avanzaba `i`, OOM-killed por overlap acumulado | Avance forzado: meter al menos UNA frase aunque exceda `max_chars` |
|
||||
| NuExtract degenera en texto largo | Sin `repetition_penalty`, decoder entra en bucle de tokens repetidos hasta agotar 2048 max_new_tokens | `repetition_penalty=1.15` + chunking obligatorio (179/179 chunks parsed OK tras fix) |
|
||||
| NuExtract `AutoProcessor.from_pretrained` rota en transformers 5.x | Sub-processor de video tira `TypeError: argument of type 'NoneType' is not iterable` (Qwen2-VL) | Bypass: `AutoTokenizer` + `AutoModelForImageTextToText` directamente |
|
||||
| Vis-network ResizeObserver loop spam (en SES/MetaMask) | Vis-network usa physics simulation → ResizeObserver dispara warnings amplificados por SES | Migrar a Sigma.js + layout server-side via `networkx.spring_layout` (sin fisica frontend) |
|
||||
| `jupyter_exec append` HTTP 405 | `jupyter_nbmodel_client` espera collab WebSocket Y.js, no soportado al 100% por jupyter-collaboration nuevo | Documentado en issue 0050; workaround actual: build_notebook scripts con `nbformat` + `nbconvert --execute` |
|
||||
| Kernel startup shadows pip packages | `00_fn_registry.py` añade cada subdir de `python/functions/` a sys.path top-level → `bigquery/datasets.py` shadows HF `datasets` package needed by transformers | Workaround per-notebook: `sys.path = [p for p in sys.path if not p.startswith(_pf+'/')]` + añadir solo el padre. Issue futuro pendiente. |
|
||||
|
||||
### Decisions — vault ADRs
|
||||
|
||||
| Decision | Razon |
|
||||
|---|---|
|
||||
| **GLiNER2 (Apache 2.0)** sustituye a GLiREL en `extract_graph_hybrid` | 6/8 relaciones correctas vs 0/1 de GLiREL en es_corporate_short, 1.18s vs 22s de mREBEL, NER+RE en una pasada |
|
||||
| mREBEL queda como fallback (no comercial) | 4/5 correctas pero CC BY-NC-SA 4.0 + 25× mas lento |
|
||||
| spaCy ES dep-rules para OpenIE schema-less | Predicado = verbo del texto (`querer`, `abrazar`), 5ms/frase, sin alucinaciones |
|
||||
| Threshold `0.3` (vs default `0.5`) sweet spot | +187% relaciones manteniendo precision; 0.2 mete +22% entidades dudosas |
|
||||
| Coreferencia normalize+substring + post-filter typed = **gratis y decisivos** | Coref −18% aislados; post-filter elimina `Madrid president_of Persona` |
|
||||
| Translate ES→EN + triplet-extract EN **NO** vale la pena | Pierdes verbos del texto (`querer` → `loves`), +500ms-1s, +300MB MarianMT, riesgo nombres propios |
|
||||
|
||||
## 2026-04-28
|
||||
|
||||
### Added
|
||||
|
||||
- `cpp/functions/core/app_about` (`app_about_cpp_core`) — ventana flotante About con `about_window_set_info(project, version, description)`, `about_window_menu_item("About...")` y `about_window_render()`. Render automatico via `fn::run_app` (cableado en `cpp/framework/app_base.cpp`).
|
||||
- `bash/functions/infra/ensure_repo_synced` (`ensure_repo_synced_bash_infra`) — pipeline idempotente que compone `gitea_create_repo` + `gitea_push_directory`: crea repo Gitea si falta, inicializa `.git` local si falta, commitea cambios pendientes y pushea. Defaults: owner `dataforge`, branch `master`.
|
||||
- `analysis.md` para 6 analyses que estaban en disco pero sin indexar: `agent_coding_eval`, `estudio_embeddings`, `estudio_mercados`, `ontology_graph`, `pruebas_jupyter`, `retrieving_graphs`. Ahora `./fn index` reporta 8 analyses (antes 2).
|
||||
- Repos `dataforge/<name>` creados en Gitea para apps y analyses que no estaban subidos: `agents_and_robots`, `element_matrix_chat`, `deploy_server`, `shaders_lab`, `voice_guide`, `agent_coding_eval`, `ontology_graph`, `turismo_spain`. Cada uno con `.gitignore` apropiado para excluir binarios, `.venv/`, `node_modules/`, `.jupyter*`, `operations.db*`.
|
||||
|
||||
### Changed
|
||||
|
||||
- `cpp/functions/core/app_menubar`: el item top-level `Settings...` pasa a ser un `BeginMenu("Settings")` con dos subitems: `Settings...` (ventana de `app_settings`) y `About...` (nuevo, ventana de `app_about`). Las apps que usan `fn_ui::app_menubar(nullptr, 0, nullptr)` heredan el cambio sin tocar nada.
|
||||
- `projects/fn_monitoring/apps/registry_dashboard/main.cpp`: cablea `fn_ui::about_window_set_info("fn_registry Dashboard", "0.2.0", "...")` antes de `fn::run_app`. Tabla `Apps` gana columna `Git` con valores `remote` (repo_url poblado), `local` (.git/ presente) o `-`.
|
||||
- `data.h`/`data.cpp`/`data_http.cpp` del dashboard: `AppRow` extendido con `repo_url` y `dir_path`.
|
||||
- 10 repos migrados de branch `main` a `master` para unificar convencion: `apps/{docker_tui,fuzzygraph,metabase_registry,pipeline_launcher,rapid_dashboards,script_navegador}`, `analysis/{estudio_embeddings,estudio_mercados,pruebas_jupyter,retrieving_graphs}`. Default branch en Gitea actualizado via API (`PATCH /repos/{owner}/{repo}` con `{"default_branch":"master"}`), branch `main` remota borrada.
|
||||
- `git config --global init.defaultBranch master` para que los proximos `git init` sean consistentes.
|
||||
- `/full-git-push`: descubre apps/analyses sin `.git` y ofrece inicializarlos con `ensure_repo_synced` automaticamente. Excluye `subrepos/` para evitar duplicacion (mirrors upstream).
|
||||
- `/full-git-pull`: tras `fn sync`, segunda pasada que clona los `dataforge/<name>` registrados en `apps`/`analysis` que no existan localmente — soluciona el "no pude recuperar la app en el otro PC".
|
||||
- `bash/functions/infra/ensure_repo_synced.sh`: localiza dependencias via `FN_REGISTRY_INFRA_DIR` o `FN_REGISTRY_ROOT`, robusto a sourcing desde zsh/bash.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `projects/fn_monitoring/apps/sqlite_api/handlers.go|main.go|handlers_test.go` + nuevos `handlers_mutations.go` y `handlers_projects.go`: cableados endpoints `POST /add_app|add_analysis|add_vault|reindex` y `GET /projects` para que el dashboard pueda crear artefactos y navegar projects desde la actions bar (estado pendiente de varios dias en uncommitted, ahora versionado en `dataforge/sqlite_api`).
|
||||
- Bug operativo en `sqlite_api` (Windows): `SO_RCVTIMEO` se pasaba como `struct timeval` cuando Windows espera `DWORD ms` → timeout efectivo de 5 ms. Ya documentado en `app.md` del dashboard.
|
||||
|
||||
## 2026-04-24
|
||||
|
||||
### Added
|
||||
|
||||
- 6 funciones `bash/infra/systemd_local_*` (install_unit, enable, start, restart, status, uninstall) para gestionar servicios systemd del sistema desde el registry (complementa las versiones remotas SSH ya existentes).
|
||||
- Pipeline `install_systemd_service_bash_pipelines` que compone las anteriores: genera unit file + install + enable + start + status.
|
||||
- Servicio systemd `sqlite_api.service` instalado y habilitado en aurgi-pc — arranque automático al iniciar WSL en `127.0.0.1:8484`.
|
||||
- `projects/fn_monitoring/launcher.sh` — launcher del dashboard (arranca API si no está + lanza ventana + cleanup).
|
||||
- Regla [`.claude/rules/kiss.md`](.claude/rules/kiss.md) — filosofía KISS para proyectos y apps.
|
||||
- Documentación ADR en `docs/adr/` con plantilla y ADR 0001 (experimento GitButler).
|
||||
- Diario en `docs/diary/` + slash command `/entrada_diario` para añadir entradas.
|
||||
- `CHANGELOG.md` (este archivo).
|
||||
- Submódulo `cpp/vendor/glfw` re-registrado con path limpio (antes heredado con path absoluto `/home/lucas/...`).
|
||||
- aurgi-pc registrado en el server centralizado (`registry.organic-machine.com`) con 18 pc_locations.
|
||||
|
||||
### Changed
|
||||
|
||||
- `registry.db` ahora está gitignorada. Es regenerable con `fn index` + completable con `fn sync`. Evita conflictos entre ramas y PCs.
|
||||
- `sqlite_api` ahora se distribuye como binario compilado (`projects/fn_monitoring/apps/sqlite_api/sqlite_api`) en lugar de `go run` al vuelo.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `http_client.cpp` del dashboard: añadido `#include <cstdint>` requerido por mingw-w64 para cross-compile Windows (g++ Linux lo incluía transitivamente).
|
||||
- `registry_dashboard.exe` (Windows) ya no abre ventana de consola al lanzarse — enlazado como GUI app (`WIN32_EXECUTABLE TRUE` / `-mwindows`).
|
||||
|
||||
### Added (design system C++)
|
||||
|
||||
- `cpp/functions/core/tokens` — design tokens para dashboards ImGui (colors, spacing, radius, font_size) inspirados en `@fn_library` (Mantine v9). Paleta dark + indigo primary. `apply_dark_theme()` aplica los tokens al `ImGuiStyle` global.
|
||||
- `cpp/functions/core/badge` — etiqueta inline con 6 variantes (Default/Success/Warning/Error/Info/Outline). Equivalente a `<Badge>` de `@fn_library`.
|
||||
- `cpp/functions/core/empty_state` — placeholder centrado para tablas/listas vacías.
|
||||
- `cpp/functions/core/page_header` — header de página con título/subtítulo + hueco para acciones + separator.
|
||||
- `registry_dashboard` migrado a los nuevos componentes: `page_header_begin/end` en el header, `empty_state` en las 4 tablas cuando están vacías, `apply_dark_theme()` al primer frame. Sin hardcode de colores disperso.
|
||||
- `systemd_local_{enable,start,restart}`: stdout de `systemctl` redirigido a stderr para no contaminar el JSON capturado por el pipeline.
|
||||
- `.gitmodules`: entry fantasma `cpp/vendor/glfw` con path absoluto `/home/lucas/...` que bloqueaba `git submodule status` y el cross-compile Windows.
|
||||
|
||||
### Removed
|
||||
|
||||
- Integración de GitButler de Claude Code — binario `~/.local/bin/but`, plugin `gitbutler-tools`, skill `.claude/skills/gitbutler/`, hooks en `settings.json`, ramas `gitbutler/*` + `e-branch-*`, estado interno `.git/gitbutler/`. Ver [ADR 0001](docs/adr/0001-gitbutler-experiment.md) para motivos.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Build output
|
||||
dag_engine
|
||||
*.exe
|
||||
|
||||
# Frontend build
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
|
||||
# Go
|
||||
vendor/
|
||||
|
||||
# Editor
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// RegisterAPI sets up all HTTP routes on the given mux.
|
||||
func RegisterAPI(mux *http.ServeMux, executor *Executor, scheduler *Scheduler, frontendFS fs.FS) {
|
||||
// API routes.
|
||||
mux.HandleFunc("GET /api/dags", handleListDags(executor))
|
||||
mux.HandleFunc("GET /api/dags/{name}", handleGetDag(executor))
|
||||
mux.HandleFunc("POST /api/dags/{name}/run", handleRunDag(executor))
|
||||
|
||||
mux.HandleFunc("GET /api/runs", handleListRuns(executor))
|
||||
mux.HandleFunc("GET /api/runs/{id}", handleGetRun(executor))
|
||||
|
||||
mux.HandleFunc("POST /api/scheduler/start", handleSchedulerStart(scheduler))
|
||||
mux.HandleFunc("POST /api/scheduler/stop", handleSchedulerStop(scheduler))
|
||||
mux.HandleFunc("GET /api/scheduler/status", handleSchedulerStatus(scheduler))
|
||||
|
||||
// Frontend SPA fallback.
|
||||
if frontendFS != nil {
|
||||
mux.Handle("/", spaHandler(frontendFS))
|
||||
}
|
||||
}
|
||||
|
||||
// spaHandler serves static files from the embedded FS, falling back to index.html
|
||||
// for unknown paths (SPA client-side routing).
|
||||
func spaHandler(fsys fs.FS) http.Handler {
|
||||
fileServer := http.FileServer(http.FS(fsys))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Try to serve the file directly.
|
||||
path := r.URL.Path
|
||||
if path == "/" {
|
||||
path = "index.html"
|
||||
} else {
|
||||
path = path[1:] // strip leading /
|
||||
}
|
||||
|
||||
if _, err := fs.Stat(fsys, path); err != nil {
|
||||
// File not found — serve index.html for SPA routing.
|
||||
r.URL.Path = "/"
|
||||
}
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: dag_engine
|
||||
lang: go
|
||||
domain: infra
|
||||
description: "Motor de ejecucion de DAGs con CLI y interfaz web. Reemplaza Dagu con implementacion propia compatible con el formato YAML existente. Almacena historial de ejecuciones en SQLite."
|
||||
tags: [service, dag, workflow, scheduler, web, cron]
|
||||
uses_functions:
|
||||
- dag_parse_go_core
|
||||
- dag_validate_go_core
|
||||
- dag_topo_sort_go_core
|
||||
- dag_resolve_env_go_core
|
||||
- parse_cron_expr_go_core
|
||||
- next_cron_time_go_core
|
||||
- cron_ticker_go_infra
|
||||
- find_go_core
|
||||
- process_spawn_go_infra
|
||||
- process_wait_go_infra
|
||||
uses_types:
|
||||
- dag_definition_go_core
|
||||
- dag_step_go_core
|
||||
- dag_validation_result_go_core
|
||||
- cron_schedule_go_core
|
||||
- process_handle_go_infra
|
||||
- process_result_go_infra
|
||||
- DagRun_go_infra
|
||||
- DagStepResult_go_infra
|
||||
framework: "net/http + vite + react"
|
||||
entry_point: "main.go"
|
||||
dir_path: "apps/dag_engine"
|
||||
---
|
||||
|
||||
## Arquitectura
|
||||
|
||||
CLI + servidor web en un unico binario:
|
||||
|
||||
```
|
||||
dag-engine run <path.yaml> # ejecuta un DAG desde terminal
|
||||
dag-engine list [dir] # lista DAGs con schedule y estado
|
||||
dag-engine status [dag_name] # historial de ejecuciones
|
||||
dag-engine validate <path.yaml> # valida sin ejecutar
|
||||
dag-engine server # arranca HTTP + frontend web
|
||||
```
|
||||
|
||||
### Backend (Go)
|
||||
|
||||
- `net/http` con `ServeMux` (Go 1.22+ pattern routing)
|
||||
- SQLite via `go-sqlite3` para historial de runs
|
||||
- Executor: parse -> validate -> topo_sort -> spawn/wait por nivel -> store
|
||||
- Scheduler: cron_ticker por cada DAG con schedule
|
||||
|
||||
### Frontend (Vite + React + Mantine)
|
||||
|
||||
- DagList: tabla de DAGs con schedule, tags, ultimo status
|
||||
- DagDetail: metadata + "Run Now" + historial
|
||||
- RunDetail: timeline de steps con stdout/stderr expandible
|
||||
|
||||
### Storage
|
||||
|
||||
SQLite `dag_engine.db`:
|
||||
- `dag_runs`: id, dag_name, status, trigger, started_at, finished_at, error
|
||||
- `dag_step_results`: id, run_id, step_name, status, exit_code, stdout, stderr, duration_ms
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm install && pnpm build
|
||||
cd .. && CGO_ENABLED=1 go build -tags fts5 -o dag-engine .
|
||||
```
|
||||
|
||||
### Uso
|
||||
|
||||
```bash
|
||||
# CLI
|
||||
./dag-engine run ~/dagu/dags/example.yaml
|
||||
./dag-engine list ~/dagu/dags/
|
||||
|
||||
# Servidor web
|
||||
./dag-engine server --port 8090 --dags-dir ~/dagu/dags/ --scheduler
|
||||
# Browser: http://localhost:8090
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Compatible con el formato YAML de Dagu. Lee DAGs existentes de `~/dagu/dags/` sin modificaciones.
|
||||
Puerto por defecto 8090 (mismo que Dagu).
|
||||
@@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Config holds the runtime configuration for the DAG engine.
|
||||
type Config struct {
|
||||
Port int
|
||||
DagsDir string
|
||||
DBPath string
|
||||
AutoScheduler bool
|
||||
}
|
||||
|
||||
// DefaultConfig returns sensible defaults.
|
||||
func DefaultConfig() Config {
|
||||
home, _ := os.UserHomeDir()
|
||||
return Config{
|
||||
Port: 8090,
|
||||
DagsDir: filepath.Join(home, "dagu", "dags"),
|
||||
DBPath: "dag_engine.db",
|
||||
}
|
||||
}
|
||||
|
||||
// ParseFlags populates config from CLI flags for the "server" subcommand.
|
||||
func (c *Config) ParseFlags(fs *flag.FlagSet, args []string) error {
|
||||
fs.IntVar(&c.Port, "port", c.Port, "HTTP server port")
|
||||
fs.StringVar(&c.DagsDir, "dags-dir", c.DagsDir, "directory containing DAG YAML files")
|
||||
fs.StringVar(&c.DBPath, "db", c.DBPath, "path to SQLite database")
|
||||
fs.BoolVar(&c.AutoScheduler, "scheduler", c.AutoScheduler, "auto-start cron scheduler")
|
||||
return fs.Parse(args)
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/core"
|
||||
"fn-registry/functions/infra"
|
||||
|
||||
"dag-engine/store"
|
||||
)
|
||||
|
||||
// Executor orchestrates DAG parsing, validation, and execution.
|
||||
type Executor struct {
|
||||
store *store.DB
|
||||
dagsDir string
|
||||
}
|
||||
|
||||
// NewExecutor creates a new executor.
|
||||
func NewExecutor(s *store.DB, dagsDir string) *Executor {
|
||||
return &Executor{store: s, dagsDir: dagsDir}
|
||||
}
|
||||
|
||||
// ExecuteDAG runs a DAG from a YAML file path and returns the run ID.
|
||||
// It runs asynchronously: steps execute in topological order with parallel levels.
|
||||
func (e *Executor) ExecuteDAG(ctx context.Context, dagPath string, trigger string) (string, error) {
|
||||
data, err := os.ReadFile(dagPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read dag: %w", err)
|
||||
}
|
||||
|
||||
dag, err := core.DagParse(data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse dag: %w", err)
|
||||
}
|
||||
dag.FilePath = dagPath
|
||||
|
||||
// Resolve env variables.
|
||||
dag = core.DagResolveEnv(dag, os.Environ())
|
||||
|
||||
// Validate.
|
||||
result := core.DagValidate(dag)
|
||||
if !result.Valid {
|
||||
return "", fmt.Errorf("validate dag: %s", strings.Join(result.Errors, "; "))
|
||||
}
|
||||
|
||||
// Create run record.
|
||||
runID := generateID()
|
||||
now := time.Now()
|
||||
run := &store.DagRun{
|
||||
ID: runID,
|
||||
DagName: dag.Name,
|
||||
DagPath: dagPath,
|
||||
Status: "running",
|
||||
Trigger: trigger,
|
||||
StartedAt: now,
|
||||
}
|
||||
if err := e.store.CreateRun(run); err != nil {
|
||||
return "", fmt.Errorf("create run: %w", err)
|
||||
}
|
||||
|
||||
// Topological sort.
|
||||
levels, err := core.DagTopoSort(dag.Steps)
|
||||
if err != nil {
|
||||
e.failRun(runID, err)
|
||||
return runID, err
|
||||
}
|
||||
|
||||
// Setup DAGU_ENV temp file for inter-step communication.
|
||||
daguEnvFile, err := os.CreateTemp("", "dagu_env_*")
|
||||
if err != nil {
|
||||
e.failRun(runID, err)
|
||||
return runID, err
|
||||
}
|
||||
daguEnvPath := daguEnvFile.Name()
|
||||
daguEnvFile.Close()
|
||||
defer os.Remove(daguEnvPath)
|
||||
|
||||
// Track step outputs for ${step_id.stdout} references.
|
||||
stepOutputs := make(map[string]string)
|
||||
|
||||
// Execute levels.
|
||||
runFailed := false
|
||||
var runErr error
|
||||
|
||||
for _, level := range levels {
|
||||
if runFailed {
|
||||
// Skip remaining levels, mark steps as skipped.
|
||||
for _, step := range level {
|
||||
e.recordStepSkipped(runID, step)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
levelFailed := false
|
||||
|
||||
for _, step := range level {
|
||||
step := step
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
mu.Lock()
|
||||
if levelFailed {
|
||||
mu.Unlock()
|
||||
e.recordStepSkipped(runID, step)
|
||||
return
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
err := e.executeStep(ctx, runID, dag, step, daguEnvPath, stepOutputs, &mu)
|
||||
if err != nil && !step.ContinueOn.Failure {
|
||||
mu.Lock()
|
||||
levelFailed = true
|
||||
runFailed = true
|
||||
runErr = fmt.Errorf("step %q failed: %w", stepName(step), err)
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Run handlers.
|
||||
if runFailed {
|
||||
e.runHandlers(ctx, runID, dag, dag.HandlerOn.Failure, daguEnvPath, stepOutputs)
|
||||
} else {
|
||||
e.runHandlers(ctx, runID, dag, dag.HandlerOn.Success, daguEnvPath, stepOutputs)
|
||||
}
|
||||
e.runHandlers(ctx, runID, dag, dag.HandlerOn.Exit, daguEnvPath, stepOutputs)
|
||||
|
||||
// Finalize run.
|
||||
fin := time.Now()
|
||||
status := "success"
|
||||
errMsg := ""
|
||||
if runFailed {
|
||||
status = "failed"
|
||||
if runErr != nil {
|
||||
errMsg = runErr.Error()
|
||||
}
|
||||
}
|
||||
e.store.UpdateRunStatus(runID, status, &fin, errMsg)
|
||||
|
||||
return runID, runErr
|
||||
}
|
||||
|
||||
// executeStep runs a single step, recording results in the store.
|
||||
func (e *Executor) executeStep(ctx context.Context, runID string, dag core.DagDefinition, step core.DagStep, daguEnvPath string, outputs map[string]string, mu *sync.Mutex) error {
|
||||
stepID := generateID()
|
||||
now := time.Now()
|
||||
e.store.InsertStepResult(&store.DagStepResult{
|
||||
ID: stepID,
|
||||
RunID: runID,
|
||||
StepName: stepName(step),
|
||||
Status: "running",
|
||||
StartedAt: &now,
|
||||
})
|
||||
|
||||
// Build environment.
|
||||
env := buildStepEnv(dag, step, daguEnvPath, outputs)
|
||||
|
||||
// Determine command.
|
||||
command := step.Command
|
||||
if command == "" && step.Script != "" {
|
||||
command = step.Script
|
||||
}
|
||||
if command == "" {
|
||||
e.store.UpdateStepResult(stepID, "skipped", 0, "", "", nil, 0, "no command or script")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve step-level ${VAR} references and ${step_id.stdout} patterns.
|
||||
mu.Lock()
|
||||
command = resolveStepRefs(command, outputs)
|
||||
mu.Unlock()
|
||||
|
||||
// Determine working directory.
|
||||
dir := step.Dir
|
||||
if dir == "" {
|
||||
dir = dag.WorkingDir
|
||||
}
|
||||
|
||||
shell := step.Shell
|
||||
if shell == "" {
|
||||
shell = dag.Shell
|
||||
}
|
||||
|
||||
// Spawn process.
|
||||
handle, err := infra.ProcessSpawn(command, dir, env, shell)
|
||||
if err != nil {
|
||||
fin := time.Now()
|
||||
e.store.UpdateStepResult(stepID, "failed", -1, "", "", &fin, time.Since(now).Milliseconds(), err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Wait for process.
|
||||
result, err := infra.ProcessWait(handle, step.TimeoutSec)
|
||||
fin := time.Now()
|
||||
duration := time.Since(now).Milliseconds()
|
||||
|
||||
if err != nil && result.ExitCode == 0 {
|
||||
result.ExitCode = -1
|
||||
}
|
||||
|
||||
status := "success"
|
||||
errMsg := ""
|
||||
if result.ExitCode != 0 || err != nil {
|
||||
status = "failed"
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
e.store.UpdateStepResult(stepID, status, result.ExitCode, result.Stdout, result.Stderr, &fin, duration, errMsg)
|
||||
|
||||
// Store output for ${step_id.stdout} references.
|
||||
if step.ID != "" || step.Output != "" {
|
||||
mu.Lock()
|
||||
key := step.ID
|
||||
if key == "" {
|
||||
key = step.Output
|
||||
}
|
||||
outputs[key] = strings.TrimSpace(result.Stdout)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// Read DAGU_ENV for inter-step env propagation.
|
||||
readDaguEnv(daguEnvPath, outputs)
|
||||
|
||||
if status == "failed" {
|
||||
return fmt.Errorf("exit code %d", result.ExitCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Executor) runHandlers(ctx context.Context, runID string, dag core.DagDefinition, handlers []core.DagStep, daguEnvPath string, outputs map[string]string) {
|
||||
var mu sync.Mutex
|
||||
for _, step := range handlers {
|
||||
e.executeStep(ctx, runID, dag, step, daguEnvPath, outputs, &mu)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Executor) failRun(runID string, err error) {
|
||||
fin := time.Now()
|
||||
e.store.UpdateRunStatus(runID, "failed", &fin, err.Error())
|
||||
}
|
||||
|
||||
func (e *Executor) recordStepSkipped(runID string, step core.DagStep) {
|
||||
now := time.Now()
|
||||
e.store.InsertStepResult(&store.DagStepResult{
|
||||
ID: generateID(),
|
||||
RunID: runID,
|
||||
StepName: stepName(step),
|
||||
Status: "skipped",
|
||||
StartedAt: &now,
|
||||
})
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func stepName(s core.DagStep) string {
|
||||
if s.Name != "" {
|
||||
return s.Name
|
||||
}
|
||||
return s.ID
|
||||
}
|
||||
|
||||
func buildStepEnv(dag core.DagDefinition, step core.DagStep, daguEnvPath string, outputs map[string]string) []string {
|
||||
env := os.Environ()
|
||||
|
||||
// Add DAG-level env.
|
||||
for k, v := range dag.Env {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
|
||||
// Add step-level env.
|
||||
for k, v := range step.Env {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
|
||||
// Add DAGU_ENV path.
|
||||
env = append(env, "DAGU_ENV="+daguEnvPath)
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
func resolveStepRefs(command string, outputs map[string]string) string {
|
||||
for k, v := range outputs {
|
||||
command = strings.ReplaceAll(command, "${"+k+".stdout}", v)
|
||||
command = strings.ReplaceAll(command, "$"+k+".stdout", v)
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
func readDaguEnv(path string, outputs map[string]string) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || len(data) == 0 {
|
||||
return
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
outputs[parts[0]] = parts[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// generateID creates a simple time-based unique ID.
|
||||
func generateID() string {
|
||||
return fmt.Sprintf("%d-%04x", time.Now().UnixNano(), time.Now().Nanosecond()%0xFFFF)
|
||||
}
|
||||
|
||||
// --- DAG listing helpers ---
|
||||
|
||||
// DagInfo summarizes a DAG file for listing.
|
||||
type DagInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Schedule []string `json:"schedule,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
FilePath string `json:"file_path"`
|
||||
Valid bool `json:"valid"`
|
||||
LastRun *store.DagRun `json:"last_run,omitempty"`
|
||||
}
|
||||
|
||||
// ListDAGs scans a directory for YAML files and returns parsed DAG info.
|
||||
func (e *Executor) ListDAGs() ([]DagInfo, error) {
|
||||
entries, err := os.ReadDir(e.dagsDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read dags dir: %w", err)
|
||||
}
|
||||
|
||||
var dags []DagInfo
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := filepath.Ext(entry.Name())
|
||||
if ext != ".yaml" && ext != ".yml" {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(e.dagsDir, entry.Name())
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
dag, err := core.DagParse(data)
|
||||
if err != nil {
|
||||
dags = append(dags, DagInfo{
|
||||
Name: strings.TrimSuffix(entry.Name(), ext),
|
||||
FilePath: path,
|
||||
Valid: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
info := DagInfo{
|
||||
Name: dag.Name,
|
||||
Description: dag.Description,
|
||||
Schedule: dag.Schedule,
|
||||
Tags: dag.Tags,
|
||||
Type: dag.Type,
|
||||
FilePath: path,
|
||||
Valid: true,
|
||||
}
|
||||
|
||||
// Attach last run info.
|
||||
runs, _, _ := e.store.ListRuns(dag.Name, 1, 0)
|
||||
if len(runs) > 0 {
|
||||
info.LastRun = &runs[0]
|
||||
}
|
||||
|
||||
dags = append(dags, info)
|
||||
}
|
||||
|
||||
return dags, nil
|
||||
}
|
||||
|
||||
// GetDAG returns detailed info for a specific DAG by name.
|
||||
func (e *Executor) GetDAG(name string) (*DagInfo, *core.DagDefinition, *core.DagValidationResult, error) {
|
||||
// Find the YAML file.
|
||||
entries, err := os.ReadDir(e.dagsDir)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
ext := filepath.Ext(entry.Name())
|
||||
base := strings.TrimSuffix(entry.Name(), ext)
|
||||
if (ext != ".yaml" && ext != ".yml") || base != name {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(e.dagsDir, entry.Name())
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
dag, err := core.DagParse(data)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("parse: %w", err)
|
||||
}
|
||||
dag.FilePath = path
|
||||
|
||||
validationResult := core.DagValidate(dag)
|
||||
|
||||
info := &DagInfo{
|
||||
Name: dag.Name,
|
||||
Description: dag.Description,
|
||||
Schedule: dag.Schedule,
|
||||
Tags: dag.Tags,
|
||||
Type: dag.Type,
|
||||
FilePath: path,
|
||||
Valid: validationResult.Valid,
|
||||
}
|
||||
|
||||
runs, _, _ := e.store.ListRuns(dag.Name, 1, 0)
|
||||
if len(runs) > 0 {
|
||||
info.LastRun = &runs[0]
|
||||
}
|
||||
|
||||
return info, &dag, &validationResult, nil
|
||||
}
|
||||
|
||||
return nil, nil, nil, fmt.Errorf("dag %q not found in %s", name, e.dagsDir)
|
||||
}
|
||||
|
||||
// ValidateDAG parses and validates a DAG file, printing results.
|
||||
func ValidateDAG(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dag, err := core.DagParse(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse error: %w", err)
|
||||
}
|
||||
|
||||
result := core.DagValidate(dag)
|
||||
|
||||
log.Printf("DAG: %s", dag.Name)
|
||||
log.Printf("Steps: %d", len(dag.Steps))
|
||||
log.Printf("Schedule: %v", dag.Schedule)
|
||||
|
||||
if result.Valid {
|
||||
log.Printf("Validation: PASS")
|
||||
log.Printf("Topological levels: %d", len(result.Levels))
|
||||
for i, level := range result.Levels {
|
||||
log.Printf(" Level %d: %v", i, level)
|
||||
}
|
||||
} else {
|
||||
log.Printf("Validation: FAIL")
|
||||
for _, e := range result.Errors {
|
||||
log.Printf(" ERROR: %s", e)
|
||||
}
|
||||
}
|
||||
for _, w := range result.Warnings {
|
||||
log.Printf(" WARNING: %s", w)
|
||||
}
|
||||
|
||||
if !result.Valid {
|
||||
return fmt.Errorf("validation failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DAG Engine</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "dag-engine-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^9.0.2",
|
||||
"@mantine/hooks": "^9.0.2",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"postcss": "^8.5.4",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"postcss-preset-mantine": {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { AppShell, Container, Title, Group, Text } from "@mantine/core";
|
||||
import { IconTopologyRing } from "@tabler/icons-react";
|
||||
import { DagList } from "./pages/DagList";
|
||||
import { DagDetail } from "./pages/DagDetail";
|
||||
import { RunDetail } from "./pages/RunDetail";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<AppShell header={{ height: 50 }} padding="md">
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px="md">
|
||||
<IconTopologyRing size={24} />
|
||||
<Title order={4}>DAG Engine</Title>
|
||||
<Text size="xs" c="dimmed">
|
||||
fn_registry workflow executor
|
||||
</Text>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Main>
|
||||
<Container size="lg">
|
||||
<Routes>
|
||||
<Route path="/" element={<DagList />} />
|
||||
<Route path="/dags/:name" element={<DagDetail />} />
|
||||
<Route path="/runs/:id" element={<RunDetail />} />
|
||||
</Routes>
|
||||
</Container>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type {
|
||||
DagSummary,
|
||||
DagDetail,
|
||||
DagRun,
|
||||
RunDetail,
|
||||
SchedulerStatus,
|
||||
} from "./types";
|
||||
|
||||
const BASE = "/api";
|
||||
|
||||
async function fetchJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, init);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function listDags(): Promise<DagSummary[]> {
|
||||
return fetchJSON("/dags");
|
||||
}
|
||||
|
||||
export function getDag(name: string): Promise<DagDetail> {
|
||||
return fetchJSON(`/dags/${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
export function triggerDag(
|
||||
name: string
|
||||
): Promise<{ status: string; dag: string; message: string }> {
|
||||
return fetchJSON(`/dags/${encodeURIComponent(name)}/run`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function listRuns(params?: {
|
||||
dag?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ runs: DagRun[]; total: number }> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.dag) search.set("dag", params.dag);
|
||||
if (params?.limit) search.set("limit", String(params.limit));
|
||||
if (params?.offset) search.set("offset", String(params.offset));
|
||||
const qs = search.toString();
|
||||
return fetchJSON(`/runs${qs ? "?" + qs : ""}`);
|
||||
}
|
||||
|
||||
export function getRun(id: string): Promise<RunDetail> {
|
||||
return fetchJSON(`/runs/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export function startScheduler(): Promise<void> {
|
||||
return fetchJSON("/scheduler/start", { method: "POST" });
|
||||
}
|
||||
|
||||
export function stopScheduler(): Promise<void> {
|
||||
return fetchJSON("/scheduler/stop", { method: "POST" });
|
||||
}
|
||||
|
||||
export function getSchedulerStatus(): Promise<SchedulerStatus> {
|
||||
return fetchJSON("/scheduler/status");
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Badge } from "@mantine/core";
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
success: "green",
|
||||
failed: "red",
|
||||
running: "blue",
|
||||
pending: "gray",
|
||||
cancelled: "yellow",
|
||||
skipped: "dimmed",
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<Badge color={colorMap[status] || "gray"} variant="light" size="sm">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Timeline, Text, Code, Collapse, Box, Group } from "@mantine/core";
|
||||
import {
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconLoader,
|
||||
IconCircleMinus,
|
||||
IconClock,
|
||||
} from "@tabler/icons-react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import type { DagStepResult } from "../types";
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
success: <IconCircleCheck size={16} color="var(--mantine-color-green-6)" />,
|
||||
failed: <IconCircleX size={16} color="var(--mantine-color-red-6)" />,
|
||||
running: <IconLoader size={16} color="var(--mantine-color-blue-6)" />,
|
||||
skipped: <IconCircleMinus size={16} color="var(--mantine-color-dimmed)" />,
|
||||
pending: <IconClock size={16} color="var(--mantine-color-gray-6)" />,
|
||||
};
|
||||
|
||||
function StepItem({ step }: { step: DagStepResult }) {
|
||||
const [opened, { toggle }] = useDisclosure(step.Status === "failed");
|
||||
const hasOutput = step.Stdout || step.Stderr;
|
||||
|
||||
return (
|
||||
<Timeline.Item
|
||||
bullet={iconMap[step.Status] || iconMap.pending}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
onClick={hasOutput ? toggle : undefined}
|
||||
style={hasOutput ? { cursor: "pointer" } : undefined}
|
||||
>
|
||||
{step.StepName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{step.DurationMs}ms
|
||||
</Text>
|
||||
{step.ExitCode !== 0 && step.ExitCode !== -1 && (
|
||||
<Text size="xs" c="red">
|
||||
exit {step.ExitCode}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{hasOutput && (
|
||||
<Collapse in={opened}>
|
||||
<Box mt="xs">
|
||||
{step.Stdout && (
|
||||
<Code block mb="xs" style={{ maxHeight: 200, overflow: "auto" }}>
|
||||
{step.Stdout}
|
||||
</Code>
|
||||
)}
|
||||
{step.Stderr && (
|
||||
<Code
|
||||
block
|
||||
color="red"
|
||||
style={{ maxHeight: 200, overflow: "auto" }}
|
||||
>
|
||||
{step.Stderr}
|
||||
</Code>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
)}
|
||||
</Timeline.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepTimeline({ steps }: { steps: DagStepResult[] }) {
|
||||
const activeIndex = steps.findIndex((s) => s.Status === "running");
|
||||
|
||||
return (
|
||||
<Timeline
|
||||
active={activeIndex >= 0 ? activeIndex : steps.length - 1}
|
||||
bulletSize={24}
|
||||
>
|
||||
{steps.map((step) => (
|
||||
<StepItem key={step.ID} step={step} />
|
||||
))}
|
||||
</Timeline>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import "@mantine/core/styles.css";
|
||||
import { MantineProvider, createTheme } from "@mantine/core";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { App } from "./App";
|
||||
|
||||
const theme = createTheme({
|
||||
primaryColor: "blue",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<MantineProvider theme={theme} defaultColorScheme="dark">
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</MantineProvider>
|
||||
);
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Title,
|
||||
Text,
|
||||
Group,
|
||||
Button,
|
||||
Badge,
|
||||
Stack,
|
||||
Paper,
|
||||
Table,
|
||||
Alert,
|
||||
Loader,
|
||||
Code,
|
||||
} from "@mantine/core";
|
||||
import { IconPlayerPlay, IconArrowLeft } from "@tabler/icons-react";
|
||||
import { getDag, triggerDag } from "../api";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import type { DagDetail as DagDetailType } from "../types";
|
||||
|
||||
export function DagDetail() {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<DagDetailType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [triggering, setTriggering] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
if (!name) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
setData(await getDag(name));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [name]);
|
||||
|
||||
const handleRun = async () => {
|
||||
if (!name) return;
|
||||
setTriggering(true);
|
||||
try {
|
||||
await triggerDag(name);
|
||||
setTimeout(load, 1000);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setTriggering(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Loader />;
|
||||
if (error) return <Alert color="red">{error}</Alert>;
|
||||
if (!data) return <Text>Not found</Text>;
|
||||
|
||||
const { dag, validation, runs } = data;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
leftSection={<IconArrowLeft size={14} />}
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Title order={2}>{dag.Name}</Title>
|
||||
{dag.Description && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{dag.Description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
leftSection={<IconPlayerPlay size={16} />}
|
||||
onClick={handleRun}
|
||||
loading={triggering}
|
||||
>
|
||||
Run Now
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
{dag.Schedule?.map((s: string) => (
|
||||
<Badge key={s} variant="light" ff="monospace">
|
||||
{s}
|
||||
</Badge>
|
||||
))}
|
||||
<Badge variant="light">{dag.Type || "chain"}</Badge>
|
||||
{dag.Tags?.map((t: string) => (
|
||||
<Badge key={t} variant="dot">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
{!validation.Valid && (
|
||||
<Alert color="red" title="Validation errors">
|
||||
{validation.Errors.map((e: string, i: number) => (
|
||||
<Text key={i} size="sm">
|
||||
{e}
|
||||
</Text>
|
||||
))}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper p="md" withBorder>
|
||||
<Title order={4} mb="sm">
|
||||
Steps ({dag.Steps?.length || 0})
|
||||
</Title>
|
||||
{validation.Levels?.map((level: string[], i: number) => (
|
||||
<Group key={i} gap="xs" mb="xs">
|
||||
<Text size="xs" c="dimmed" w={60}>
|
||||
Level {i}:
|
||||
</Text>
|
||||
{level.map((name: string) => {
|
||||
const step = dag.Steps?.find(
|
||||
(s) => s.Name === name || s.ID === name
|
||||
);
|
||||
return (
|
||||
<Badge key={name} variant="outline" size="sm">
|
||||
{name}
|
||||
{step?.Depends?.length
|
||||
? ` (after ${step.Depends.join(",")})`
|
||||
: ""}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
))}
|
||||
|
||||
{dag.Env && Object.keys(dag.Env).length > 0 && (
|
||||
<>
|
||||
<Title order={5} mt="md" mb="xs">
|
||||
Environment
|
||||
</Title>
|
||||
<Code block>
|
||||
{Object.entries(dag.Env)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("\n")}
|
||||
</Code>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper p="md" withBorder>
|
||||
<Title order={4} mb="sm">
|
||||
Run History
|
||||
</Title>
|
||||
{runs?.length ? (
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Trigger</Table.Th>
|
||||
<Table.Th>Started</Table.Th>
|
||||
<Table.Th>Duration</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{runs.map((r) => (
|
||||
<Table.Tr
|
||||
key={r.ID}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/runs/${r.ID}`)}
|
||||
>
|
||||
<Table.Td>
|
||||
<StatusBadge status={r.Status} />
|
||||
</Table.Td>
|
||||
<Table.Td>{r.Trigger}</Table.Td>
|
||||
<Table.Td>
|
||||
{new Date(r.StartedAt).toLocaleString()}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{r.FinishedAt
|
||||
? `${Math.round((new Date(r.FinishedAt).getTime() - new Date(r.StartedAt).getTime()) / 1000)}s`
|
||||
: "running..."}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No runs yet
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Table,
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Badge,
|
||||
Text,
|
||||
Loader,
|
||||
Stack,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconPlayerPlay,
|
||||
IconPlayerStop,
|
||||
IconRefresh,
|
||||
} from "@tabler/icons-react";
|
||||
import { listDags, getSchedulerStatus, startScheduler, stopScheduler } from "../api";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import type { DagSummary, SchedulerStatus } from "../types";
|
||||
|
||||
export function DagList() {
|
||||
const [dags, setDags] = useState<DagSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [scheduler, setScheduler] = useState<SchedulerStatus | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [d, s] = await Promise.all([listDags(), getSchedulerStatus()]);
|
||||
setDags(d || []);
|
||||
setScheduler(s);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const interval = setInterval(load, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const toggleScheduler = async () => {
|
||||
if (scheduler?.running) {
|
||||
await stopScheduler();
|
||||
} else {
|
||||
await startScheduler();
|
||||
}
|
||||
const s = await getSchedulerStatus();
|
||||
setScheduler(s);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>DAGs</Title>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
onClick={load}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={scheduler?.running ? "filled" : "light"}
|
||||
color={scheduler?.running ? "green" : "gray"}
|
||||
leftSection={
|
||||
scheduler?.running ? (
|
||||
<IconPlayerStop size={14} />
|
||||
) : (
|
||||
<IconPlayerPlay size={14} />
|
||||
)
|
||||
}
|
||||
onClick={toggleScheduler}
|
||||
>
|
||||
Scheduler {scheduler?.running ? "ON" : "OFF"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{error && <Alert color="red">{error}</Alert>}
|
||||
|
||||
{loading && !dags.length ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Schedule</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Tags</Table.Th>
|
||||
<Table.Th>Last Status</Table.Th>
|
||||
<Table.Th>Last Run</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{dags.map((d) => (
|
||||
<Table.Tr
|
||||
key={d.file_path}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/dags/${d.name}`)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text fw={500}>{d.name}</Text>
|
||||
{d.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{d.description}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" ff="monospace">
|
||||
{d.schedule?.join(", ") || "-"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" size="xs">
|
||||
{d.type || "chain"}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
{d.tags?.map((t) => (
|
||||
<Badge key={t} variant="dot" size="xs">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{d.last_run ? (
|
||||
<StatusBadge status={d.last_run.Status} />
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
-
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">
|
||||
{d.last_run
|
||||
? new Date(d.last_run.StartedAt).toLocaleString()
|
||||
: "-"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Title,
|
||||
Text,
|
||||
Group,
|
||||
Button,
|
||||
Stack,
|
||||
Paper,
|
||||
Alert,
|
||||
Loader,
|
||||
} from "@mantine/core";
|
||||
import { IconArrowLeft } from "@tabler/icons-react";
|
||||
import { getRun } from "../api";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { StepTimeline } from "../components/StepTimeline";
|
||||
import type { RunDetail as RunDetailType } from "../types";
|
||||
|
||||
export function RunDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<RunDetailType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
setData(await getRun(id));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// Auto-refresh while running.
|
||||
const interval = setInterval(() => {
|
||||
if (data?.run.Status === "running") {
|
||||
load();
|
||||
}
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [id, data?.run.Status]);
|
||||
|
||||
if (loading) return <Loader />;
|
||||
if (error) return <Alert color="red">{error}</Alert>;
|
||||
if (!data) return <Text>Not found</Text>;
|
||||
|
||||
const { run, steps } = data;
|
||||
const duration = run.FinishedAt
|
||||
? `${Math.round((new Date(run.FinishedAt).getTime() - new Date(run.StartedAt).getTime()) / 1000)}s`
|
||||
: "running...";
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
leftSection={<IconArrowLeft size={14} />}
|
||||
onClick={() => navigate(`/dags/${run.DagName}`)}
|
||||
>
|
||||
Back to {run.DagName}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Title order={2}>Run {run.ID.substring(0, 16)}...</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{run.DagName} · {run.Trigger} ·{" "}
|
||||
{new Date(run.StartedAt).toLocaleString()}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<StatusBadge status={run.Status} />
|
||||
<Text size="sm">{duration}</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{run.Error && (
|
||||
<Alert color="red" title="Error">
|
||||
{run.Error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper p="md" withBorder>
|
||||
<Title order={4} mb="md">
|
||||
Steps ({steps?.length || 0})
|
||||
</Title>
|
||||
{steps?.length ? (
|
||||
<StepTimeline steps={steps} />
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No steps recorded
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
export interface DagSummary {
|
||||
name: string;
|
||||
description?: string;
|
||||
schedule?: string[];
|
||||
tags?: string[];
|
||||
type?: string;
|
||||
file_path: string;
|
||||
valid: boolean;
|
||||
last_run?: DagRun;
|
||||
}
|
||||
|
||||
export interface DagRun {
|
||||
ID: string;
|
||||
DagName: string;
|
||||
DagPath: string;
|
||||
Status: string;
|
||||
Trigger: string;
|
||||
StartedAt: string;
|
||||
FinishedAt?: string;
|
||||
Error: string;
|
||||
}
|
||||
|
||||
export interface DagStepResult {
|
||||
ID: string;
|
||||
RunID: string;
|
||||
StepName: string;
|
||||
Status: string;
|
||||
ExitCode: number;
|
||||
Stdout: string;
|
||||
Stderr: string;
|
||||
StartedAt?: string;
|
||||
FinishedAt?: string;
|
||||
DurationMs: number;
|
||||
Error: string;
|
||||
}
|
||||
|
||||
export interface DagDetail {
|
||||
info: DagSummary;
|
||||
dag: {
|
||||
Name: string;
|
||||
Description: string;
|
||||
Type: string;
|
||||
Schedule: string[];
|
||||
Steps: { Name: string; ID: string; Command: string; Script: string; Depends: string[] }[];
|
||||
Env: Record<string, string>;
|
||||
Tags: string[];
|
||||
HandlerOn: { Failure: unknown[]; Success: unknown[] };
|
||||
};
|
||||
validation: {
|
||||
Valid: boolean;
|
||||
Errors: string[];
|
||||
Warnings: string[];
|
||||
Levels: string[][];
|
||||
};
|
||||
runs: DagRun[];
|
||||
}
|
||||
|
||||
export interface RunDetail {
|
||||
run: DagRun;
|
||||
steps: DagStepResult[];
|
||||
}
|
||||
|
||||
export interface SchedulerStatus {
|
||||
running: boolean;
|
||||
dags: { name: string; path: string; schedule: string; next_run: string }[];
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5175,
|
||||
proxy: {
|
||||
"/api": "http://localhost:8090",
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
module dag-engine
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
fn-registry v0.0.0-00010101000000-000000000000
|
||||
github.com/mattn/go-sqlite3 v1.14.37
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/ClickHouse/ch-go v0.71.0 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/apache/arrow-go/v18 v18.1.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/flatbuffers v25.1.24+incompatible // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.1 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.18.3 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/marcboeker/go-duckdb v1.8.5 // indirect
|
||||
github.com/paulmach/orb v0.12.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.25 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace fn-registry => /home/lucas/fn_registry
|
||||
@@ -0,0 +1,168 @@
|
||||
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
|
||||
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y=
|
||||
github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0=
|
||||
github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE=
|
||||
github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLKOaJN/AC6puCca6Jm7o=
|
||||
github.com/google/flatbuffers v25.1.24+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
|
||||
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0=
|
||||
github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8=
|
||||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
|
||||
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
|
||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
|
||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
|
||||
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
||||
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
||||
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
||||
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=
|
||||
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0=
|
||||
gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func handleListDags(executor *Executor) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
dags, err := executor.ListDAGs()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dags)
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetDag(executor *Executor) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
info, dag, validation, err := executor.GetDAG(name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Get recent runs.
|
||||
runs, _, _ := executor.store.ListRuns(dag.Name, 10, 0)
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"info": info,
|
||||
"dag": dag,
|
||||
"validation": validation,
|
||||
"runs": runs,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func handleRunDag(executor *Executor) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
info, _, _, err := executor.GetDAG(name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Execute asynchronously.
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
executor.ExecuteDAG(ctx, info.FilePath, "api")
|
||||
}()
|
||||
|
||||
// Return run acknowledgment.
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{
|
||||
"status": "accepted",
|
||||
"dag": name,
|
||||
"message": "DAG execution started",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- JSON helpers ---
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func handleListRuns(executor *Executor) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
dagName := r.URL.Query().Get("dag")
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
runs, total, err := executor.store.ListRuns(dagName, limit, offset)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"runs": runs,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetRun(executor *Executor) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
run, err := executor.store.GetRun(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if run == nil {
|
||||
writeError(w, http.StatusNotFound, "run not found")
|
||||
return
|
||||
}
|
||||
|
||||
steps, err := executor.store.ListStepResults(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"run": run,
|
||||
"steps": steps,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import "net/http"
|
||||
|
||||
func handleSchedulerStart(scheduler *Scheduler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := scheduler.Start(); err != nil {
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "started"})
|
||||
}
|
||||
}
|
||||
|
||||
func handleSchedulerStop(scheduler *Scheduler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
scheduler.Stop()
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "stopped"})
|
||||
}
|
||||
}
|
||||
|
||||
func handleSchedulerStatus(scheduler *Scheduler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
status := scheduler.Status()
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
iofs "io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/core"
|
||||
|
||||
"dag-engine/store"
|
||||
)
|
||||
|
||||
//go:embed all:frontend/dist
|
||||
var frontendDist embed.FS
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cmd := os.Args[1]
|
||||
args := os.Args[2:]
|
||||
|
||||
switch cmd {
|
||||
case "run":
|
||||
cmdRun(args)
|
||||
case "list":
|
||||
cmdList(args)
|
||||
case "status":
|
||||
cmdStatus(args)
|
||||
case "validate":
|
||||
cmdValidate(args)
|
||||
case "server":
|
||||
cmdServer(args)
|
||||
case "help", "-h", "--help":
|
||||
printUsage()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd)
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println(`dag-engine — DAG workflow executor
|
||||
|
||||
Usage:
|
||||
dag-engine <command> [options]
|
||||
|
||||
Commands:
|
||||
run <path.yaml> Execute a DAG and show results
|
||||
list [dir] List DAGs with schedule and last status
|
||||
status [dag_name] Show execution history
|
||||
validate <path.yaml> Parse and validate without executing
|
||||
server Start HTTP server with web frontend
|
||||
|
||||
Server options:
|
||||
--port <port> HTTP port (default: 8090)
|
||||
--dags-dir <dir> DAGs directory (default: ~/dagu/dags)
|
||||
--db <path> SQLite database path (default: dag_engine.db)
|
||||
--scheduler Auto-start cron scheduler`)
|
||||
}
|
||||
|
||||
// --- CLI Commands ---
|
||||
|
||||
func cmdRun(args []string) {
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: dag-engine run <path.yaml>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
dagPath := args[0]
|
||||
cfg := DefaultConfig()
|
||||
|
||||
// Parse optional flags after the path.
|
||||
fs := flag.NewFlagSet("run", flag.ExitOnError)
|
||||
fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path")
|
||||
fs.Parse(args[1:])
|
||||
|
||||
db, err := store.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
executor := NewExecutor(db, filepath.Dir(dagPath))
|
||||
|
||||
fmt.Printf("Executing %s...\n", dagPath)
|
||||
ctx := context.Background()
|
||||
runID, err := executor.ExecuteDAG(ctx, dagPath, "manual")
|
||||
|
||||
// Print results.
|
||||
if runID != "" {
|
||||
run, _ := db.GetRun(runID)
|
||||
steps, _ := db.ListStepResults(runID)
|
||||
|
||||
if run != nil {
|
||||
fmt.Println()
|
||||
for _, s := range steps {
|
||||
icon := " "
|
||||
switch s.Status {
|
||||
case "success":
|
||||
icon = "OK"
|
||||
case "failed":
|
||||
icon = "!!"
|
||||
case "skipped":
|
||||
icon = "--"
|
||||
case "running":
|
||||
icon = ".."
|
||||
}
|
||||
fmt.Printf("[%s] %s (%dms)\n", icon, s.StepName, s.DurationMs)
|
||||
if s.Status == "failed" && s.Stderr != "" {
|
||||
for _, line := range strings.Split(strings.TrimSpace(s.Stderr), "\n") {
|
||||
fmt.Printf(" %s\n", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
dur := ""
|
||||
if run.FinishedAt != nil {
|
||||
dur = fmt.Sprintf(" (%s)", run.FinishedAt.Sub(run.StartedAt).Round(time.Millisecond))
|
||||
}
|
||||
fmt.Printf("Run %s: %s%s\n", runID, strings.ToUpper(run.Status), dur)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdList(args []string) {
|
||||
cfg := DefaultConfig()
|
||||
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
||||
cfg.DagsDir = args[0]
|
||||
args = args[1:]
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("list", flag.ExitOnError)
|
||||
fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path")
|
||||
fs.Parse(args)
|
||||
|
||||
db, err := store.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
executor := NewExecutor(db, cfg.DagsDir)
|
||||
dags, err := executor.ListDAGs()
|
||||
if err != nil {
|
||||
log.Fatalf("list dags: %v", err)
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "NAME\tSCHEDULE\tTYPE\tTAGS\tLAST STATUS\tLAST RUN")
|
||||
for _, d := range dags {
|
||||
sched := strings.Join(d.Schedule, ", ")
|
||||
tags := strings.Join(d.Tags, ", ")
|
||||
lastStatus := "-"
|
||||
lastRun := "-"
|
||||
if d.LastRun != nil {
|
||||
lastStatus = d.LastRun.Status
|
||||
lastRun = d.LastRun.StartedAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
typ := d.Type
|
||||
if typ == "" {
|
||||
typ = "chain"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", d.Name, sched, typ, tags, lastStatus, lastRun)
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
func cmdStatus(args []string) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
fs := flag.NewFlagSet("status", flag.ExitOnError)
|
||||
fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path")
|
||||
limit := fs.Int("limit", 10, "number of runs to show")
|
||||
fs.Parse(args)
|
||||
|
||||
dagName := ""
|
||||
if fs.NArg() > 0 {
|
||||
dagName = fs.Arg(0)
|
||||
}
|
||||
|
||||
db, err := store.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
runs, total, err := db.ListRuns(dagName, *limit, 0)
|
||||
if err != nil {
|
||||
log.Fatalf("list runs: %v", err)
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintf(w, "Showing %d of %d runs", len(runs), total)
|
||||
if dagName != "" {
|
||||
fmt.Fprintf(w, " for %s", dagName)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "RUN_ID\tDAG\tSTATUS\tTRIGGER\tSTARTED\tDURATION")
|
||||
for _, r := range runs {
|
||||
dur := "-"
|
||||
if r.FinishedAt != nil {
|
||||
dur = r.FinishedAt.Sub(r.StartedAt).Round(time.Millisecond).String()
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
r.ID, r.DagName, r.Status, r.Trigger,
|
||||
r.StartedAt.Format("2006-01-02 15:04:05"), dur)
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
func cmdValidate(args []string) {
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: dag-engine validate <path.yaml>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(args[0])
|
||||
if err != nil {
|
||||
log.Fatalf("read: %v", err)
|
||||
}
|
||||
|
||||
dag, err := core.DagParse(data)
|
||||
if err != nil {
|
||||
log.Fatalf("parse error: %v", err)
|
||||
}
|
||||
|
||||
result := core.DagValidate(dag)
|
||||
|
||||
fmt.Printf("DAG: %s\n", dag.Name)
|
||||
fmt.Printf("Steps: %d\n", len(dag.Steps))
|
||||
fmt.Printf("Schedule: %v\n", dag.Schedule)
|
||||
fmt.Printf("Type: %s\n", dag.Type)
|
||||
|
||||
if result.Valid {
|
||||
fmt.Println("Validation: PASS")
|
||||
for i, level := range result.Levels {
|
||||
fmt.Printf(" Level %d: %v\n", i, level)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Validation: FAIL")
|
||||
for _, e := range result.Errors {
|
||||
fmt.Printf(" ERROR: %s\n", e)
|
||||
}
|
||||
}
|
||||
for _, w := range result.Warnings {
|
||||
fmt.Printf(" WARNING: %s\n", w)
|
||||
}
|
||||
|
||||
if !result.Valid {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server Command ---
|
||||
|
||||
func cmdServer(args []string) {
|
||||
cfg := DefaultConfig()
|
||||
fs := flag.NewFlagSet("server", flag.ExitOnError)
|
||||
cfg.ParseFlags(fs, args)
|
||||
|
||||
db, err := store.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
executor := NewExecutor(db, cfg.DagsDir)
|
||||
scheduler := NewScheduler(executor, cfg.DagsDir)
|
||||
|
||||
// Prepare frontend FS.
|
||||
var feFS iofs.FS
|
||||
distFS, err := iofs.Sub(frontendDist, "frontend/dist")
|
||||
if err == nil {
|
||||
// Check if dist has content (built frontend exists).
|
||||
entries, _ := iofs.ReadDir(distFS, ".")
|
||||
if len(entries) > 0 {
|
||||
feFS = distFS
|
||||
log.Printf("serving frontend from embedded dist/")
|
||||
}
|
||||
}
|
||||
if feFS == nil {
|
||||
log.Printf("no frontend build found, API-only mode")
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
RegisterAPI(mux, executor, scheduler, feFS)
|
||||
|
||||
handler := corsMiddleware(loggingMiddleware(mux))
|
||||
|
||||
if cfg.AutoScheduler {
|
||||
if err := scheduler.Start(); err != nil {
|
||||
log.Printf("scheduler start: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||
log.Printf("dag-engine server starting on http://0.0.0.0%s", addr)
|
||||
log.Printf("dags dir: %s", cfg.DagsDir)
|
||||
log.Printf("database: %s", cfg.DBPath)
|
||||
|
||||
srv := &http.Server{Addr: addr, Handler: handler}
|
||||
|
||||
// Graceful shutdown.
|
||||
go func() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
log.Println("shutting down...")
|
||||
scheduler.Stop()
|
||||
srv.Shutdown(context.Background())
|
||||
}()
|
||||
|
||||
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
||||
log.Fatalf("server: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// corsMiddleware adds permissive CORS headers for development.
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// loggingMiddleware logs each HTTP request with method, path and duration.
|
||||
func loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/core"
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
// ScheduledDAG represents a DAG with a parsed cron schedule.
|
||||
type ScheduledDAG struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Schedule string `json:"schedule"`
|
||||
NextRun time.Time `json:"next_run"`
|
||||
}
|
||||
|
||||
// Scheduler manages cron-triggered DAG execution.
|
||||
type Scheduler struct {
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
cancel context.CancelFunc
|
||||
dagsDir string
|
||||
executor *Executor
|
||||
dags []ScheduledDAG
|
||||
}
|
||||
|
||||
// NewScheduler creates a new scheduler.
|
||||
func NewScheduler(executor *Executor, dagsDir string) *Scheduler {
|
||||
return &Scheduler{
|
||||
executor: executor,
|
||||
dagsDir: dagsDir,
|
||||
}
|
||||
}
|
||||
|
||||
// Start scans for DAGs with schedules and starts cron tickers for each.
|
||||
func (s *Scheduler) Start() error {
|
||||
s.mu.Lock()
|
||||
if s.running {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("scheduler already running")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.cancel = cancel
|
||||
s.running = true
|
||||
s.mu.Unlock()
|
||||
|
||||
scheduled, err := s.scanDAGs()
|
||||
if err != nil {
|
||||
s.mu.Lock()
|
||||
s.running = false
|
||||
s.mu.Unlock()
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.dags = scheduled
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Printf("[scheduler] started with %d DAGs", len(scheduled))
|
||||
|
||||
for _, dag := range scheduled {
|
||||
dag := dag
|
||||
go s.runTicker(ctx, dag)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop cancels all tickers and stops the scheduler.
|
||||
func (s *Scheduler) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.running {
|
||||
return
|
||||
}
|
||||
s.cancel()
|
||||
s.running = false
|
||||
s.dags = nil
|
||||
log.Printf("[scheduler] stopped")
|
||||
}
|
||||
|
||||
// IsRunning returns true if the scheduler is active.
|
||||
func (s *Scheduler) IsRunning() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.running
|
||||
}
|
||||
|
||||
// Status returns the list of scheduled DAGs with their next run time.
|
||||
type SchedulerStatus struct {
|
||||
Running bool `json:"running"`
|
||||
DAGs []ScheduledDAG `json:"dags"`
|
||||
}
|
||||
|
||||
func (s *Scheduler) Status() SchedulerStatus {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return SchedulerStatus{
|
||||
Running: s.running,
|
||||
DAGs: s.dags,
|
||||
}
|
||||
}
|
||||
|
||||
// scanDAGs reads the dags directory and returns DAGs that have cron schedules.
|
||||
func (s *Scheduler) scanDAGs() ([]ScheduledDAG, error) {
|
||||
entries, err := os.ReadDir(s.dagsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var scheduled []ScheduledDAG
|
||||
for _, entry := range entries {
|
||||
ext := filepath.Ext(entry.Name())
|
||||
if ext != ".yaml" && ext != ".yml" {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(s.dagsDir, entry.Name())
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
dag, err := core.DagParse(data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, expr := range dag.Schedule {
|
||||
sched, err := core.ParseCronExpr(strings.TrimSpace(expr))
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] invalid cron %q in %s: %v", expr, dag.Name, err)
|
||||
continue
|
||||
}
|
||||
next := core.NextCronTime(sched, time.Now())
|
||||
scheduled = append(scheduled, ScheduledDAG{
|
||||
Name: dag.Name,
|
||||
Path: path,
|
||||
Schedule: expr,
|
||||
NextRun: next,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return scheduled, nil
|
||||
}
|
||||
|
||||
// runTicker starts a cron ticker for a single DAG schedule.
|
||||
func (s *Scheduler) runTicker(ctx context.Context, dag ScheduledDAG) {
|
||||
sched, err := core.ParseCronExpr(strings.TrimSpace(dag.Schedule))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert core.CronSchedule to infra.CronTickerSchedule.
|
||||
tickerSched := infra.CronTickerSchedule{
|
||||
Minute: sched.Minute,
|
||||
Hour: sched.Hour,
|
||||
DayOfMonth: sched.DayOfMonth,
|
||||
Month: sched.Month,
|
||||
DayOfWeek: sched.DayOfWeek,
|
||||
}
|
||||
|
||||
ch := infra.CronTicker(tickerSched, ctx)
|
||||
log.Printf("[scheduler] ticker started for %s (%s), next: %s", dag.Name, dag.Schedule, dag.NextRun.Format(time.RFC3339))
|
||||
|
||||
for t := range ch {
|
||||
log.Printf("[scheduler] triggered %s at %s", dag.Name, t.Format(time.RFC3339))
|
||||
go func() {
|
||||
runID, err := s.executor.ExecuteDAG(ctx, dag.Path, "cron")
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] %s failed: %v (run: %s)", dag.Name, err, runID)
|
||||
} else {
|
||||
log.Printf("[scheduler] %s completed (run: %s)", dag.Name, runID)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE IF NOT EXISTS dag_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
dag_name TEXT NOT NULL,
|
||||
dag_path TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','success','failed','cancelled')),
|
||||
trigger TEXT NOT NULL DEFAULT 'manual' CHECK(trigger IN ('manual','cron','api')),
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dag_step_results (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL REFERENCES dag_runs(id) ON DELETE CASCADE,
|
||||
step_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','success','failed','skipped')),
|
||||
exit_code INTEGER NOT NULL DEFAULT -1,
|
||||
stdout TEXT NOT NULL DEFAULT '',
|
||||
stderr TEXT NOT NULL DEFAULT '',
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_dag_name ON dag_runs(dag_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_status ON dag_runs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_started ON dag_runs(started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_step_results_run ON dag_step_results(run_id);
|
||||
@@ -0,0 +1,231 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
//go:embed migrations/001_init.sql
|
||||
var migrationSQL string
|
||||
|
||||
// DB wraps a SQLite connection for DAG run persistence.
|
||||
type DB struct {
|
||||
conn *sql.DB
|
||||
path string
|
||||
}
|
||||
|
||||
// Open opens or creates a DAG engine database at the given path.
|
||||
func Open(path string) (*DB, error) {
|
||||
conn, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=on")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: open %s: %w", path, err)
|
||||
}
|
||||
if _, err := conn.Exec(migrationSQL); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("store: migrate: %w", err)
|
||||
}
|
||||
return &DB{conn: conn, path: path}, nil
|
||||
}
|
||||
|
||||
// Close closes the database connection.
|
||||
func (db *DB) Close() error {
|
||||
return db.conn.Close()
|
||||
}
|
||||
|
||||
// --- DagRun CRUD ---
|
||||
|
||||
// DagRun mirrors infra.DagRun for the store layer.
|
||||
type DagRun struct {
|
||||
ID string
|
||||
DagName string
|
||||
DagPath string
|
||||
Status string
|
||||
Trigger string
|
||||
StartedAt time.Time
|
||||
FinishedAt *time.Time
|
||||
Error string
|
||||
}
|
||||
|
||||
// CreateRun inserts a new run record.
|
||||
func (db *DB) CreateRun(run *DagRun) error {
|
||||
_, err := db.conn.Exec(
|
||||
`INSERT INTO dag_runs (id, dag_name, dag_path, status, trigger, started_at, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
run.ID, run.DagName, run.DagPath, run.Status, run.Trigger,
|
||||
run.StartedAt.Format(time.RFC3339), run.Error,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateRunStatus updates a run's status and optionally its finished_at and error.
|
||||
func (db *DB) UpdateRunStatus(id, status string, finishedAt *time.Time, errMsg string) error {
|
||||
var fin *string
|
||||
if finishedAt != nil {
|
||||
s := finishedAt.Format(time.RFC3339)
|
||||
fin = &s
|
||||
}
|
||||
_, err := db.conn.Exec(
|
||||
`UPDATE dag_runs SET status=?, finished_at=?, error=? WHERE id=?`,
|
||||
status, fin, errMsg, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetRun retrieves a single run by ID.
|
||||
func (db *DB) GetRun(id string) (*DagRun, error) {
|
||||
row := db.conn.QueryRow(
|
||||
`SELECT id, dag_name, dag_path, status, trigger, started_at, finished_at, error
|
||||
FROM dag_runs WHERE id=?`, id,
|
||||
)
|
||||
return scanRun(row)
|
||||
}
|
||||
|
||||
// ListRuns returns runs, newest first, with optional dag name filter.
|
||||
func (db *DB) ListRuns(dagName string, limit, offset int) ([]DagRun, int, error) {
|
||||
var total int
|
||||
var args []interface{}
|
||||
where := ""
|
||||
if dagName != "" {
|
||||
where = " WHERE dag_name=?"
|
||||
args = append(args, dagName)
|
||||
}
|
||||
err := db.conn.QueryRow("SELECT COUNT(*) FROM dag_runs"+where, args...).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
query := "SELECT id, dag_name, dag_path, status, trigger, started_at, finished_at, error FROM dag_runs" +
|
||||
where + " ORDER BY started_at DESC LIMIT ? OFFSET ?"
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := db.conn.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var runs []DagRun
|
||||
for rows.Next() {
|
||||
r, err := scanRunRows(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
runs = append(runs, *r)
|
||||
}
|
||||
return runs, total, rows.Err()
|
||||
}
|
||||
|
||||
// --- DagStepResult CRUD ---
|
||||
|
||||
// DagStepResult mirrors infra.DagStepResult for the store layer.
|
||||
type DagStepResult struct {
|
||||
ID string
|
||||
RunID string
|
||||
StepName string
|
||||
Status string
|
||||
ExitCode int
|
||||
Stdout string
|
||||
Stderr string
|
||||
StartedAt *time.Time
|
||||
FinishedAt *time.Time
|
||||
DurationMs int64
|
||||
Error string
|
||||
}
|
||||
|
||||
// InsertStepResult inserts a new step result.
|
||||
func (db *DB) InsertStepResult(r *DagStepResult) error {
|
||||
var startedAt, finishedAt *string
|
||||
if r.StartedAt != nil {
|
||||
s := r.StartedAt.Format(time.RFC3339)
|
||||
startedAt = &s
|
||||
}
|
||||
if r.FinishedAt != nil {
|
||||
s := r.FinishedAt.Format(time.RFC3339)
|
||||
finishedAt = &s
|
||||
}
|
||||
_, err := db.conn.Exec(
|
||||
`INSERT INTO dag_step_results (id, run_id, step_name, status, exit_code, stdout, stderr, started_at, finished_at, duration_ms, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
r.ID, r.RunID, r.StepName, r.Status, r.ExitCode, r.Stdout, r.Stderr,
|
||||
startedAt, finishedAt, r.DurationMs, r.Error,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateStepResult updates a step result by ID.
|
||||
func (db *DB) UpdateStepResult(id, status string, exitCode int, stdout, stderr string, finishedAt *time.Time, durationMs int64, errMsg string) error {
|
||||
var fin *string
|
||||
if finishedAt != nil {
|
||||
s := finishedAt.Format(time.RFC3339)
|
||||
fin = &s
|
||||
}
|
||||
_, err := db.conn.Exec(
|
||||
`UPDATE dag_step_results SET status=?, exit_code=?, stdout=?, stderr=?, finished_at=?, duration_ms=?, error=? WHERE id=?`,
|
||||
status, exitCode, stdout, stderr, fin, durationMs, errMsg, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListStepResults returns all step results for a given run.
|
||||
func (db *DB) ListStepResults(runID string) ([]DagStepResult, error) {
|
||||
rows, err := db.conn.Query(
|
||||
`SELECT id, run_id, step_name, status, exit_code, stdout, stderr, started_at, finished_at, duration_ms, error
|
||||
FROM dag_step_results WHERE run_id=? ORDER BY started_at ASC`, runID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []DagStepResult
|
||||
for rows.Next() {
|
||||
var r DagStepResult
|
||||
var startedAt, finishedAt sql.NullString
|
||||
if err := rows.Scan(&r.ID, &r.RunID, &r.StepName, &r.Status, &r.ExitCode,
|
||||
&r.Stdout, &r.Stderr, &startedAt, &finishedAt, &r.DurationMs, &r.Error); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if startedAt.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, startedAt.String)
|
||||
r.StartedAt = &t
|
||||
}
|
||||
if finishedAt.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, finishedAt.String)
|
||||
r.FinishedAt = &t
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
// --- scan helpers ---
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}
|
||||
|
||||
func scanRun(s scanner) (*DagRun, error) {
|
||||
var r DagRun
|
||||
var startedAt string
|
||||
var finishedAt sql.NullString
|
||||
if err := s.Scan(&r.ID, &r.DagName, &r.DagPath, &r.Status, &r.Trigger, &startedAt, &finishedAt, &r.Error); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
r.StartedAt, _ = time.Parse(time.RFC3339, startedAt)
|
||||
if finishedAt.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, finishedAt.String)
|
||||
r.FinishedAt = &t
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func scanRunRows(rows *sql.Rows) (*DagRun, error) {
|
||||
return scanRun(rows)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: shaders_lab
|
||||
lang: cpp
|
||||
domain: gfx
|
||||
description: "Live GLSL shader playground con DAG pipeline. Editor de codigo con compilacion en caliente, panel DAG con paleta de generadores/filtros/output, dos canvas (Code y DAG), parseo de uniforms anotados (// @slider, @color, @xy) que se convierten en controles, persistencia de generators en shaders_lab.db, y guardado/carga de layouts ImGui."
|
||||
tags: [imgui, opengl, glsl, shaders, dag, live-coding, playground, sqlite]
|
||||
uses_functions:
|
||||
# gfx
|
||||
- gl_loader_cpp_gfx
|
||||
- gl_shader_cpp_gfx
|
||||
- gl_framebuffer_cpp_gfx
|
||||
- fullscreen_quad_cpp_gfx
|
||||
- shader_canvas_cpp_gfx
|
||||
- uniform_parser_cpp_gfx
|
||||
- uniform_panel_cpp_gfx
|
||||
- dag_catalog_cpp_gfx
|
||||
- dag_compile_cpp_gfx
|
||||
- dag_uniforms_cpp_gfx
|
||||
- dag_panel_cpp_gfx
|
||||
- dag_node_editor_cpp_gfx
|
||||
- dag_palette_cpp_gfx
|
||||
- dag_node_previews_cpp_gfx
|
||||
- shaderlab_db_cpp_gfx
|
||||
- code_to_generator_cpp_gfx
|
||||
# core (modal Save-as-generator)
|
||||
- modal_dialog_cpp_core
|
||||
- text_input_cpp_core
|
||||
- button_cpp_core
|
||||
uses_types:
|
||||
- dag_types_cpp_gfx
|
||||
framework: "imgui"
|
||||
entry_point: "main.cpp"
|
||||
dir_path: "cpp/apps/shaders_lab"
|
||||
repo_url: ""
|
||||
---
|
||||
|
||||
## Arquitectura
|
||||
|
||||
App ImGui de live-coding GLSL con dos modos en paralelo:
|
||||
|
||||
1. **Code panel** — editor de fragment shader libre. Las anotaciones en
|
||||
uniforms (`// @slider`, `// @color`, `// @xy`, `// @toggle`) se parsean y
|
||||
convierten en controles del panel **Controls** que escriben en un
|
||||
`UniformStore` aplicado al programa cada frame.
|
||||
2. **DAG panel** — pipeline node-based con catalogo de generadores
|
||||
(plasma, voronoi, etc.) y filtros (blur, threshold, etc.) que se
|
||||
compilan a un fragment shader unificado y se renderizan en **Canvas DAG**.
|
||||
|
||||
Al guardar un Code shader como "generator" se traduce a un `DagNodeDef` y se
|
||||
persiste en `shaders_lab.db` (tabla via `shaderlab_db`), apareciendo en la
|
||||
paleta del DAG junto a los builtins.
|
||||
|
||||
## Capas
|
||||
|
||||
| Archivo | Responsabilidad |
|
||||
|---|---|
|
||||
| `main.cpp` | UI shell, paneles, modal save-as, layouts, AppConfig |
|
||||
| `compiler.cpp` | `compile_code()`, `compile_dag()`, `mark_code_dirty()` con debounce 250ms |
|
||||
|
||||
`main.cpp` mantiene estado global de sesion (g_source, g_pipeline, g_descs,
|
||||
g_store, g_layouts...) — ImGui retained-mode obliga a que persista entre
|
||||
frames. Toda la logica pura de compilacion vive en `compiler.cpp` y en las
|
||||
funciones `dag_compile`, `code_to_generator`, `uniform_parser` del registry.
|
||||
|
||||
## Persistencia
|
||||
|
||||
- **`shaders_lab.db`** (junto al .exe) — tabla de generators de usuario via
|
||||
`shaderlab_db_*`, ademas de `imgui_layouts` (creada por `layout_storage`).
|
||||
- `imgui.ini` y `app_settings.ini` — gestionados por `fn::run_app` en
|
||||
`<exe_dir>/local_files/`.
|
||||
|
||||
## Paneles
|
||||
|
||||
| Panel | Atajo | Que muestra |
|
||||
|---|---|---|
|
||||
| Code | Ctrl+1 | Editor del fragment shader + boton "Save as generator" |
|
||||
| DAG Pipeline | Ctrl+2 | Node editor con la pipeline |
|
||||
| Canvas Code | Ctrl+3 | Render del Code shader |
|
||||
| Canvas DAG | Ctrl+4 | Render del shader compilado del DAG |
|
||||
| Controls | Ctrl+5 | Sliders/color pickers de uniforms anotados |
|
||||
| Functions | Ctrl+6 | Paleta del DAG (generators + filters + output) |
|
||||
| Generated GLSL | Ctrl+7 | GLSL final del DAG con uniforms baked como const array |
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
cd cpp && cmake -B build/linux -S . && cmake --build build/linux --target shaders_lab
|
||||
|
||||
# Windows (cross-compile)
|
||||
cd cpp && cmake -B build/windows -S . -DCMAKE_TOOLCHAIN_FILE=toolchains/mingw-w64.cmake \
|
||||
&& cmake --build build/windows --target shaders_lab
|
||||
```
|
||||
|
||||
## Decisiones
|
||||
|
||||
- `init_gl_loader = true` (via `fn::run_app` por default cuando se enlaza
|
||||
con OpenGL) — `shader_canvas`, `gl_shader`, `gl_framebuffer` llaman gl*.
|
||||
- `viewports = true` — los Canvas se pueden arrastrar fuera del main.
|
||||
- DAG default: arranca con un nodo "plasma" + "output" si la paleta los
|
||||
encuentra; persiste el INI con `layout_storage`.
|
||||
- El boton "Save as generator" valida snake_case, evita colisionar con
|
||||
builtins, traduce con `code_to_generator`, persiste con `shaderlab_db_save_generator`,
|
||||
y registra el nodo nuevo en el catalogo en vivo (`dag_register_node`).
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: analyze_dns
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "analyze_dns(domain: string, mode: string) -> void"
|
||||
description: "Análisis DNS completo de un dominio: registros A/AAAA/MX/NS/TXT/CNAME/SOA, consulta whois y verificación contra listas negras DNSBL (spamhaus, spamcop, sorbs, barracuda)."
|
||||
tags: [bash, cybersecurity, dns, network, whois, dnsbl, reconnaissance, pendiente-usar]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: domain
|
||||
desc: "dominio a analizar, ej: example.com"
|
||||
- name: mode
|
||||
desc: "modo de análisis: records (solo registros DNS), whois (solo whois), dnsbl (solo listas negras) o all (todo, por defecto)"
|
||||
output: "imprime registros DNS, información whois y estado DNSBL a stdout con colores ANSI"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/cybersecurity/analyze_dns.sh"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||
source_license: "MIT"
|
||||
source_file: "scripts/linux/ciberseguridad/redes/analisis_dns.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/cybersecurity/analyze_dns.sh
|
||||
|
||||
# Análisis completo
|
||||
analyze_dns example.com
|
||||
|
||||
# Solo registros DNS
|
||||
analyze_dns example.com records
|
||||
|
||||
# Solo whois
|
||||
analyze_dns example.com whois
|
||||
|
||||
# Solo DNSBL
|
||||
analyze_dns example.com dnsbl
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Requiere `dig` (paquete dnsutils). `whois` es opcional — si no está instalado y el modo es `all`, se omite el paso whois con aviso. Las listas negras DNSBL se consultan via DNS inverso (técnica estándar sin HTTP). El modo `dnsbl` resuelve primero la IP del dominio y luego construye la consulta invertida para cada blacklist.
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env bash
|
||||
# analyze_dns
|
||||
# -----------
|
||||
# Análisis DNS completo de un dominio: registros A/AAAA/MX/NS/TXT/CNAME/SOA,
|
||||
# consulta whois y verificación contra listas negras DNSBL.
|
||||
#
|
||||
# USO (directo):
|
||||
# analyze_dns example.com [records|whois|dnsbl|all]
|
||||
#
|
||||
# Depende de: dig, whois (opcional), curl (para DNSBL)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||
bash_colors
|
||||
bash_log_init
|
||||
|
||||
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||
|
||||
_dns_is_valid_domain() {
|
||||
local domain="$1"
|
||||
[[ -n "$domain" && "$domain" =~ ^[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}$ ]]
|
||||
}
|
||||
|
||||
_dns_build_dnsbl_query() {
|
||||
local ip="$1"
|
||||
local bl="$2"
|
||||
local reversed
|
||||
reversed="$(echo "$ip" | awk -F. '{print $4"."$3"."$2"."$1}')"
|
||||
echo "${reversed}.${bl}"
|
||||
}
|
||||
|
||||
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||
|
||||
_dns_query_record() {
|
||||
local domain="$1"
|
||||
local type="$2"
|
||||
local result
|
||||
result="$(dig +short "$type" "$domain" 2>/dev/null || true)"
|
||||
if [[ -z "$result" ]]; then
|
||||
echo " (sin registros)"
|
||||
else
|
||||
echo "$result" | while IFS= read -r line; do
|
||||
echo " * $line"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
_dns_show_all_records() {
|
||||
local domain="$1"
|
||||
echo ""
|
||||
for type in A AAAA MX NS TXT CNAME SOA; do
|
||||
echo -e "${CYAN}── ${type} ──────────────────${NC}"
|
||||
_dns_query_record "$domain" "$type"
|
||||
echo ""
|
||||
done
|
||||
}
|
||||
|
||||
_dns_show_whois() {
|
||||
local domain="$1"
|
||||
echo ""
|
||||
info "Consultando whois de ${domain}..."
|
||||
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||
whois "$domain" 2>/dev/null \
|
||||
| grep -iE "(registrar|registrant|creation|expiry|expire|updated|name server|status)" \
|
||||
| head -20 \
|
||||
| while IFS= read -r line; do echo -e " ${DIM_GRAY}${line}${NC}"; done
|
||||
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||
}
|
||||
|
||||
_dns_check_dnsbl() {
|
||||
local domain="$1"
|
||||
local ip
|
||||
ip="$(dig +short A "$domain" 2>/dev/null | head -1 || true)"
|
||||
|
||||
if [[ -z "$ip" ]]; then
|
||||
warning "No se pudo resolver la IP de $domain para comprobar DNSBL"
|
||||
return
|
||||
fi
|
||||
|
||||
info "IP a comprobar: $ip"
|
||||
echo ""
|
||||
|
||||
local blacklists=(
|
||||
"zen.spamhaus.org"
|
||||
"bl.spamcop.net"
|
||||
"dnsbl.sorbs.net"
|
||||
"b.barracudacentral.org"
|
||||
)
|
||||
|
||||
local found=0
|
||||
for bl in "${blacklists[@]}"; do
|
||||
local query
|
||||
query="$(_dns_build_dnsbl_query "$ip" "$bl")"
|
||||
local result
|
||||
result="$(dig +short A "$query" 2>/dev/null || true)"
|
||||
if [[ -n "$result" ]]; then
|
||||
echo -e " ${RED}LISTADO${NC} ${bl} ($result)"
|
||||
found=$((found + 1))
|
||||
else
|
||||
echo -e " ${GREEN}limpio${NC} ${bl}"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
if [[ $found -eq 0 ]]; then
|
||||
success "La IP no aparece en ninguna lista negra comprobada"
|
||||
else
|
||||
warning "La IP aparece en ${found} lista(s) negra(s)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||
|
||||
analyze_dns() {
|
||||
local domain="$1"
|
||||
local mode="${2:-all}"
|
||||
|
||||
if [[ -z "$domain" ]]; then
|
||||
error "analyze_dns: se requiere un dominio como primer argumento" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! _dns_is_valid_domain "$domain"; then
|
||||
error "analyze_dns: dominio no válido: '$domain'" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! command -v dig &>/dev/null; then
|
||||
error "analyze_dns: 'dig' no está instalado (sudo apt install dnsutils)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
info "Analizando: ${domain}"
|
||||
|
||||
case "$mode" in
|
||||
records)
|
||||
_dns_show_all_records "$domain"
|
||||
;;
|
||||
whois)
|
||||
if ! command -v whois &>/dev/null; then
|
||||
error "analyze_dns: 'whois' no está instalado (sudo apt install whois)" >&2
|
||||
return 1
|
||||
fi
|
||||
_dns_show_whois "$domain"
|
||||
;;
|
||||
dnsbl)
|
||||
_dns_check_dnsbl "$domain"
|
||||
;;
|
||||
all)
|
||||
_dns_show_all_records "$domain"
|
||||
if command -v whois &>/dev/null; then
|
||||
_dns_show_whois "$domain"
|
||||
else
|
||||
warning "whois no disponible, omitiendo"
|
||||
fi
|
||||
echo ""
|
||||
_dns_check_dnsbl "$domain"
|
||||
;;
|
||||
*)
|
||||
error "analyze_dns: modo no válido '$mode'. Use: records|whois|dnsbl|all" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
analyze_dns "$@"
|
||||
fi
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: audit_http_headers
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "audit_http_headers(url: string) -> void"
|
||||
description: "Audita las cabeceras HTTP de seguridad de una URL: verifica la presencia de HSTS (con validación de max-age mínimo de 6 meses), Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy y cabeceras CORS. También detecta cabeceras que exponen información del servidor."
|
||||
tags: [bash, cybersecurity, web, http, headers, security, hsts, csp, hardening, pendiente-usar]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: url
|
||||
desc: "URL del sitio web a auditar; si no tiene esquema se añade https:// automáticamente"
|
||||
output: "imprime el estado de cada cabecera de seguridad (ok/falta/advertencia), el valor de las presentes y cabeceras que exponen información del servidor"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/cybersecurity/audit_http_headers.sh"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||
source_license: "MIT"
|
||||
source_file: "scripts/linux/ciberseguridad/web/cabeceras_http.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/cybersecurity/audit_http_headers.sh
|
||||
|
||||
# Con URL completa
|
||||
audit_http_headers https://example.com
|
||||
|
||||
# Sin esquema (añade https:// automáticamente)
|
||||
audit_http_headers example.com
|
||||
|
||||
# Seguir redirecciones
|
||||
audit_http_headers http://example.com
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Usa `curl -sI --location` para seguir redirecciones y obtener solo cabeceras. El check de HSTS valida que `max-age` sea >= 15.768.000 segundos (6 meses), valor mínimo recomendado por OWASP. Las cabeceras Server, X-Powered-By, X-AspNet-Version y X-Generator se marcan como advertencia por revelar información del stack tecnológico. Timeout de 15 segundos para evitar cuelgues.
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env bash
|
||||
# audit_http_headers
|
||||
# ------------------
|
||||
# Audita las cabeceras HTTP de seguridad de una URL: HSTS, CSP, X-Frame-Options,
|
||||
# X-Content-Type-Options, Referrer-Policy, Permissions-Policy y otras.
|
||||
# También muestra cabeceras que exponen información del servidor.
|
||||
#
|
||||
# USO (directo):
|
||||
# audit_http_headers <url>
|
||||
#
|
||||
# Depende de: curl
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||
bash_colors
|
||||
bash_log_init
|
||||
|
||||
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||
|
||||
_hdr_normalize_url() {
|
||||
local url="$1"
|
||||
if [[ ! "$url" =~ ^https?:// ]]; then
|
||||
echo "https://${url}"
|
||||
else
|
||||
echo "$url"
|
||||
fi
|
||||
}
|
||||
|
||||
_hdr_header_present() {
|
||||
local headers="$1"
|
||||
local name="$2"
|
||||
echo "$headers" | grep -qi "^${name}:"
|
||||
}
|
||||
|
||||
_hdr_extract_value() {
|
||||
local headers="$1"
|
||||
local name="$2"
|
||||
echo "$headers" | grep -i "^${name}:" | cut -d: -f2- | xargs
|
||||
}
|
||||
|
||||
_hdr_hsts_is_strong() {
|
||||
local value="$1"
|
||||
local max_age
|
||||
max_age="$(echo "$value" | grep -oE 'max-age=[0-9]+' | cut -d= -f2 || echo 0)"
|
||||
[[ "$max_age" -ge 15768000 ]] # 6 meses en segundos
|
||||
}
|
||||
|
||||
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||
|
||||
_hdr_fetch() {
|
||||
local url="$1"
|
||||
curl -sI --max-time 15 --location "$url" 2>/dev/null | tr -d '\r'
|
||||
}
|
||||
|
||||
_hdr_check_header() {
|
||||
local headers="$1"
|
||||
local name="$2"
|
||||
local description="$3"
|
||||
|
||||
if _hdr_header_present "$headers" "$name"; then
|
||||
local value
|
||||
value="$(_hdr_extract_value "$headers" "$name")"
|
||||
echo -e " ${GREEN}[ok]${NC} ${name}"
|
||||
echo -e " ${GRAY}${value}${NC}"
|
||||
else
|
||||
echo -e " ${RED}[x] ${NC} ${name} -- ${description}"
|
||||
fi
|
||||
}
|
||||
|
||||
_hdr_check_hsts() {
|
||||
local headers="$1"
|
||||
local name="Strict-Transport-Security"
|
||||
if _hdr_header_present "$headers" "$name"; then
|
||||
local value
|
||||
value="$(_hdr_extract_value "$headers" "$name")"
|
||||
if _hdr_hsts_is_strong "$value"; then
|
||||
echo -e " ${GREEN}[ok]${NC} ${name}"
|
||||
else
|
||||
echo -e " ${YELLOW}[!] ${NC} ${name} -- max-age demasiado corto (<6 meses)"
|
||||
fi
|
||||
echo -e " ${GRAY}${value}${NC}"
|
||||
else
|
||||
echo -e " ${RED}[x] ${NC} ${name} -- HSTS no configurado"
|
||||
fi
|
||||
}
|
||||
|
||||
_hdr_show_server_info() {
|
||||
local headers="$1"
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}── Información del servidor ──────────────────────${NC}"
|
||||
|
||||
for h in Server X-Powered-By X-AspNet-Version X-Generator; do
|
||||
if _hdr_header_present "$headers" "$h"; then
|
||||
local val
|
||||
val="$(_hdr_extract_value "$headers" "$h")"
|
||||
echo -e " ${YELLOW}[!]${NC} ${h}: ${val} (información expuesta)"
|
||||
fi
|
||||
done
|
||||
|
||||
local status
|
||||
status="$(echo "$headers" | head -1)"
|
||||
echo -e " ${CYAN}Status:${NC} ${status}"
|
||||
}
|
||||
|
||||
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||
|
||||
audit_http_headers() {
|
||||
local raw_url="$1"
|
||||
|
||||
if [[ -z "$raw_url" ]]; then
|
||||
error "audit_http_headers: se requiere una URL como argumento" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! command -v curl &>/dev/null; then
|
||||
error "audit_http_headers: 'curl' no está instalado (sudo apt install curl)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local url
|
||||
url="$(_hdr_normalize_url "$raw_url")"
|
||||
|
||||
info "Consultando cabeceras de: ${url}"
|
||||
local headers
|
||||
headers="$(_hdr_fetch "$url")"
|
||||
|
||||
if [[ -z "$headers" ]]; then
|
||||
error "audit_http_headers: no se pudieron obtener las cabeceras. ¿El sitio está disponible?" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${PURPLE}════════ Cabeceras de Seguridad ════════════════${NC}"
|
||||
echo ""
|
||||
|
||||
_hdr_check_hsts "$headers"
|
||||
_hdr_check_header "$headers" "Content-Security-Policy" "Previene XSS e inyección de contenido"
|
||||
_hdr_check_header "$headers" "X-Frame-Options" "Previene clickjacking"
|
||||
_hdr_check_header "$headers" "X-Content-Type-Options" "Previene MIME sniffing"
|
||||
_hdr_check_header "$headers" "Referrer-Policy" "Controla información del referrer"
|
||||
_hdr_check_header "$headers" "Permissions-Policy" "Controla acceso a APIs del navegador"
|
||||
_hdr_check_header "$headers" "Cross-Origin-Opener-Policy" "Aísla el contexto de navegación"
|
||||
_hdr_check_header "$headers" "Cross-Origin-Resource-Policy" "Controla compartición de recursos"
|
||||
|
||||
_hdr_show_server_info "$headers"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
audit_http_headers "$@"
|
||||
fi
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: audit_ssh_config
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "audit_ssh_config(config_path: string) -> void"
|
||||
description: "Audita la configuración de sshd_config evaluando parámetros de seguridad críticos (PermitRootLogin, PasswordAuthentication, Port, MaxAuthTries, X11Forwarding, AllowUsers). También revisa intentos de login fallidos en los logs y lista las claves autorizadas del usuario actual."
|
||||
tags: [bash, cybersecurity, ssh, audit, security, hardening, linux, pendiente-usar]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: config_path
|
||||
desc: "ruta al archivo sshd_config a auditar (por defecto: /etc/ssh/sshd_config)"
|
||||
output: "imprime checks con nivel ok/warn/bad para cada parámetro, últimos 10 intentos de login fallidos y lista de claves autorizadas"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/cybersecurity/audit_ssh_config.sh"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||
source_license: "MIT"
|
||||
source_file: "scripts/linux/ciberseguridad/sistema/auditar_ssh.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/cybersecurity/audit_ssh_config.sh
|
||||
|
||||
# Auditar la configuración por defecto
|
||||
audit_ssh_config
|
||||
|
||||
# Auditar un archivo alternativo
|
||||
audit_ssh_config /etc/ssh/sshd_config.d/custom.conf
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Los logs de intentos fallidos se buscan primero en `journalctl` (systemd) y si no está disponible en `/var/log/auth.log`. Leer `/etc/ssh/sshd_config` puede requerir permisos de root en algunos sistemas. Los criterios de evaluación siguen las recomendaciones de CIS Benchmark para SSH: PermitRootLogin=no, PasswordAuthentication=no, MaxAuthTries<=3.
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env bash
|
||||
# audit_ssh_config
|
||||
# ----------------
|
||||
# Audita la configuración de sshd_config evaluando parámetros de seguridad,
|
||||
# revisa intentos de login fallidos y lista las claves autorizadas del usuario.
|
||||
#
|
||||
# USO (directo):
|
||||
# audit_ssh_config [/ruta/a/sshd_config]
|
||||
#
|
||||
# Depende de: grep, ssh (opcional para validación), journalctl o /var/log/auth.log
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||
bash_colors
|
||||
bash_log_init
|
||||
|
||||
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||
|
||||
_ssh_get_value() {
|
||||
local config="$1"
|
||||
local key="$2"
|
||||
grep -iE "^[[:space:]]*${key}[[:space:]]" "$config" 2>/dev/null \
|
||||
| tail -1 | awk '{print $2}' | xargs
|
||||
}
|
||||
|
||||
_ssh_eval_permit_root() {
|
||||
local val="${1:-yes}"
|
||||
case "${val,,}" in
|
||||
no|prohibit-password) echo "ok" ;;
|
||||
without-password) echo "warn" ;;
|
||||
*) echo "bad" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
_ssh_eval_password_auth() {
|
||||
local val="${1:-yes}"
|
||||
[[ "${val,,}" == "no" ]] && echo "ok" || echo "bad"
|
||||
}
|
||||
|
||||
_ssh_eval_max_auth_tries() {
|
||||
local val="${1:-6}"
|
||||
[[ "$val" -le 3 ]] && echo "ok" || echo "warn"
|
||||
}
|
||||
|
||||
_ssh_eval_x11_forwarding() {
|
||||
local val="${1:-no}"
|
||||
[[ "${val,,}" == "no" ]] && echo "ok" || echo "warn"
|
||||
}
|
||||
|
||||
# ─── Funciones de presentación ────────────────────────────────────────────────
|
||||
|
||||
_ssh_print_check() {
|
||||
local level="$1"
|
||||
local label="$2"
|
||||
local value="$3"
|
||||
local note="$4"
|
||||
|
||||
case "$level" in
|
||||
ok) echo -e " ${GREEN}[ok]${NC} ${label}: ${value}" ;;
|
||||
warn) echo -e " ${YELLOW}[!] ${NC} ${label}: ${value} -- ${note}" ;;
|
||||
bad) echo -e " ${RED}[x] ${NC} ${label}: ${value} -- ${note}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
_ssh_show_config_checks() {
|
||||
local config="$1"
|
||||
|
||||
echo -e "${PURPLE}════════ Configuración sshd_config ════════════${NC}"
|
||||
echo ""
|
||||
|
||||
local permit_root
|
||||
permit_root="$(_ssh_get_value "$config" "PermitRootLogin")"
|
||||
permit_root="${permit_root:-yes (por defecto)}"
|
||||
_ssh_print_check "$(_ssh_eval_permit_root "$permit_root")" \
|
||||
"PermitRootLogin" "$permit_root" "debería ser 'no' o 'prohibit-password'"
|
||||
|
||||
local pass_auth
|
||||
pass_auth="$(_ssh_get_value "$config" "PasswordAuthentication")"
|
||||
pass_auth="${pass_auth:-yes (por defecto)}"
|
||||
_ssh_print_check "$(_ssh_eval_password_auth "$pass_auth")" \
|
||||
"PasswordAuthentication" "$pass_auth" "debería ser 'no' (usar claves)"
|
||||
|
||||
local port
|
||||
port="$(_ssh_get_value "$config" "Port")"
|
||||
port="${port:-22 (por defecto)}"
|
||||
if [[ "$port" == "22"* ]]; then
|
||||
_ssh_print_check "warn" "Port" "$port" "considera cambiar el puerto 22"
|
||||
else
|
||||
_ssh_print_check "ok" "Port" "$port" ""
|
||||
fi
|
||||
|
||||
local max_tries
|
||||
max_tries="$(_ssh_get_value "$config" "MaxAuthTries")"
|
||||
max_tries="${max_tries:-6 (por defecto)}"
|
||||
_ssh_print_check "$(_ssh_eval_max_auth_tries "${max_tries%% *}")" \
|
||||
"MaxAuthTries" "$max_tries" "recomendado <= 3"
|
||||
|
||||
local x11
|
||||
x11="$(_ssh_get_value "$config" "X11Forwarding")"
|
||||
x11="${x11:-no (por defecto)}"
|
||||
_ssh_print_check "$(_ssh_eval_x11_forwarding "$x11")" \
|
||||
"X11Forwarding" "$x11" "deshabilitar si no se usa"
|
||||
|
||||
local allow_users allow_groups
|
||||
allow_users="$(_ssh_get_value "$config" "AllowUsers")"
|
||||
allow_groups="$(_ssh_get_value "$config" "AllowGroups")"
|
||||
if [[ -z "$allow_users" && -z "$allow_groups" ]]; then
|
||||
_ssh_print_check "warn" "AllowUsers/AllowGroups" "(no definidos)" "considera restringir acceso por usuario o grupo"
|
||||
else
|
||||
[[ -n "$allow_users" ]] && _ssh_print_check "ok" "AllowUsers" "$allow_users" ""
|
||||
[[ -n "$allow_groups" ]] && _ssh_print_check "ok" "AllowGroups" "$allow_groups" ""
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
_ssh_show_failed_logins() {
|
||||
echo -e "${PURPLE}════════ Últimos intentos de login fallidos ════${NC}"
|
||||
echo ""
|
||||
|
||||
if command -v journalctl &>/dev/null; then
|
||||
journalctl -u ssh -u sshd --no-pager -q 2>/dev/null \
|
||||
| grep -i "failed\|invalid\|error" | tail -10 \
|
||||
| while IFS= read -r line; do echo -e " ${DIM_GRAY}${line}${NC}"; done || true
|
||||
elif [[ -f /var/log/auth.log ]]; then
|
||||
grep -i "failed\|invalid" /var/log/auth.log 2>/dev/null | tail -10 \
|
||||
| while IFS= read -r line; do echo -e " ${DIM_GRAY}${line}${NC}"; done || true
|
||||
else
|
||||
info "No se encontró fuente de logs de autenticación"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
_ssh_show_authorized_keys() {
|
||||
echo -e "${PURPLE}════════ Claves autorizadas (~/.ssh) ═══════════${NC}"
|
||||
echo ""
|
||||
|
||||
local auth_keys="$HOME/.ssh/authorized_keys"
|
||||
if [[ -f "$auth_keys" ]]; then
|
||||
local count
|
||||
count="$(wc -l < "$auth_keys")"
|
||||
info "${count} clave(s) en authorized_keys:"
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" || "$line" == "#"* ]] && continue
|
||||
local key_type key_comment
|
||||
key_type="$(echo "$line" | awk '{print $1}')"
|
||||
key_comment="$(echo "$line" | awk '{print $NF}')"
|
||||
echo -e " ${GREEN}*${NC} ${key_type} -- ${key_comment}"
|
||||
done < "$auth_keys"
|
||||
else
|
||||
info "No existe $auth_keys"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||
|
||||
audit_ssh_config() {
|
||||
local config_path="${1:-/etc/ssh/sshd_config}"
|
||||
|
||||
if [[ ! -f "$config_path" ]]; then
|
||||
warning "audit_ssh_config: no se encontró $config_path -- ¿está instalado sshd?"
|
||||
else
|
||||
_ssh_show_config_checks "$config_path"
|
||||
fi
|
||||
|
||||
_ssh_show_failed_logins
|
||||
_ssh_show_authorized_keys
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
audit_ssh_config "$@"
|
||||
fi
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: check_firewall
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "check_firewall() -> void"
|
||||
description: "Detecta el firewall activo del sistema (ufw, firewalld o iptables) y muestra su estado, reglas activas y puertos en escucha para cruzar con las reglas. Si no se detecta ningún firewall, emite una advertencia de exposición."
|
||||
tags: [bash, cybersecurity, firewall, ufw, iptables, network, hardening, linux, pendiente-usar]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params: []
|
||||
output: "imprime el firewall detectado, su estado (activo/inactivo), reglas vigentes y lista de puertos en escucha"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/cybersecurity/check_firewall.sh"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||
source_license: "MIT"
|
||||
source_file: "scripts/linux/ciberseguridad/sistema/firewall_status.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/cybersecurity/check_firewall.sh
|
||||
|
||||
check_firewall
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
La detección sigue el orden: ufw > firewalld > iptables > none. Para ufw muestra `ufw status verbose`; para firewalld muestra zona por defecto, servicios y puertos permitidos; para iptables muestra las cadenas INPUT/OUTPUT/FORWARD. Leer reglas de iptables requiere privilegios de root. El cruce de puertos en escucha (via `ss -tlnp`) ayuda a identificar servicios sin regla de firewall correspondiente.
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env bash
|
||||
# check_firewall
|
||||
# --------------
|
||||
# Detecta el firewall activo del sistema (ufw, firewalld o iptables) y muestra
|
||||
# su estado y reglas. También lista los puertos en escucha para cruzar con reglas.
|
||||
#
|
||||
# USO (directo):
|
||||
# check_firewall
|
||||
#
|
||||
# Depende de: ufw, firewall-cmd o iptables (el que esté disponible), ss
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||
bash_colors
|
||||
bash_log_init
|
||||
|
||||
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||
|
||||
_fw_detect() {
|
||||
if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "Status:"; then
|
||||
echo "ufw"
|
||||
elif command -v firewall-cmd &>/dev/null && firewall-cmd --state 2>/dev/null | grep -q "running"; then
|
||||
echo "firewalld"
|
||||
elif command -v iptables &>/dev/null; then
|
||||
echo "iptables"
|
||||
else
|
||||
echo "none"
|
||||
fi
|
||||
}
|
||||
|
||||
_fw_ufw_is_active() {
|
||||
ufw status 2>/dev/null | grep -q "Status: active"
|
||||
}
|
||||
|
||||
_fw_firewalld_is_running() {
|
||||
firewall-cmd --state 2>/dev/null | grep -q "running"
|
||||
}
|
||||
|
||||
_fw_iptables_has_rules() {
|
||||
local count
|
||||
count="$(iptables -L INPUT --line-numbers 2>/dev/null | grep -c "^[0-9]" || echo 0)"
|
||||
[[ "$count" -gt 0 ]]
|
||||
}
|
||||
|
||||
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||
|
||||
_fw_show_ufw() {
|
||||
echo -e "${PURPLE}════════ UFW ════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
|
||||
if _fw_ufw_is_active; then
|
||||
success "UFW está activo"
|
||||
else
|
||||
echo -e " ${RED}[x]${NC} UFW está INACTIVO"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "Reglas activas:"
|
||||
ufw status verbose 2>/dev/null | while IFS= read -r line; do
|
||||
echo -e " ${DIM_GRAY}${line}${NC}"
|
||||
done
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
_fw_show_firewalld() {
|
||||
echo -e "${PURPLE}════════ FirewallD ══════════════════════════════${NC}"
|
||||
echo ""
|
||||
|
||||
if _fw_firewalld_is_running; then
|
||||
success "firewalld está activo"
|
||||
else
|
||||
echo -e " ${RED}[x]${NC} firewalld está INACTIVO"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
local zone
|
||||
zone="$(firewall-cmd --get-default-zone 2>/dev/null || echo "desconocida")"
|
||||
info "Zona por defecto: ${zone}"
|
||||
echo ""
|
||||
|
||||
info "Servicios permitidos en zona ${zone}:"
|
||||
firewall-cmd --zone="$zone" --list-services 2>/dev/null \
|
||||
| tr ' ' '\n' | while IFS= read -r svc; do
|
||||
echo -e " ${GREEN}*${NC} ${svc}"
|
||||
done
|
||||
|
||||
echo ""
|
||||
info "Puertos permitidos:"
|
||||
firewall-cmd --zone="$zone" --list-ports 2>/dev/null \
|
||||
| tr ' ' '\n' | while IFS= read -r port; do
|
||||
[[ -n "$port" ]] && echo -e " ${YELLOW}*${NC} ${port}"
|
||||
done || true
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
_fw_show_iptables() {
|
||||
echo -e "${PURPLE}════════ iptables ═══════════════════════════════${NC}"
|
||||
echo ""
|
||||
|
||||
for chain in INPUT OUTPUT FORWARD; do
|
||||
echo -e "${CYAN}── ${chain} ──${NC}"
|
||||
iptables -L "$chain" --line-numbers -n 2>/dev/null \
|
||||
| while IFS= read -r line; do echo -e " ${DIM_GRAY}${line}${NC}"; done
|
||||
echo ""
|
||||
done
|
||||
|
||||
if ! _fw_iptables_has_rules; then
|
||||
echo -e " ${YELLOW}[!]${NC} No hay reglas INPUT definidas -- el sistema puede estar sin filtrar tráfico"
|
||||
fi
|
||||
}
|
||||
|
||||
_fw_show_none() {
|
||||
echo ""
|
||||
echo -e " ${RED}[x]${NC} No se detectó ningún firewall activo (ufw, firewalld, iptables)"
|
||||
echo -e " ${YELLOW}[!]${NC} El sistema puede estar completamente expuesto"
|
||||
echo ""
|
||||
info "Para instalar y activar ufw: sudo apt install ufw && sudo ufw enable"
|
||||
}
|
||||
|
||||
_fw_show_listening_crosscheck() {
|
||||
echo ""
|
||||
echo -e "${PURPLE}════════ Puertos en escucha (para cruzar con reglas) ════${NC}"
|
||||
echo ""
|
||||
ss -tlnp 2>/dev/null | tail -n +2 | while IFS= read -r line; do
|
||||
echo -e " ${DIM_GRAY}${line}${NC}"
|
||||
done
|
||||
}
|
||||
|
||||
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||
|
||||
check_firewall() {
|
||||
local fw
|
||||
fw="$(_fw_detect)"
|
||||
|
||||
info "Firewall detectado: ${fw}"
|
||||
echo ""
|
||||
|
||||
case "$fw" in
|
||||
ufw) _fw_show_ufw ;;
|
||||
firewalld) _fw_show_firewalld ;;
|
||||
iptables) _fw_show_iptables ;;
|
||||
none) _fw_show_none ;;
|
||||
esac
|
||||
|
||||
_fw_show_listening_crosscheck
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
check_firewall "$@"
|
||||
fi
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: detect_suspicious_users
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "detect_suspicious_users() -> void"
|
||||
description: "Revisa el sistema en busca de indicadores de compromiso en cuentas de usuario: UIDs 0 extras (además de root), usuarios con shell de login válida, homes en rutas inusuales, miembros de grupos privilegiados (sudo, docker, wheel, adm, etc.) y sesiones activas."
|
||||
tags: [bash, cybersecurity, users, audit, linux, privilege-escalation, hardening, pendiente-usar]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params: []
|
||||
output: "imprime secciones con UIDs 0, usuarios con shell, homes inusuales, grupos privilegiados, últimos logins y sesiones activas"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/cybersecurity/detect_suspicious_users.sh"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||
source_license: "MIT"
|
||||
source_file: "scripts/linux/ciberseguridad/sistema/usuarios_sospechosos.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/cybersecurity/detect_suspicious_users.sh
|
||||
|
||||
detect_suspicious_users
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Los usuarios de sistema (UID <= 999) se excluyen del check de shell válida para evitar falsos positivos. Los grupos privilegiados monitorizados son: sudo, wheel, docker, adm, lxd, libvirt, kvm, disk, shadow. Homes inusuales son aquellos fuera de /home, /root, /var, /srv, /nonexistent y /tmp. `lastlog` puede no estar disponible en todas las distribuciones.
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env bash
|
||||
# detect_suspicious_users
|
||||
# -----------------------
|
||||
# Revisa el sistema en busca de usuarios potencialmente sospechosos:
|
||||
# UIDs 0 extras, shells válidas, homes en rutas inusuales, grupos privilegiados
|
||||
# y sesiones activas.
|
||||
#
|
||||
# USO (directo):
|
||||
# detect_suspicious_users
|
||||
#
|
||||
# Depende de: /etc/passwd, getent, w, lastlog
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||
bash_colors
|
||||
bash_log_init
|
||||
|
||||
# ─── Constantes ───────────────────────────────────────────────────────────────
|
||||
|
||||
_SUSPICIOUS_VALID_SHELLS=("/bin/bash" "/bin/sh" "/bin/zsh" "/bin/fish" "/usr/bin/bash" "/usr/bin/zsh" "/usr/bin/fish")
|
||||
_SUSPICIOUS_PRIVILEGED_GROUPS=("sudo" "wheel" "docker" "adm" "lxd" "libvirt" "kvm" "disk" "shadow")
|
||||
_SUSPICIOUS_SYSTEM_USERS_MAX_UID=999
|
||||
|
||||
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||
|
||||
_sus_is_valid_shell() {
|
||||
local shell="$1"
|
||||
for s in "${_SUSPICIOUS_VALID_SHELLS[@]}"; do
|
||||
[[ "$shell" == "$s" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
_sus_is_system_user() {
|
||||
local uid="$1"
|
||||
[[ "$uid" -le $_SUSPICIOUS_SYSTEM_USERS_MAX_UID ]]
|
||||
}
|
||||
|
||||
_sus_is_unusual_home() {
|
||||
local home="$1"
|
||||
[[ ! "$home" =~ ^(/home|/root|/var|/srv|/nonexistent|/tmp) ]]
|
||||
}
|
||||
|
||||
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||
|
||||
_sus_show_uid0_users() {
|
||||
echo -e "${PURPLE}════════ Usuarios con UID 0 (root) ═════════════${NC}"
|
||||
echo ""
|
||||
|
||||
local found=0
|
||||
while IFS=: read -r username _ uid _; do
|
||||
if [[ "$uid" -eq 0 ]]; then
|
||||
if [[ "$username" == "root" ]]; then
|
||||
echo -e " ${GREEN}[ok]${NC} root (esperado)"
|
||||
else
|
||||
echo -e " ${RED}[x]${NC} ${username} tiene UID 0 -- SOSPECHOSO"
|
||||
fi
|
||||
found=$((found + 1))
|
||||
fi
|
||||
done < /etc/passwd
|
||||
|
||||
if [[ $found -eq 1 ]]; then
|
||||
echo ""
|
||||
success "Solo root tiene UID 0"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
_sus_show_users_with_shell() {
|
||||
echo -e "${PURPLE}════════ Usuarios con shell de login válida ═════${NC}"
|
||||
echo ""
|
||||
echo -e " ${GRAY}(excluye usuarios de sistema con UID <= ${_SUSPICIOUS_SYSTEM_USERS_MAX_UID})${NC}"
|
||||
echo ""
|
||||
|
||||
local found=0
|
||||
while IFS=: read -r username _ uid _ _ home shell; do
|
||||
if ! _sus_is_system_user "$uid" && _sus_is_valid_shell "$shell"; then
|
||||
echo -e " ${CYAN}*${NC} ${username} (UID ${uid}) -- shell: ${shell} -- home: ${home}"
|
||||
found=$((found + 1))
|
||||
fi
|
||||
done < /etc/passwd
|
||||
|
||||
[[ $found -eq 0 ]] && info "No se encontraron usuarios normales con shell válida"
|
||||
echo ""
|
||||
}
|
||||
|
||||
_sus_show_unusual_homes() {
|
||||
echo -e "${PURPLE}════════ Usuarios con home inusual ══════════════${NC}"
|
||||
echo ""
|
||||
|
||||
local found=0
|
||||
while IFS=: read -r username _ uid _ _ home shell; do
|
||||
if _sus_is_valid_shell "$shell" && _sus_is_unusual_home "$home"; then
|
||||
echo -e " ${YELLOW}[!]${NC} ${username} -- home: ${home}"
|
||||
found=$((found + 1))
|
||||
fi
|
||||
done < /etc/passwd
|
||||
|
||||
[[ $found -eq 0 ]] && success "No se detectaron homes en rutas inusuales"
|
||||
echo ""
|
||||
}
|
||||
|
||||
_sus_show_privileged_groups() {
|
||||
echo -e "${PURPLE}════════ Grupos privilegiados y sus miembros ════${NC}"
|
||||
echo ""
|
||||
|
||||
for group in "${_SUSPICIOUS_PRIVILEGED_GROUPS[@]}"; do
|
||||
local members
|
||||
members="$(getent group "$group" 2>/dev/null | cut -d: -f4 || true)"
|
||||
if [[ -n "$members" ]]; then
|
||||
echo -e " ${YELLOW}*${NC} ${group}: ${members}"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
_sus_show_active_sessions() {
|
||||
echo -e "${PURPLE}════════ Sesiones activas ═══════════════════════${NC}"
|
||||
echo ""
|
||||
|
||||
w 2>/dev/null | while IFS= read -r line; do
|
||||
echo -e " ${DIM_GRAY}${line}${NC}"
|
||||
done
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
_sus_show_last_logins() {
|
||||
echo -e "${PURPLE}════════ Últimos logins por usuario ═════════════${NC}"
|
||||
echo ""
|
||||
|
||||
if command -v lastlog &>/dev/null; then
|
||||
lastlog 2>/dev/null | awk 'NR==1 || $NF != "logged" {
|
||||
if (NR==1 || $2 != "**Never") printf " %-16s %-10s %s\n", $1, $2, $NF
|
||||
}' | grep -v "^$" | while IFS= read -r line; do
|
||||
echo -e " ${DIM_GRAY}${line}${NC}"
|
||||
done
|
||||
else
|
||||
warning "lastlog no disponible"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||
|
||||
detect_suspicious_users() {
|
||||
_sus_show_uid0_users
|
||||
_sus_show_users_with_shell
|
||||
_sus_show_unusual_homes
|
||||
_sus_show_privileged_groups
|
||||
_sus_show_last_logins
|
||||
_sus_show_active_sessions
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
detect_suspicious_users "$@"
|
||||
fi
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: encrypt_file
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "encrypt_file(mode: string, file: string) -> void"
|
||||
description: "Cifra o descifra un archivo usando AES-256-CBC con PBKDF2 (310.000 iteraciones) via openssl. La contraseña se lee de la variable de entorno ENCRYPT_PASSWORD o se solicita interactivamente. El archivo cifrado se guarda con extensión .enc; al descifrar se recupera el nombre original."
|
||||
tags: [bash, cybersecurity, encryption, aes256, openssl, crypto, pbkdf2, pendiente-usar]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: mode
|
||||
desc: "operación a realizar: encrypt (cifrar) o decrypt (descifrar)"
|
||||
- name: file
|
||||
desc: "ruta al archivo a cifrar o descifrar"
|
||||
output: "genera el archivo cifrado (input.enc) o descifrado (input sin .enc, o input.dec) e imprime progreso a stdout"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/cybersecurity/encrypt_file.sh"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||
source_license: "MIT"
|
||||
source_file: "scripts/linux/ciberseguridad/utilidades/cifrar_archivo.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/cybersecurity/encrypt_file.sh
|
||||
|
||||
# Cifrar (solicita contraseña interactivamente)
|
||||
encrypt_file encrypt documento.pdf
|
||||
|
||||
# Descifrar
|
||||
encrypt_file decrypt documento.pdf.enc
|
||||
|
||||
# Con contraseña via variable de entorno (no interactivo)
|
||||
ENCRYPT_PASSWORD="mi-secreto-seguro" encrypt_file encrypt datos.tar.gz
|
||||
ENCRYPT_PASSWORD="mi-secreto-seguro" encrypt_file decrypt datos.tar.gz.enc
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Usa `openssl enc -aes-256-cbc -pbkdf2 -iter 310000` — compatible con OpenSSL 1.1.1+. Las 310.000 iteraciones de PBKDF2 siguen las recomendaciones NIST para derivación de claves en 2024. La contraseña se limpia de memoria al terminar. Si el archivo de salida ya existe, la función falla silenciosamente (no sobrescribe por seguridad cuando se usa con ENCRYPT_PASSWORD).
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env bash
|
||||
# encrypt_file
|
||||
# ------------
|
||||
# Cifra o descifra un archivo usando AES-256-CBC con PBKDF2 (310.000 iteraciones).
|
||||
# La contraseña se lee de la variable de entorno ENCRYPT_PASSWORD o se solicita
|
||||
# interactivamente por stdin.
|
||||
#
|
||||
# USO (directo):
|
||||
# encrypt_file encrypt <archivo>
|
||||
# encrypt_file decrypt <archivo.enc>
|
||||
# ENCRYPT_PASSWORD=secreto encrypt_file encrypt archivo.txt
|
||||
#
|
||||
# Depende de: openssl
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||
bash_colors
|
||||
bash_log_init
|
||||
|
||||
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||
|
||||
_enc_output_path_encrypt() {
|
||||
local input="$1"
|
||||
echo "${input}.enc"
|
||||
}
|
||||
|
||||
_enc_output_path_decrypt() {
|
||||
local input="$1"
|
||||
if [[ "$input" == *.enc ]]; then
|
||||
echo "${input%.enc}"
|
||||
else
|
||||
echo "${input}.dec"
|
||||
fi
|
||||
}
|
||||
|
||||
_enc_human_size() {
|
||||
local file="$1"
|
||||
du -sh "$file" 2>/dev/null | cut -f1
|
||||
}
|
||||
|
||||
# ─── Funciones de efecto ──────────────────────────────────────────name──────────
|
||||
|
||||
_enc_ask_password() {
|
||||
local pass
|
||||
read -rsp "Contraseña: " pass
|
||||
echo "" >&2
|
||||
echo "$pass"
|
||||
}
|
||||
|
||||
_enc_ask_password_confirm() {
|
||||
local pass1 pass2
|
||||
read -rsp "Contraseña: " pass1
|
||||
echo "" >&2
|
||||
read -rsp "Confirmar contraseña: " pass2
|
||||
echo "" >&2
|
||||
|
||||
if [[ "$pass1" != "$pass2" ]]; then
|
||||
error "Las contraseñas no coinciden" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ${#pass1} -lt 8 ]]; then
|
||||
warning "La contraseña es muy corta (mínimo 8 caracteres recomendado)"
|
||||
fi
|
||||
|
||||
echo "$pass1"
|
||||
}
|
||||
|
||||
_enc_do_encrypt() {
|
||||
local input="$1"
|
||||
local output="$2"
|
||||
local password="$3"
|
||||
|
||||
openssl enc -aes-256-cbc -pbkdf2 -iter 310000 \
|
||||
-in "$input" -out "$output" \
|
||||
-pass "pass:${password}" 2>/dev/null
|
||||
}
|
||||
|
||||
_enc_do_decrypt() {
|
||||
local input="$1"
|
||||
local output="$2"
|
||||
local password="$3"
|
||||
|
||||
openssl enc -d -aes-256-cbc -pbkdf2 -iter 310000 \
|
||||
-in "$input" -out "$output" \
|
||||
-pass "pass:${password}" 2>/dev/null
|
||||
}
|
||||
|
||||
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||
|
||||
encrypt_file() {
|
||||
local mode="$1"
|
||||
local file="$2"
|
||||
|
||||
if [[ -z "$mode" || -z "$file" ]]; then
|
||||
error "encrypt_file: uso: encrypt_file <encrypt|decrypt> <archivo>" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! command -v openssl &>/dev/null; then
|
||||
error "encrypt_file: 'openssl' no está instalado (sudo apt install openssl)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$file" ]]; then
|
||||
error "encrypt_file: archivo no encontrado: $file" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local password
|
||||
if [[ -n "${ENCRYPT_PASSWORD:-}" ]]; then
|
||||
password="$ENCRYPT_PASSWORD"
|
||||
else
|
||||
case "$mode" in
|
||||
encrypt) password="$(_enc_ask_password_confirm)" || return 1 ;;
|
||||
decrypt) password="$(_enc_ask_password)" ;;
|
||||
*)
|
||||
error "encrypt_file: modo no válido '$mode'. Use: encrypt|decrypt" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
case "$mode" in
|
||||
encrypt)
|
||||
local output
|
||||
output="$(_enc_output_path_encrypt "$file")"
|
||||
info "Archivo: ${file} ($(_enc_human_size "$file"))"
|
||||
info "Salida: ${output}"
|
||||
info "Cifrando con AES-256-CBC + PBKDF2..."
|
||||
|
||||
if _enc_do_encrypt "$file" "$output" "$password"; then
|
||||
success "Archivo cifrado: ${output} ($(_enc_human_size "$output"))"
|
||||
else
|
||||
error "encrypt_file: el cifrado falló" >&2
|
||||
rm -f "$output"
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
decrypt)
|
||||
local output
|
||||
output="$(_enc_output_path_decrypt "$file")"
|
||||
info "Archivo: ${file} ($(_enc_human_size "$file"))"
|
||||
info "Salida: ${output}"
|
||||
info "Descifrando..."
|
||||
|
||||
if _enc_do_decrypt "$file" "$output" "$password"; then
|
||||
success "Archivo descifrado: ${output} ($(_enc_human_size "$output"))"
|
||||
else
|
||||
error "encrypt_file: el descifrado falló -- ¿contraseña incorrecta?" >&2
|
||||
rm -f "$output"
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Limpiar contraseña de memoria
|
||||
password=""
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
encrypt_file "$@"
|
||||
fi
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: enumerate_subdomains
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "enumerate_subdomains(domain: string, output_file: string) -> void"
|
||||
description: "Enumera subdominios de un dominio objetivo usando un diccionario integrado de ~100 subdominios comunes (www, mail, api, dev, admin, vpn, etc.). Detecta tanto registros A (IP directa) como CNAME. Muestra progreso cada 20 subdominios y opcionalmente guarda los resultados en un archivo."
|
||||
tags: [bash, cybersecurity, dns, subdomain, enumeration, reconnaissance, osint, pendiente-usar]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: domain
|
||||
desc: "dominio objetivo a enumerar, ej: example.com"
|
||||
- name: output_file
|
||||
desc: "ruta al archivo donde guardar los resultados (opcional; si se omite, solo imprime a stdout)"
|
||||
output: "imprime subdominios encontrados con su IP o CNAME, progreso cada 20 entradas y resumen final con total encontrados"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/cybersecurity/enumerate_subdomains.sh"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||
source_license: "MIT"
|
||||
source_file: "scripts/linux/ciberseguridad/web/subdominios.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/cybersecurity/enumerate_subdomains.sh
|
||||
|
||||
# Solo imprimir resultados
|
||||
enumerate_subdomains example.com
|
||||
|
||||
# Guardar resultados en archivo
|
||||
enumerate_subdomains example.com /tmp/subdominios.txt
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Requiere `dig` (paquete dnsutils). El diccionario integrado cubre subdominios comunes en entornos corporativos y de desarrollo: www, api, dev, admin, vpn, git, jenkins, staging, prod, db, mail, smtp, ns1/ns2, grafana, kibana, docker, k8s, auth, sso, etc. (~100 entradas). La enumeración es puramente pasiva via DNS — no realiza ningún tipo de conexión al servidor web. Los subdominios con CNAME sin resolución A se marcan en amarillo.
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
# enumerate_subdomains
|
||||
# --------------------
|
||||
# Enumera subdominios de un dominio objetivo usando un diccionario integrado de
|
||||
# ~100 subdominios comunes. Detecta tanto registros A (IP directa) como CNAME.
|
||||
# Opcionalmente guarda el resultado en un archivo.
|
||||
#
|
||||
# USO (directo):
|
||||
# enumerate_subdomains <dominio> [archivo_salida]
|
||||
# enumerate_subdomains example.com
|
||||
# enumerate_subdomains example.com /tmp/resultado.txt
|
||||
#
|
||||
# Depende de: dig (dnsutils)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||
bash_colors
|
||||
bash_log_init
|
||||
|
||||
# ─── Diccionario integrado ─────────────────────────────────────────────────────
|
||||
|
||||
_SUBDOMAIN_WORDLIST=(
|
||||
www mail ftp api dev admin vpn ssh git gitlab github jenkins ci cd
|
||||
staging prod test demo beta alpha app web portal intranet extranet
|
||||
remote desktop files cdn static assets media img images upload
|
||||
db database mysql postgres redis mongo smtp pop imap webmail mx
|
||||
ns1 ns2 dns autodiscover autoconfig crm erp shop store payment
|
||||
backup old legacy v1 v2 v3 internal corp office support helpdesk
|
||||
wiki docs doc status monitor grafana kibana elastic search
|
||||
registry docker k8s kubernetes auth sso login oauth api2 mobile
|
||||
)
|
||||
|
||||
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||
|
||||
_sub_is_valid_domain() {
|
||||
[[ -n "$1" && "$1" =~ ^[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}$ ]]
|
||||
}
|
||||
|
||||
_sub_build_fqdn() {
|
||||
local sub="$1"
|
||||
local domain="$2"
|
||||
echo "${sub}.${domain}"
|
||||
}
|
||||
|
||||
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||
|
||||
_sub_resolve_a() {
|
||||
local fqdn="$1"
|
||||
dig +short A "$fqdn" 2>/dev/null | grep -E '^[0-9]+\.' | head -1 || true
|
||||
}
|
||||
|
||||
_sub_resolve_cname() {
|
||||
local fqdn="$1"
|
||||
dig +short CNAME "$fqdn" 2>/dev/null | head -1 || true
|
||||
}
|
||||
|
||||
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||
|
||||
enumerate_subdomains() {
|
||||
local domain="$1"
|
||||
local output_file="${2:-}"
|
||||
|
||||
if [[ -z "$domain" ]]; then
|
||||
error "enumerate_subdomains: se requiere un dominio como argumento" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! _sub_is_valid_domain "$domain"; then
|
||||
error "enumerate_subdomains: dominio no válido: '$domain'" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! command -v dig &>/dev/null; then
|
||||
error "enumerate_subdomains: 'dig' no está instalado (sudo apt install dnsutils)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local total="${#_SUBDOMAIN_WORDLIST[@]}"
|
||||
local found=0
|
||||
local checked=0
|
||||
|
||||
info "Probando ${total} subdominios en ${domain}..."
|
||||
echo ""
|
||||
|
||||
if [[ -n "$output_file" ]]; then
|
||||
echo "# Subdominios encontrados en ${domain} -- $(date)" > "$output_file"
|
||||
fi
|
||||
|
||||
for sub in "${_SUBDOMAIN_WORDLIST[@]}"; do
|
||||
local fqdn
|
||||
fqdn="$(_sub_build_fqdn "$sub" "$domain")"
|
||||
checked=$((checked + 1))
|
||||
|
||||
local ip
|
||||
ip="$(_sub_resolve_a "$fqdn")"
|
||||
|
||||
if [[ -n "$ip" ]]; then
|
||||
echo -e " ${GREEN}[ok]${NC} ${fqdn} -> ${CYAN}${ip}${NC}"
|
||||
[[ -n "$output_file" ]] && echo "${fqdn} -> ${ip}" >> "$output_file"
|
||||
found=$((found + 1))
|
||||
else
|
||||
local cname
|
||||
cname="$(_sub_resolve_cname "$fqdn")"
|
||||
if [[ -n "$cname" ]]; then
|
||||
echo -e " ${YELLOW}[cn]${NC} ${fqdn} -> CNAME: ${cname}"
|
||||
[[ -n "$output_file" ]] && echo "${fqdn} -> CNAME: ${cname}" >> "$output_file"
|
||||
found=$((found + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# Progreso cada 20 subdominios
|
||||
if (( checked % 20 == 0 )); then
|
||||
echo -e " ${DIM_GRAY}[${checked}/${total} probados, ${found} encontrados]${NC}"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||
|
||||
if [[ $found -eq 0 ]]; then
|
||||
info "No se encontraron subdominios en el diccionario"
|
||||
else
|
||||
success "Total encontrados: ${found} de ${total} probados"
|
||||
[[ -n "$output_file" ]] && info "Resultado guardado en: ${output_file}"
|
||||
fi
|
||||
|
||||
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
enumerate_subdomains "$@"
|
||||
fi
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: generate_password
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "generate_password(mode: string, length: int, count: int) -> void"
|
||||
description: "Genera contraseñas seguras en cuatro modos: full (alfanumérico + símbolos, excluye caracteres ambiguos), alpha (solo alfanumérico), passphrase (palabras aleatorias unidas con guión) y pin (numérico). Calcula y muestra la entropía en bits para cada modo."
|
||||
tags: [bash, cybersecurity, password, generator, entropy, security, urandom, pendiente-usar]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: mode
|
||||
desc: "modo de generación: full (alfanumérico+símbolos, por defecto), alpha (solo letras y números), passphrase (palabras), pin (numérico)"
|
||||
- name: length
|
||||
desc: "longitud en caracteres para full/alpha/pin, o número de palabras para passphrase (por defecto: 16)"
|
||||
- name: count
|
||||
desc: "número de contraseñas a generar (por defecto: 1)"
|
||||
output: "imprime las contraseñas generadas a stdout (una por línea) con información de entropía"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/cybersecurity/generate_password.sh"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||
source_license: "MIT"
|
||||
source_file: "scripts/linux/ciberseguridad/utilidades/generar_password.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/cybersecurity/generate_password.sh
|
||||
|
||||
# Contraseña completa de 20 caracteres
|
||||
generate_password full 20
|
||||
|
||||
# 5 contraseñas alfanuméricas de 16 caracteres
|
||||
generate_password alpha 16 5
|
||||
|
||||
# Passphrase de 6 palabras
|
||||
generate_password passphrase 6
|
||||
|
||||
# PIN de 8 dígitos
|
||||
generate_password pin 8
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Usa `/dev/urandom` como fuente de aleatoriedad criptográficamente segura. El modo `full` excluye caracteres ambiguos (0, O, l, I, 1) para mejorar legibilidad. El modo `passphrase` requiere un diccionario del sistema (`/usr/share/dict/words` o similar). La entropía se calcula como log2(charset^length) en bits. Las contraseñas nunca se escriben a disco.
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env bash
|
||||
# generate_password
|
||||
# -----------------
|
||||
# Genera contraseñas seguras en varios modos: completo (alfanumérico + símbolos),
|
||||
# solo alfanumérico, passphrase de palabras o PIN numérico.
|
||||
# Calcula la entropía en bits para cada contraseña generada.
|
||||
#
|
||||
# USO (directo):
|
||||
# generate_password [full|alpha|passphrase|pin] [longitud] [cantidad]
|
||||
#
|
||||
# Depende de: /dev/urandom, python3 (para entropía), shuf (para passphrases)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||
bash_colors
|
||||
bash_log_init
|
||||
|
||||
# ─── Constantes ───────────────────────────────────────────────────────────────
|
||||
|
||||
_GENPW_DEFAULT_LENGTH=16
|
||||
_GENPW_DEFAULT_COUNT=1
|
||||
_GENPW_WORDLIST_PATHS=("/usr/share/dict/words" "/usr/dict/words" "/usr/share/dict/american-english")
|
||||
|
||||
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||
|
||||
_genpw_find_wordlist() {
|
||||
for path in "${_GENPW_WORDLIST_PATHS[@]}"; do
|
||||
[[ -f "$path" ]] && echo "$path" && return
|
||||
done
|
||||
echo ""
|
||||
}
|
||||
|
||||
_genpw_calc_entropy() {
|
||||
local charset_size="$1"
|
||||
local length="$2"
|
||||
python3 -c "import math; print(f'{math.log2(${charset_size}**${length}):.1f}')" 2>/dev/null || echo "?"
|
||||
}
|
||||
|
||||
# ─── Funciones de generación ──────────────────────────────────────────────────
|
||||
|
||||
_genpw_gen_full() {
|
||||
local length="$1"
|
||||
# Alfanumérico + símbolos (excluye ambiguos: 0OlI1)
|
||||
tr -dc 'A-HJ-NP-Za-km-z2-9!@#$%^&*()_+-=[]{}|;:,.<>?' \
|
||||
< /dev/urandom | head -c "$length"
|
||||
echo
|
||||
}
|
||||
|
||||
_genpw_gen_alpha() {
|
||||
local length="$1"
|
||||
tr -dc 'A-HJ-NP-Za-km-z2-9' \
|
||||
< /dev/urandom | head -c "$length"
|
||||
echo
|
||||
}
|
||||
|
||||
_genpw_gen_passphrase() {
|
||||
local words="$1"
|
||||
local wordlist
|
||||
wordlist="$(_genpw_find_wordlist)"
|
||||
|
||||
if [[ -z "$wordlist" ]]; then
|
||||
error "generate_password: no se encontró diccionario (sudo apt install wamerican)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local phrase=""
|
||||
for ((i=0; i<words; i++)); do
|
||||
local word
|
||||
word="$(shuf -n1 "$wordlist" | tr -dc 'a-z' | head -c 20)"
|
||||
[[ ${#word} -lt 3 ]] && { i=$((i-1)); continue; }
|
||||
phrase="${phrase}${word}-"
|
||||
done
|
||||
echo "${phrase%-}"
|
||||
}
|
||||
|
||||
_genpw_gen_pin() {
|
||||
local length="$1"
|
||||
tr -dc '0-9' < /dev/urandom | head -c "$length"
|
||||
echo
|
||||
}
|
||||
|
||||
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||
|
||||
generate_password() {
|
||||
local mode="${1:-full}"
|
||||
local length="${2:-$_GENPW_DEFAULT_LENGTH}"
|
||||
local count="${3:-$_GENPW_DEFAULT_COUNT}"
|
||||
|
||||
# Validar que length y count son numéricos
|
||||
if ! [[ "$length" =~ ^[0-9]+$ ]] || ! [[ "$count" =~ ^[0-9]+$ ]]; then
|
||||
error "generate_password: longitud y cantidad deben ser números enteros positivos" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local charset_size entropy
|
||||
|
||||
case "$mode" in
|
||||
full)
|
||||
charset_size=78
|
||||
entropy="$(_genpw_calc_entropy $charset_size "$length")"
|
||||
info "Contraseñas alfanuméricas + símbolos (longitud: ${length}, entropía: ~${entropy} bits)"
|
||||
echo ""
|
||||
for ((i=1; i<=count; i++)); do
|
||||
_genpw_gen_full "$length"
|
||||
done
|
||||
;;
|
||||
alpha)
|
||||
charset_size=56
|
||||
entropy="$(_genpw_calc_entropy $charset_size "$length")"
|
||||
info "Contraseñas alfanuméricas (longitud: ${length}, entropía: ~${entropy} bits)"
|
||||
echo ""
|
||||
for ((i=1; i<=count; i++)); do
|
||||
_genpw_gen_alpha "$length"
|
||||
done
|
||||
;;
|
||||
passphrase)
|
||||
info "Passphrases (${length} palabras)"
|
||||
echo ""
|
||||
for ((i=1; i<=count; i++)); do
|
||||
_genpw_gen_passphrase "$length" || return 1
|
||||
done
|
||||
;;
|
||||
pin)
|
||||
charset_size=10
|
||||
entropy="$(_genpw_calc_entropy $charset_size "$length")"
|
||||
info "PINs numéricos (longitud: ${length}, entropía: ~${entropy} bits)"
|
||||
echo ""
|
||||
for ((i=1; i<=count; i++)); do
|
||||
_genpw_gen_pin "$length"
|
||||
done
|
||||
;;
|
||||
*)
|
||||
error "generate_password: modo no válido '$mode'. Use: full|alpha|passphrase|pin" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
generate_password "$@"
|
||||
fi
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: geolocate_ip
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "geolocate_ip(target: string) -> void"
|
||||
description: "Geolocaliza una dirección IP o dominio usando la API pública de ip-api.com. Muestra país, región, ciudad, coordenadas, ISP, ASN y detecta VPN, Proxy o infraestructura de hosting."
|
||||
tags: [bash, cybersecurity, network, geoip, ip, osint, reconnaissance, pendiente-usar]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: target
|
||||
desc: "dirección IP (IPv4) o nombre de dominio a geolocalizar"
|
||||
output: "imprime información de geolocalización a stdout: país, ciudad, ISP, ASN, coordenadas y flags de VPN/Proxy/Hosting"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/cybersecurity/geolocate_ip.sh"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||
source_license: "MIT"
|
||||
source_file: "scripts/linux/ciberseguridad/redes/geoip.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/cybersecurity/geolocate_ip.sh
|
||||
|
||||
# Geolocalizar una IP
|
||||
geolocate_ip 8.8.8.8
|
||||
|
||||
# Geolocalizar un dominio (resuelve a IP primero)
|
||||
geolocate_ip example.com
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Requiere `curl`. Si se pasa un dominio en lugar de una IP, se resuelve a IP usando `dig` antes de consultar la API. La API de ip-api.com es gratuita para uso no comercial con límite de 45 req/min. Los campos `proxy=true` y `hosting=true` indican posible uso de VPN, proxy Tor o datacenter.
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
# geolocate_ip
|
||||
# ------------
|
||||
# Geolocaliza una IP o dominio usando la API pública de ip-api.com.
|
||||
# Muestra país, ciudad, ISP, ASN y detecta VPN/Proxy/Hosting.
|
||||
#
|
||||
# USO (directo):
|
||||
# geolocate_ip <ip_o_dominio>
|
||||
#
|
||||
# Depende de: curl, dig (para resolver dominios)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||
bash_colors
|
||||
bash_log_init
|
||||
|
||||
# ─── Constantes ───────────────────────────────────────────────────────────────
|
||||
|
||||
_GEOIP_API="http://ip-api.com/json"
|
||||
_GEOIP_FIELDS="status,message,country,countryCode,regionName,city,zip,lat,lon,isp,org,as,proxy,hosting,query"
|
||||
|
||||
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||
|
||||
_geo_is_ip() {
|
||||
[[ "$1" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]
|
||||
}
|
||||
|
||||
_geo_build_api_url() {
|
||||
local target="$1"
|
||||
echo "${_GEOIP_API}/${target}?fields=${_GEOIP_FIELDS}"
|
||||
}
|
||||
|
||||
_geo_extract_field() {
|
||||
local json="$1"
|
||||
local key="$2"
|
||||
echo "$json" | grep -o "\"${key}\":[^,}]*" | cut -d: -f2- | tr -d '"' | xargs
|
||||
}
|
||||
|
||||
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||
|
||||
_geo_resolve_domain() {
|
||||
local domain="$1"
|
||||
dig +short A "$domain" 2>/dev/null | head -1 || true
|
||||
}
|
||||
|
||||
_geo_fetch() {
|
||||
local target="$1"
|
||||
local url
|
||||
url="$(_geo_build_api_url "$target")"
|
||||
curl -s --max-time 10 "$url" 2>/dev/null
|
||||
}
|
||||
|
||||
_geo_display_result() {
|
||||
local json="$1"
|
||||
|
||||
local status
|
||||
status="$(_geo_extract_field "$json" "status")"
|
||||
if [[ "$status" != "success" ]]; then
|
||||
local msg
|
||||
msg="$(_geo_extract_field "$json" "message")"
|
||||
error "La API devolvió error: ${msg:-respuesta inesperada}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local ip_queried country country_code region city zip lat lon isp org asn proxy hosting
|
||||
ip_queried="$(_geo_extract_field "$json" "query")"
|
||||
country="$(_geo_extract_field "$json" "country")"
|
||||
country_code="$(_geo_extract_field "$json" "countryCode")"
|
||||
region="$(_geo_extract_field "$json" "regionName")"
|
||||
city="$(_geo_extract_field "$json" "city")"
|
||||
zip="$(_geo_extract_field "$json" "zip")"
|
||||
lat="$(_geo_extract_field "$json" "lat")"
|
||||
lon="$(_geo_extract_field "$json" "lon")"
|
||||
isp="$(_geo_extract_field "$json" "isp")"
|
||||
org="$(_geo_extract_field "$json" "org")"
|
||||
asn="$(_geo_extract_field "$json" "as")"
|
||||
proxy="$(_geo_extract_field "$json" "proxy")"
|
||||
hosting="$(_geo_extract_field "$json" "hosting")"
|
||||
|
||||
echo ""
|
||||
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e " ${CYAN}IP consultada:${NC} ${ip_queried}"
|
||||
echo -e " ${CYAN}País:${NC} ${country} (${country_code})"
|
||||
echo -e " ${CYAN}Región:${NC} ${region}"
|
||||
echo -e " ${CYAN}Ciudad:${NC} ${city} ${zip}"
|
||||
echo -e " ${CYAN}Coordenadas:${NC} ${lat}, ${lon}"
|
||||
echo -e " ${CYAN}ISP:${NC} ${isp}"
|
||||
echo -e " ${CYAN}Organización:${NC} ${org}"
|
||||
echo -e " ${CYAN}ASN:${NC} ${asn}"
|
||||
echo ""
|
||||
|
||||
if [[ "$proxy" == "true" ]]; then
|
||||
echo -e " ${RED}[!] VPN / Proxy detectado${NC}"
|
||||
fi
|
||||
if [[ "$hosting" == "true" ]]; then
|
||||
echo -e " ${YELLOW}[i] Hosting / datacenter detectado${NC}"
|
||||
fi
|
||||
if [[ "$proxy" != "true" && "$hosting" != "true" ]]; then
|
||||
echo -e " ${GREEN}[ok] Sin indicios de VPN, Proxy o Tor${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||
}
|
||||
|
||||
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||
|
||||
geolocate_ip() {
|
||||
local target="$1"
|
||||
|
||||
if [[ -z "$target" ]]; then
|
||||
error "geolocate_ip: se requiere una IP o dominio como argumento" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! command -v curl &>/dev/null; then
|
||||
error "geolocate_ip: 'curl' no está instalado (sudo apt install curl)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local query_target="$target"
|
||||
|
||||
if ! _geo_is_ip "$target"; then
|
||||
info "Resolviendo dominio a IP..."
|
||||
local resolved
|
||||
resolved="$(_geo_resolve_domain "$target")"
|
||||
if [[ -z "$resolved" ]]; then
|
||||
error "geolocate_ip: no se pudo resolver '$target'" >&2
|
||||
return 1
|
||||
fi
|
||||
info "Resuelto: ${target} -> ${resolved}"
|
||||
query_target="$resolved"
|
||||
fi
|
||||
|
||||
info "Consultando geolocalización de ${query_target}..."
|
||||
local json
|
||||
json="$(_geo_fetch "$query_target")"
|
||||
|
||||
if [[ -z "$json" ]]; then
|
||||
error "geolocate_ip: no se obtuvo respuesta de la API" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
_geo_display_result "$json"
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
geolocate_ip "$@"
|
||||
fi
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: inspect_ssl_cert
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "inspect_ssl_cert(host: string) -> void"
|
||||
description: "Inspecciona el certificado SSL/TLS de un host: muestra sujeto, emisor, fechas de validez, días hasta expiración, SANs (Subject Alternative Names), cadena de confianza completa y detecta soporte de versiones inseguras TLS 1.0/1.1."
|
||||
tags: [bash, cybersecurity, ssl, tls, certificate, web, openssl, security, pendiente-usar]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: host
|
||||
desc: "host a inspeccionar, acepta formato host o host:puerto (por defecto puerto 443), ej: example.com o example.com:8443"
|
||||
output: "imprime detalles del certificado SSL/TLS, días hasta expiración con nivel de alerta, SANs, cadena de confianza y resultado de checks de versiones TLS"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/cybersecurity/inspect_ssl_cert.sh"
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||
source_license: "MIT"
|
||||
source_file: "scripts/linux/ciberseguridad/web/ssl_cert_info.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/cybersecurity/inspect_ssl_cert.sh
|
||||
|
||||
# Puerto 443 por defecto
|
||||
inspect_ssl_cert example.com
|
||||
|
||||
# Puerto personalizado
|
||||
inspect_ssl_cert example.com:8443
|
||||
|
||||
# API interna
|
||||
inspect_ssl_cert api.internal.example.com:4443
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Requiere `openssl` y `timeout`. Usa `openssl s_client` con SNI (`-servername`) para soportar virtual hosting. La alerta de expiración se activa a 30 días o menos. La detección de TLS 1.0/1.1 usa flags `-tls1` y `-tls1_1` de openssl s_client — si el servidor acepta la conexión y negocia un cipher, el protocolo inseguro está habilitado. Cada conexión tiene timeout de 10 segundos para evitar cuelgues en hosts sin respuesta.
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bash
|
||||
# inspect_ssl_cert
|
||||
# ----------------
|
||||
# Inspecciona el certificado SSL/TLS de un host: sujeto, emisor, fechas de validez,
|
||||
# SANs, cadena de confianza y versiones de TLS aceptadas (detecta TLS 1.0/1.1 inseguros).
|
||||
#
|
||||
# USO (directo):
|
||||
# inspect_ssl_cert <host[:puerto]>
|
||||
# inspect_ssl_cert example.com
|
||||
# inspect_ssl_cert example.com:8443
|
||||
#
|
||||
# Depende de: openssl, timeout
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||
bash_colors
|
||||
bash_log_init
|
||||
|
||||
# ─── Constantes ───────────────────────────────────────────────────────────────
|
||||
|
||||
_SSL_WARN_DAYS=30
|
||||
|
||||
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||
|
||||
_ssl_parse_host_port() {
|
||||
local input="$1"
|
||||
if [[ "$input" =~ ^(.+):([0-9]+)$ ]]; then
|
||||
echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]}"
|
||||
else
|
||||
echo "${input} 443"
|
||||
fi
|
||||
}
|
||||
|
||||
_ssl_days_until_expiry() {
|
||||
local expiry_str="$1"
|
||||
local expiry_epoch
|
||||
expiry_epoch="$(date -d "$expiry_str" +%s 2>/dev/null || echo 0)"
|
||||
local now_epoch
|
||||
now_epoch="$(date +%s)"
|
||||
echo $(( (expiry_epoch - now_epoch) / 86400 ))
|
||||
}
|
||||
|
||||
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||
|
||||
_ssl_fetch_subject() {
|
||||
local host="$1"
|
||||
local port="$2"
|
||||
echo | timeout 10 openssl s_client -connect "${host}:${port}" -servername "$host" 2>/dev/null \
|
||||
| openssl x509 -noout -subject -issuer 2>/dev/null
|
||||
}
|
||||
|
||||
_ssl_fetch_dates() {
|
||||
local host="$1"
|
||||
local port="$2"
|
||||
echo | timeout 10 openssl s_client -connect "${host}:${port}" -servername "$host" 2>/dev/null \
|
||||
| openssl x509 -noout -dates 2>/dev/null
|
||||
}
|
||||
|
||||
_ssl_fetch_san() {
|
||||
local host="$1"
|
||||
local port="$2"
|
||||
echo | timeout 10 openssl s_client -connect "${host}:${port}" -servername "$host" 2>/dev/null \
|
||||
| openssl x509 -noout -ext subjectAltName 2>/dev/null \
|
||||
| grep -oE 'DNS:[^,]+' | sed 's/DNS://g' | tr '\n' ' '
|
||||
}
|
||||
|
||||
_ssl_fetch_chain() {
|
||||
local host="$1"
|
||||
local port="$2"
|
||||
echo | timeout 10 openssl s_client -connect "${host}:${port}" -servername "$host" \
|
||||
-showcerts 2>/dev/null \
|
||||
| grep -E "^(subject|issuer)=" | sed 's/^/ /'
|
||||
}
|
||||
|
||||
_ssl_check_tls_version() {
|
||||
local host="$1"
|
||||
local port="$2"
|
||||
local proto="$3"
|
||||
local label="$4"
|
||||
if echo | timeout 5 openssl s_client -connect "${host}:${port}" \
|
||||
-servername "$host" "${proto}" 2>/dev/null | grep -q "Cipher"; then
|
||||
echo -e " ${RED}[x]${NC} ${label} -- soportado (inseguro)"
|
||||
else
|
||||
echo -e " ${GREEN}[ok]${NC} ${label} no soportado"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||
|
||||
inspect_ssl_cert() {
|
||||
local input="$1"
|
||||
|
||||
if [[ -z "$input" ]]; then
|
||||
error "inspect_ssl_cert: se requiere un host como argumento (ej: example.com o example.com:8443)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! command -v openssl &>/dev/null; then
|
||||
error "inspect_ssl_cert: 'openssl' no está instalado (sudo apt install openssl)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local host port
|
||||
read -r host port <<< "$(_ssl_parse_host_port "$input")"
|
||||
|
||||
info "Conectando a ${host}:${port}..."
|
||||
echo ""
|
||||
|
||||
local subj_issuer
|
||||
subj_issuer="$(_ssl_fetch_subject "$host" "$port")"
|
||||
if [[ -z "$subj_issuer" ]]; then
|
||||
error "inspect_ssl_cert: no se pudo obtener el certificado. ¿El host está disponible?" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local subject issuer
|
||||
subject="$(echo "$subj_issuer" | grep ^subject | cut -d= -f2- | xargs)"
|
||||
issuer="$(echo "$subj_issuer" | grep ^issuer | cut -d= -f2- | xargs)"
|
||||
|
||||
local dates
|
||||
dates="$(_ssl_fetch_dates "$host" "$port")"
|
||||
local not_before not_after
|
||||
not_before="$(echo "$dates" | grep notBefore | cut -d= -f2)"
|
||||
not_after="$(echo "$dates" | grep notAfter | cut -d= -f2)"
|
||||
|
||||
local days_left
|
||||
days_left="$(_ssl_days_until_expiry "$not_after")"
|
||||
|
||||
local sans
|
||||
sans="$(_ssl_fetch_san "$host" "$port")"
|
||||
|
||||
echo -e "${PURPLE}════════ Certificado SSL/TLS ════════════════════${NC}"
|
||||
echo -e " ${CYAN}Sujeto:${NC} ${subject}"
|
||||
echo -e " ${CYAN}Emisor:${NC} ${issuer}"
|
||||
echo -e " ${CYAN}Válido desde:${NC} ${not_before}"
|
||||
echo -e " ${CYAN}Válido hasta:${NC} ${not_after}"
|
||||
echo ""
|
||||
|
||||
if [[ $days_left -le 0 ]]; then
|
||||
echo -e " ${RED}[x] CERTIFICADO EXPIRADO${NC}"
|
||||
elif [[ $days_left -le $_SSL_WARN_DAYS ]]; then
|
||||
echo -e " ${YELLOW}[!] Expira en ${days_left} días -- renovar pronto${NC}"
|
||||
else
|
||||
echo -e " ${GREEN}[ok] Válido -- expira en ${days_left} días${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e " ${CYAN}SANs:${NC}"
|
||||
echo "$sans" | tr ' ' '\n' | grep -v '^$' | while IFS= read -r san; do
|
||||
echo -e " ${GREEN}*${NC} ${san}"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e "${PURPLE}════════ Cadena de confianza ════════════════════${NC}"
|
||||
_ssl_fetch_chain "$host" "$port"
|
||||
|
||||
echo ""
|
||||
echo -e "${PURPLE}════════ Versiones TLS aceptadas ════════════════${NC}"
|
||||
_ssl_check_tls_version "$host" "$port" "-tls1" "TLS 1.0"
|
||||
_ssl_check_tls_version "$host" "$port" "-tls1_1" "TLS 1.1"
|
||||
echo -e " ${GREEN}[ok]${NC} TLS 1.2 / 1.3 (estándar)"
|
||||
echo -e "${PURPLE}═════════════════════════════════════════════════${NC}"
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
inspect_ssl_cert "$@"
|
||||
fi
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user