Compare commits
537 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 | |||
| 0bdb3d72d7 | |||
| 4e8bbb0a88 | |||
| ffbcafa52d | |||
| d9b448a07b | |||
| 5c712bb974 | |||
| 29dee49a36 | |||
| f0d9ffa2bb | |||
| 132a7d3240 | |||
| dcd1843609 | |||
| d2ae672a23 | |||
| 76a607cf6f | |||
| a1b7e5e143 | |||
| fc8062bade | |||
| 7eef2544ab | |||
| 5aef738bc8 | |||
| 126a20ce07 | |||
| f3e62e8303 | |||
| 5965997c9e | |||
| 690e68a542 | |||
| c311623a76 | |||
| b1016ec845 | |||
| cbc4c5eafa | |||
| 89e443ab18 | |||
| f4932ce64c | |||
| 2d108c295a | |||
| 73a4c3a148 | |||
| 356dbcdadd | |||
| 1233efb31d | |||
| 513c2fb4a7 | |||
| 9b5c430f7f | |||
| 5f4f1f7508 | |||
| 9b4bb3aabc | |||
| 34ecadf5a4 | |||
| b55f120a00 | |||
| 89730911c2 | |||
| 3a3a8fd9a9 | |||
| 29b1c4cd8b | |||
| 131f860a94 | |||
| 9660a1c432 | |||
| 256e038cbe | |||
| b406b29074 | |||
| 834e910bcf | |||
| 7605a5760a | |||
| 9f4ac6de32 | |||
| a9f2c60e3d | |||
| 9fd0ca9cac | |||
| 63a9cb5273 | |||
| 25a392df48 | |||
| 9c0d24d3ef | |||
| bee3b0d946 | |||
| af039f6023 | |||
| f168795bda | |||
| bbd2cbff3e | |||
| 056ce6679c | |||
| eb9476503f | |||
| e7a00e221e | |||
| f61a4c4b18 | |||
| 40d6db312d | |||
| c5bb64160f | |||
| e89b78cc45 | |||
| e33b306225 | |||
| 9c859e96d8 | |||
| 10d17f9362 | |||
| 974f704214 | |||
| 0fa16a033c | |||
| f851988d6f | |||
| e9a8cbf20f | |||
| 846012c087 | |||
| 6d0d63cb23 | |||
| b220f8c0be | |||
| 4c52b41b7b | |||
| aea2131dcb | |||
| 1aaeec5090 | |||
| 7e3599e3ac | |||
| 29c8046d4e | |||
| 125ef74358 | |||
| 960f310bcf | |||
| 268a76602a | |||
| dc78d8fea3 | |||
| e02a950ee0 | |||
| a75170cbc6 | |||
| c33e907fef | |||
| d7f2c00d7b | |||
| 8f24157096 | |||
| 3b88857999 | |||
| 4d6ea9a910 | |||
| bb38eedfd1 | |||
| 9d3bfd2cd2 | |||
| 90693fb32f | |||
| b5a6711c64 | |||
| c72ae15429 | |||
| e3bb9c3b38 | |||
| 48caec5665 | |||
| 169cb0853b | |||
| add09c2faa | |||
| f748256c1d | |||
| 4b2240fbce | |||
| c2528c6ea4 | |||
| dd324b7785 | |||
| 9095fe8c65 | |||
| d6240022a4 | |||
| 405be396c8 | |||
| 2c15a0b5e9 | |||
| eaed99e52c | |||
| ac71d4b079 | |||
| f11f60d121 | |||
| e0573302af | |||
| 54be36dd63 | |||
| 72c572e1ea | |||
| 2bae07d1f5 | |||
| 9abaefeb00 | |||
| 05444f74d3 | |||
| 528a16cd5a | |||
| d549aa0314 | |||
| 3798e2d959 |
+1
-5
@@ -21,14 +21,12 @@ Cualquier decision tecnica que choque con estos objetivos esta mal priorizada. E
|
||||
|
||||
**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, cada analysis y **cada project** es su propio repo Gitea en `dataforge/<basename>` con branch `master` (ver ADR 0002). `apps/*`, `analysis/*` y `projects/*` estan en el `.gitignore` del repo padre — el codigo de cada app vive en `apps/<name>/.git/`. Cada `projects/<name>/` es a su vez un sub-repo que versiona solo sus docs de nivel-project (`project.md`, `CONVENTIONS.md`, ...) con un `.gitignore` interno que excluye `apps/*/` y `analysis/*/` (sub-repos hijos). Ver `.claude/rules/projects.md`. 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. **Gotcha worktrees**: si creas una app nueva dentro de un git worktree del repo padre, haz `git init` dentro de `apps/<name>/` ANTES de limpiar el worktree, sino el codigo se pierde (apps/* gitignored). **REGLA DURA**: el repo padre NUNCA trackea contenido de artefactos hijos (apps/analysis/projects) — solo `.gitkeep`. Nada de `git add -f` sobre esos paths: deja el padre permanentemente dirty (doble-tracking). Auditoria + fix en `.claude/rules/apps_subrepo.md`. Ver `.claude/rules/apps_subrepo.md`.
|
||||
**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`
|
||||
|
||||
**Slash commands:** `/commands` lista todos los slash commands del repo agrupados por namespace (global + projects). Project commands viven en `projects/<p>/.claude/commands/` y se exponen como `/<project>:<cmd>` via symlink. Ver `.claude/rules/project_commands.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`.
|
||||
|
||||
---
|
||||
@@ -193,7 +191,6 @@ Regla decisiva: antes de cada bloque de codigo, decide caso. Si dudas entre 2 y
|
||||
| `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>` |
|
||||
| `claude -p` o `subprocess(["claude", "-p", ...])` para obtener una respuesta del modelo | Lento (cold start ~7-15s, carga MCP + CLAUDE.md), caro, sin control de tools | `ask_llm` (grupo `claude-direct`, API directa, arranque 0). Ver regla `llm_invocation.md` |
|
||||
|
||||
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.
|
||||
|
||||
@@ -261,7 +258,6 @@ fn check params # Lista funciones sin params_schema
|
||||
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 services-spec # audita bloque `service:` del app.md (issue 0105)
|
||||
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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
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: opus
|
||||
model: sonnet
|
||||
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||
---
|
||||
|
||||
@@ -42,10 +42,10 @@ Opcionalmente:
|
||||
|
||||
```bash
|
||||
# Por id
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, dir_path FROM apps WHERE id = '<app_id>';"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, dir_path FROM apps WHERE id = '<app_id>';"
|
||||
|
||||
# Por dir_path
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, dir_path FROM apps WHERE dir_path = '<dir>';"
|
||||
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.
|
||||
@@ -78,8 +78,8 @@ APP_DB="$APP_DIR/operations.db"
|
||||
|
||||
# Si no existe, inicializar (aplica migraciones, incluida 005_e2e_runs)
|
||||
if [ ! -f "$APP_DB" ]; then
|
||||
cd $HOME/fn_registry
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init "$APP_DIR"
|
||||
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)
|
||||
@@ -97,7 +97,7 @@ Hay dos caminos:
|
||||
**Camino A — invocar funcion del registry (preferido):**
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
cd /home/lucas/fn_registry
|
||||
./fn run e2e_run_checks_go_infra ...
|
||||
```
|
||||
|
||||
@@ -139,15 +139,15 @@ func main() {
|
||||
|
||||
Ejecutar con:
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
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/fn_registry
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops assertion eval --db "$APP_DB"
|
||||
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.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: fn-constructor
|
||||
description: "Agente constructor (Fase 1) del ciclo reactivo. Construye funciones, tests y tipos en Go, Python, TypeScript y Bash para fn_registry."
|
||||
model: opus
|
||||
model: sonnet
|
||||
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||
---
|
||||
|
||||
@@ -15,20 +15,20 @@ Eres el agente constructor del fn_registry. Tu rol es crear funciones, tests y t
|
||||
|
||||
```bash
|
||||
# Buscar si ya existe algo similar (OBLIGATORIO antes de crear)
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
|
||||
# Buscar tipos existentes
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
|
||||
# Ver funciones de un dominio
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, purity, signature FROM functions WHERE domain = 'DOMINIO' ORDER BY name;"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, purity, signature FROM functions WHERE domain = 'DOMINIO' ORDER BY name;"
|
||||
|
||||
# Ver tipos de un dominio
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'DOMINIO';"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'DOMINIO';"
|
||||
|
||||
# Verificar que un ID referenciado existe
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM functions WHERE id = 'ID_AQUI';"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM types WHERE id = 'ID_AQUI';"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = 'ID_AQUI';"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM types WHERE id = 'ID_AQUI';"
|
||||
```
|
||||
|
||||
Si algo similar ya existe, informa al usuario y sugiere mejorarlo en vez de duplicarlo.
|
||||
@@ -39,13 +39,13 @@ Antes de implementar logica desde cero, busca funciones del registry que puedas
|
||||
|
||||
```bash
|
||||
# Buscar funciones reutilizables por lo que hacen (ampliar con OR y prefijos)
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, purity, signature, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:filter* OR description:map* OR description:transform*') ORDER BY name;"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, purity, signature, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:filter* OR description:map* OR description:transform*') ORDER BY name;"
|
||||
|
||||
# Ver que retorna y que tipos usa una funcion candidata
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, signature, returns, uses_types FROM functions WHERE id = 'ID_CANDIDATO';"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature, returns, uses_types FROM functions WHERE id = 'ID_CANDIDATO';"
|
||||
|
||||
# Buscar funciones puras del mismo dominio (las mas componibles)
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, signature FROM functions WHERE domain = 'DOMINIO' AND purity = 'pure' ORDER BY name;"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature FROM functions WHERE domain = 'DOMINIO' AND purity = 'pure' ORDER BY name;"
|
||||
```
|
||||
|
||||
**Criterios de reutilizacion:**
|
||||
@@ -78,38 +78,38 @@ Esto acelera la construccion y fortalece el grafo de dependencias del registry.
|
||||
| `bash` | `bash/functions/{domain}/{name}.sh` | `bash/functions/pipelines/{name}.sh` | *(no aplica)* |
|
||||
| `typescript` | `frontend/functions/{domain}/{name}.ts` | *(no aplica)* | `frontend/types/{domain}/{name}.ts` |
|
||||
|
||||
**Ruta absoluta donde crear el archivo** = `$HOME/fn_registry/` + `file_path` del .md.
|
||||
**Ruta absoluta donde crear el archivo** = `/home/lucas/fn_registry/` + `file_path` del .md.
|
||||
|
||||
Ejemplo: si `lang: bash` y `domain: infra`, el archivo va en:
|
||||
- `$HOME/fn_registry/bash/functions/infra/{name}.sh` + `.md`
|
||||
- **NUNCA** en `$HOME/fn_registry/functions/infra/{name}.sh`
|
||||
- `/home/lucas/fn_registry/bash/functions/infra/{name}.sh` + `.md`
|
||||
- **NUNCA** en `/home/lucas/fn_registry/functions/infra/{name}.sh`
|
||||
|
||||
### Estructura detallada
|
||||
|
||||
**Go** (carpeta raiz: `functions/` y `types/`)
|
||||
- Funciones: `$HOME/fn_registry/functions/{domain}/{name}.go` + `.md`
|
||||
- Tests: `$HOME/fn_registry/functions/{domain}/{name}_test.go`
|
||||
- Tipos: `$HOME/fn_registry/functions/{domain}/{name}.go` (codigo, mismo paquete Go) + `$HOME/fn_registry/types/{domain}/{name}.md` (metadata con file_path apuntando a functions/)
|
||||
- Pipelines: `$HOME/fn_registry/functions/pipelines/{name}.go` + `.md`
|
||||
- Funciones: `/home/lucas/fn_registry/functions/{domain}/{name}.go` + `.md`
|
||||
- Tests: `/home/lucas/fn_registry/functions/{domain}/{name}_test.go`
|
||||
- Tipos: `/home/lucas/fn_registry/functions/{domain}/{name}.go` (codigo, mismo paquete Go) + `/home/lucas/fn_registry/types/{domain}/{name}.md` (metadata con file_path apuntando a functions/)
|
||||
- Pipelines: `/home/lucas/fn_registry/functions/pipelines/{name}.go` + `.md`
|
||||
- Paquete Go = nombre del directorio (core, finance, datascience, cybersecurity, infra, shell, tui, io)
|
||||
|
||||
**Python** (carpeta raiz: `python/`)
|
||||
- Funciones: `$HOME/fn_registry/python/functions/{domain}/{name}.py` + `.md`
|
||||
- Tests: `$HOME/fn_registry/python/functions/{domain}/{name}_test.py`
|
||||
- Tipos: `$HOME/fn_registry/python/types/{domain}/{name}.py` + `.md`
|
||||
- Pipelines: `$HOME/fn_registry/python/functions/pipelines/{name}.py` + `.md`
|
||||
- Funciones: `/home/lucas/fn_registry/python/functions/{domain}/{name}.py` + `.md`
|
||||
- Tests: `/home/lucas/fn_registry/python/functions/{domain}/{name}_test.py`
|
||||
- Tipos: `/home/lucas/fn_registry/python/types/{domain}/{name}.py` + `.md`
|
||||
- Pipelines: `/home/lucas/fn_registry/python/functions/pipelines/{name}.py` + `.md`
|
||||
|
||||
**Bash** (carpeta raiz: `bash/`)
|
||||
- Funciones: `$HOME/fn_registry/bash/functions/{domain}/{name}.sh` + `.md`
|
||||
- Tests: `$HOME/fn_registry/bash/functions/{domain}/{name}_test.sh`
|
||||
- Pipelines: `$HOME/fn_registry/bash/functions/pipelines/{name}.sh` + `.md`
|
||||
- Funciones: `/home/lucas/fn_registry/bash/functions/{domain}/{name}.sh` + `.md`
|
||||
- Tests: `/home/lucas/fn_registry/bash/functions/{domain}/{name}_test.sh`
|
||||
- Pipelines: `/home/lucas/fn_registry/bash/functions/pipelines/{name}.sh` + `.md`
|
||||
- Tipos: Bash no tiene tipos — usar solo `uses_types` para referenciar tipos de otros lenguajes
|
||||
|
||||
**TypeScript** (carpeta raiz: `frontend/`)
|
||||
- Funciones puras: `$HOME/fn_registry/frontend/functions/core/{name}.ts` + `.md`
|
||||
- Componentes React: `$HOME/fn_registry/frontend/functions/ui/{name}.tsx` + `.md`
|
||||
- Funciones puras: `/home/lucas/fn_registry/frontend/functions/core/{name}.ts` + `.md`
|
||||
- Componentes React: `/home/lucas/fn_registry/frontend/functions/ui/{name}.tsx` + `.md`
|
||||
- Tests: junto al archivo, `{name}.test.ts` o `{name}.test.tsx`
|
||||
- Tipos: `$HOME/fn_registry/frontend/types/{domain}/{name}.ts` + `.md`
|
||||
- Tipos: `/home/lucas/fn_registry/frontend/types/{domain}/{name}.ts` + `.md`
|
||||
|
||||
---
|
||||
|
||||
@@ -591,7 +591,7 @@ Documentar completamente el .md igualmente.
|
||||
1. **BUSCAR** en registry.db con FTS5 si existe algo similar
|
||||
2. **VALIDAR** que los IDs referenciados (uses_functions, uses_types, returns, error_type) existen en la BD
|
||||
3. **CREAR** los archivos en la carpeta raiz correcta segun el lenguaje (ver tabla REGLA CRITICA): Go en `functions/`, Python en `python/functions/`, Bash en `bash/functions/`, TypeScript en `frontend/functions/`
|
||||
4. **INDEXAR** ejecutando: `cd $HOME/fn_registry && CGO_ENABLED=1 ./fn index`
|
||||
4. **INDEXAR** ejecutando: `cd /home/lucas/fn_registry && CGO_ENABLED=1 ./fn index`
|
||||
5. **VERIFICAR** con: `./fn show {id}` que se indexo correctamente
|
||||
6. Si hay errores de validacion, corregirlos y re-indexar
|
||||
|
||||
@@ -600,10 +600,10 @@ Documentar completamente el .md igualmente.
|
||||
1. **LEER** la funcion existente (codigo + .md) desde la BD: `sqlite3 registry.db "SELECT code, signature FROM functions WHERE id = '...'"`
|
||||
2. **CREAR** el archivo de test
|
||||
3. **EJECUTAR** los tests:
|
||||
- Go: `cd $HOME/fn_registry && CGO_ENABLED=1 go test -tags fts5 -run TestNombre ./functions/{domain}/`
|
||||
- Python: `cd $HOME/fn_registry/python && python -m pytest functions/{domain}/{name}_test.py`
|
||||
- Go: `cd /home/lucas/fn_registry && CGO_ENABLED=1 go test -tags fts5 -run TestNombre ./functions/{domain}/`
|
||||
- Python: `cd /home/lucas/fn_registry/python && python -m pytest functions/{domain}/{name}_test.py`
|
||||
- TypeScript: desde `frontend/`, ejecutar con el test runner configurado
|
||||
- Bash: `cd $HOME/fn_registry && bash bash/functions/{domain}/{name}_test.sh`
|
||||
- Bash: `cd /home/lucas/fn_registry && bash bash/functions/{domain}/{name}_test.sh`
|
||||
4. **ACTUALIZAR** el .md con `tested: true`, `tests: [...]` y `test_file_path`
|
||||
5. **RE-INDEXAR** y verificar
|
||||
|
||||
@@ -620,19 +620,19 @@ Documentar completamente el .md igualmente.
|
||||
|
||||
```bash
|
||||
# Compilar CLI (necesario si se modifico codigo del CLI)
|
||||
cd $HOME/fn_registry && CGO_ENABLED=1 go build -tags fts5 -o fn ./cmd/fn/
|
||||
cd /home/lucas/fn_registry && CGO_ENABLED=1 go build -tags fts5 -o fn ./cmd/fn/
|
||||
|
||||
# Indexar registry
|
||||
cd $HOME/fn_registry && CGO_ENABLED=1 ./fn index
|
||||
cd /home/lucas/fn_registry && CGO_ENABLED=1 ./fn index
|
||||
|
||||
# Tests Go de un dominio
|
||||
cd $HOME/fn_registry && CGO_ENABLED=1 go test -tags fts5 ./functions/{domain}/
|
||||
cd /home/lucas/fn_registry && CGO_ENABLED=1 go test -tags fts5 ./functions/{domain}/
|
||||
|
||||
# Tests Go de todo el registry
|
||||
cd $HOME/fn_registry && CGO_ENABLED=1 go test -tags fts5 ./...
|
||||
cd /home/lucas/fn_registry && CGO_ENABLED=1 go test -tags fts5 ./...
|
||||
|
||||
# Mostrar funcion indexada
|
||||
cd $HOME/fn_registry && ./fn show {id}
|
||||
cd /home/lucas/fn_registry && ./fn show {id}
|
||||
```
|
||||
|
||||
### fn run — Ejecutar funciones y pipelines directamente
|
||||
@@ -640,7 +640,7 @@ cd $HOME/fn_registry && ./fn show {id}
|
||||
Despues de crear/indexar, puedes ejecutar directamente con `fn run`:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
cd /home/lucas/fn_registry
|
||||
|
||||
# Go pipeline (go run . en su directorio)
|
||||
./fn run init_metabase --project test
|
||||
@@ -729,7 +729,7 @@ Peticion: "Crea una funcion que calcule la media de un slice de float64"
|
||||
|
||||
### Paso 1: Buscar en BD
|
||||
```bash
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:mean* OR name:average* OR description:media* OR description:average*') ORDER BY name;"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:mean* OR name:average* OR description:media* OR description:average*') ORDER BY name;"
|
||||
```
|
||||
|
||||
### Paso 2: Crear archivos
|
||||
@@ -823,6 +823,6 @@ func TestMean(t *testing.T) {
|
||||
|
||||
### Paso 3: Indexar y verificar
|
||||
```bash
|
||||
cd $HOME/fn_registry && CGO_ENABLED=1 ./fn index
|
||||
cd /home/lucas/fn_registry && CGO_ENABLED=1 ./fn index
|
||||
./fn show mean_go_core
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: fn-executor
|
||||
description: "Agente ejecutor (Fase 2) del ciclo reactivo. Prepara apps, ejecuta pipelines/funciones Go y Python, y registra ejecuciones en operations.db."
|
||||
model: opus
|
||||
model: sonnet
|
||||
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||
---
|
||||
|
||||
@@ -35,22 +35,22 @@ Las apps estan indexadas en registry.db con toda la metadata necesaria para ejec
|
||||
|
||||
```bash
|
||||
# Ver todas las apps disponibles
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, lang, domain, description, entry_point, dir_path FROM apps ORDER BY name;"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, domain, description, entry_point, dir_path FROM apps ORDER BY name;"
|
||||
|
||||
# Ver app completa con dependencias y framework
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, lang, entry_point, dir_path, uses_functions, uses_types, framework, tags FROM apps WHERE id = 'APP_ID';"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, entry_point, dir_path, uses_functions, uses_types, framework, tags FROM apps WHERE id = 'APP_ID';"
|
||||
|
||||
# Buscar apps por FTS (nombre, descripcion, tags, documentacion)
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, lang, description FROM apps WHERE id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, description FROM apps WHERE id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
|
||||
# Apps de un dominio
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, description, entry_point FROM apps WHERE domain = 'DOMINIO';"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, description, entry_point FROM apps WHERE domain = 'DOMINIO';"
|
||||
|
||||
# Apps que usan una funcion especifica
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name FROM apps WHERE uses_functions LIKE '%funcion_id%';"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name FROM apps WHERE uses_functions LIKE '%funcion_id%';"
|
||||
|
||||
# Ver documentacion completa de una app
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT documentation, notes FROM apps WHERE id = 'APP_ID';"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT documentation, notes FROM apps WHERE id = 'APP_ID';"
|
||||
```
|
||||
|
||||
**Campos clave de apps para ejecucion:**
|
||||
@@ -65,19 +65,19 @@ sqlite3 $HOME/fn_registry/registry.db "SELECT documentation, notes FROM apps WHE
|
||||
|
||||
```bash
|
||||
# Ver pipeline/funcion completa
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, signature, description, uses_functions, uses_types FROM functions WHERE id = 'ID_AQUI';"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, signature, description, uses_functions, uses_types FROM functions WHERE id = 'ID_AQUI';"
|
||||
|
||||
# Ver codigo de la funcion
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT code FROM functions WHERE id = 'ID_AQUI';"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT code FROM functions WHERE id = 'ID_AQUI';"
|
||||
|
||||
# Pipelines disponibles (con tag launcher para TUI)
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, signature, description FROM functions WHERE kind = 'pipeline' ORDER BY name;"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature, description FROM functions WHERE kind = 'pipeline' ORDER BY name;"
|
||||
|
||||
# Funciones impuras ejecutables directamente
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, signature, description FROM functions WHERE purity = 'impure' AND kind = 'function' ORDER BY name;"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature, description FROM functions WHERE purity = 'impure' AND kind = 'function' ORDER BY name;"
|
||||
|
||||
# Buscar por FTS
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
```
|
||||
|
||||
### Usar contexto de apps para ejecucion inteligente
|
||||
@@ -98,10 +98,10 @@ Cuando te pidan ejecutar una app, sigue este flujo:
|
||||
|
||||
```bash
|
||||
# Desde la raiz del registry
|
||||
cd $HOME/fn_registry
|
||||
cd /home/lucas/fn_registry
|
||||
|
||||
# Opcion A: Usar el CLI
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||
|
||||
# Opcion B: Copiar template directamente
|
||||
cp fn_operations/project_template/operations.db apps/{app_name}/operations.db
|
||||
@@ -221,10 +221,10 @@ Las entities representan los datos concretos del proyecto. Las relations documen
|
||||
### Crear entities (datos que el pipeline consume o produce)
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
cd /home/lucas/fn_registry
|
||||
|
||||
# Entity de entrada
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops entity add \
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity add \
|
||||
--db apps/{app_name}/operations.db \
|
||||
--name "btc_ticks" \
|
||||
--type-ref "tick_go_finance" \
|
||||
@@ -235,7 +235,7 @@ FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops entity add \
|
||||
--metadata '{"pair":"BTCUSDT","exchange":"binance"}'
|
||||
|
||||
# Entity de salida
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops entity add \
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity add \
|
||||
--db apps/{app_name}/operations.db \
|
||||
--name "btc_ohlcv_5m" \
|
||||
--type-ref "ohlcv_go_finance" \
|
||||
@@ -249,7 +249,7 @@ FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops entity add \
|
||||
### Crear relations (como se conectan entities)
|
||||
|
||||
```bash
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops relation add \
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops relation add \
|
||||
--db apps/{app_name}/operations.db \
|
||||
--name "ticks_to_ohlcv" \
|
||||
--from-entity "{entity_id}" \
|
||||
@@ -262,13 +262,13 @@ FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops relation add \
|
||||
|
||||
```bash
|
||||
# Listar entities
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops entity list --db apps/{app_name}/operations.db
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity list --db apps/{app_name}/operations.db
|
||||
|
||||
# Listar relations
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops relation list --db apps/{app_name}/operations.db
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops relation list --db apps/{app_name}/operations.db
|
||||
|
||||
# Ver grafo ASCII
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops graph --db apps/{app_name}/operations.db
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops graph --db apps/{app_name}/operations.db
|
||||
```
|
||||
|
||||
---
|
||||
@@ -280,7 +280,7 @@ FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops graph --db apps/{app_name}/operation
|
||||
`fn run` despacha automaticamente segun el lenguaje y tipo:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
cd /home/lucas/fn_registry
|
||||
|
||||
# Go pipeline (go run . en su directorio)
|
||||
./fn run init_metabase --project test
|
||||
@@ -318,13 +318,13 @@ Para apps con su propio main.go/main.py/main.sh:
|
||||
|
||||
```bash
|
||||
# Go app
|
||||
cd $HOME/fn_registry/apps/{app_name} && CGO_ENABLED=1 go run -tags fts5 . [flags]
|
||||
cd /home/lucas/fn_registry/apps/{app_name} && CGO_ENABLED=1 go run -tags fts5 . [flags]
|
||||
|
||||
# Python app
|
||||
cd $HOME/fn_registry/apps/{app_name} && python3 main.py [args]
|
||||
cd /home/lucas/fn_registry/apps/{app_name} && python3 main.py [args]
|
||||
|
||||
# Bash app
|
||||
cd $HOME/fn_registry/apps/{app_name} && bash main.sh [args]
|
||||
cd /home/lucas/fn_registry/apps/{app_name} && bash main.sh [args]
|
||||
```
|
||||
|
||||
### Capturar metricas de ejecucion
|
||||
@@ -340,7 +340,7 @@ Al ejecutar, siempre captura:
|
||||
```bash
|
||||
# Ejemplo: ejecutar con captura de tiempo
|
||||
START=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
OUTPUT=$(cd $HOME/fn_registry/apps/{app_name} && CGO_ENABLED=1 go run -tags fts5 . 2>&1)
|
||||
OUTPUT=$(cd /home/lucas/fn_registry/apps/{app_name} && CGO_ENABLED=1 go run -tags fts5 . 2>&1)
|
||||
EXIT_CODE=$?
|
||||
END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
@@ -362,7 +362,7 @@ echo "Status: $STATUS | Start: $START | End: $END"
|
||||
### Via CLI
|
||||
|
||||
```bash
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution add \
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
|
||||
--db apps/{app_name}/operations.db \
|
||||
--pipeline-id "tick_to_ohlcv_go_finance" \
|
||||
--relation-id "{relation_id}" \
|
||||
@@ -396,16 +396,16 @@ sqlite3 apps/{app_name}/operations.db "INSERT INTO executions (id, pipeline_id,
|
||||
|
||||
```bash
|
||||
# Listar todas
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db
|
||||
|
||||
# Por pipeline
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db --pipeline-id "ID"
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db --pipeline-id "ID"
|
||||
|
||||
# Por status
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db --status failure
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db --status failure
|
||||
|
||||
# Detalle de una ejecucion
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution show --db apps/{app_name}/operations.db --id "EXEC_ID"
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution show --db apps/{app_name}/operations.db --id "EXEC_ID"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -441,12 +441,12 @@ Si hay assertions definidas sobre las entities afectadas, evaluarlas para verifi
|
||||
|
||||
```bash
|
||||
# Evaluar assertions de una entity
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops assertion eval \
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval \
|
||||
--db apps/{app_name}/operations.db \
|
||||
--entity-id "ENTITY_ID"
|
||||
|
||||
# Evaluar Y reaccionar (actualiza status de entities, crea proposals si hay fallos criticos)
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops assertion eval \
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval \
|
||||
--db apps/{app_name}/operations.db \
|
||||
--entity-id "ENTITY_ID" \
|
||||
--react
|
||||
@@ -467,10 +467,10 @@ Cuando el usuario pide ejecutar algo que aun no tiene app:
|
||||
|
||||
```bash
|
||||
# 1. Crear directorio
|
||||
mkdir -p $HOME/fn_registry/apps/{app_name}
|
||||
mkdir -p /home/lucas/fn_registry/apps/{app_name}
|
||||
|
||||
# 2. Crear app.md (OBLIGATORIO)
|
||||
cat > $HOME/fn_registry/apps/{app_name}/app.md << 'MDEOF'
|
||||
cat > /home/lucas/fn_registry/apps/{app_name}/app.md << 'MDEOF'
|
||||
---
|
||||
name: {app_name}
|
||||
lang: go
|
||||
@@ -490,7 +490,7 @@ dir_path: "apps/{app_name}"
|
||||
MDEOF
|
||||
|
||||
# 3. Crear .gitignore
|
||||
cat > $HOME/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
|
||||
cat > /home/lucas/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
|
||||
operations.db
|
||||
operations.db-wal
|
||||
operations.db-shm
|
||||
@@ -499,7 +499,7 @@ build/
|
||||
GIEOF
|
||||
|
||||
# 4. Inicializar modulo Go
|
||||
cd $HOME/fn_registry/apps/{app_name}
|
||||
cd /home/lucas/fn_registry/apps/{app_name}
|
||||
go mod init fn_registry/apps/{app_name}
|
||||
|
||||
# 5. Crear main.go minimo
|
||||
@@ -523,8 +523,8 @@ func main() {
|
||||
GOEOF
|
||||
|
||||
# 6. Inicializar operations.db
|
||||
cd $HOME/fn_registry
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
||||
cd /home/lucas/fn_registry
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||
|
||||
# 7. Indexar en registry.db
|
||||
./fn index
|
||||
@@ -534,10 +534,10 @@ FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
||||
|
||||
```bash
|
||||
# 1. Crear directorio
|
||||
mkdir -p $HOME/fn_registry/apps/{app_name}
|
||||
mkdir -p /home/lucas/fn_registry/apps/{app_name}
|
||||
|
||||
# 2. Crear app.md (OBLIGATORIO)
|
||||
cat > $HOME/fn_registry/apps/{app_name}/app.md << 'MDEOF'
|
||||
cat > /home/lucas/fn_registry/apps/{app_name}/app.md << 'MDEOF'
|
||||
---
|
||||
name: {app_name}
|
||||
lang: py
|
||||
@@ -557,7 +557,7 @@ dir_path: "apps/{app_name}"
|
||||
MDEOF
|
||||
|
||||
# 3. Crear .gitignore
|
||||
cat > $HOME/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
|
||||
cat > /home/lucas/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
|
||||
operations.db
|
||||
operations.db-wal
|
||||
operations.db-shm
|
||||
@@ -565,7 +565,7 @@ __pycache__/
|
||||
GIEOF
|
||||
|
||||
# 4. Crear main.py
|
||||
cat > $HOME/fn_registry/apps/{app_name}/main.py << 'PYEOF'
|
||||
cat > /home/lucas/fn_registry/apps/{app_name}/main.py << 'PYEOF'
|
||||
"""Pipeline executor."""
|
||||
import sys
|
||||
import time
|
||||
@@ -584,8 +584,8 @@ if __name__ == "__main__":
|
||||
PYEOF
|
||||
|
||||
# 5. Inicializar operations.db
|
||||
cd $HOME/fn_registry
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
||||
cd /home/lucas/fn_registry
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||
|
||||
# 6. Indexar en registry.db
|
||||
./fn index
|
||||
@@ -595,10 +595,10 @@ FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
||||
|
||||
```bash
|
||||
# 1. Crear directorio
|
||||
mkdir -p $HOME/fn_registry/apps/{app_name}
|
||||
mkdir -p /home/lucas/fn_registry/apps/{app_name}
|
||||
|
||||
# 2. Crear app.md (OBLIGATORIO)
|
||||
cat > $HOME/fn_registry/apps/{app_name}/app.md << 'MDEOF'
|
||||
cat > /home/lucas/fn_registry/apps/{app_name}/app.md << 'MDEOF'
|
||||
---
|
||||
name: {app_name}
|
||||
lang: bash
|
||||
@@ -618,14 +618,14 @@ dir_path: "apps/{app_name}"
|
||||
MDEOF
|
||||
|
||||
# 3. Crear .gitignore
|
||||
cat > $HOME/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
|
||||
cat > /home/lucas/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
|
||||
operations.db
|
||||
operations.db-wal
|
||||
operations.db-shm
|
||||
GIEOF
|
||||
|
||||
# 4. Crear main.sh
|
||||
cat > $HOME/fn_registry/apps/{app_name}/main.sh << 'SHEOF'
|
||||
cat > /home/lucas/fn_registry/apps/{app_name}/main.sh << 'SHEOF'
|
||||
#!/usr/bin/env bash
|
||||
# Pipeline executor: {app_name}
|
||||
set -euo pipefail
|
||||
@@ -650,11 +650,11 @@ main() {
|
||||
|
||||
main "$@"
|
||||
SHEOF
|
||||
chmod +x $HOME/fn_registry/apps/{app_name}/main.sh
|
||||
chmod +x /home/lucas/fn_registry/apps/{app_name}/main.sh
|
||||
|
||||
# 5. Inicializar operations.db
|
||||
cd $HOME/fn_registry
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
||||
cd /home/lucas/fn_registry
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||
|
||||
# 6. Indexar en registry.db
|
||||
./fn index
|
||||
@@ -669,7 +669,7 @@ Este patron captura todo lo necesario para registrar la ejecucion:
|
||||
### Go
|
||||
|
||||
```bash
|
||||
APP_DIR="$HOME/fn_registry/apps/{app_name}"
|
||||
APP_DIR="/home/lucas/fn_registry/apps/{app_name}"
|
||||
OPS_DB="$APP_DIR/operations.db"
|
||||
PIPELINE_ID="{pipeline_id}"
|
||||
RELATION_ID="{relation_id}" # vacio si no aplica
|
||||
@@ -689,8 +689,8 @@ else
|
||||
fi
|
||||
|
||||
# Registrar ejecucion
|
||||
cd $HOME/fn_registry
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution add \
|
||||
cd /home/lucas/fn_registry
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
|
||||
--db "$OPS_DB" \
|
||||
--pipeline-id "$PIPELINE_ID" \
|
||||
--status "$STATUS" \
|
||||
@@ -704,7 +704,7 @@ rm -f "$STDOUT_FILE" "$STDERR_FILE"
|
||||
### Python
|
||||
|
||||
```bash
|
||||
APP_DIR="$HOME/fn_registry/apps/{app_name}"
|
||||
APP_DIR="/home/lucas/fn_registry/apps/{app_name}"
|
||||
OPS_DB="$APP_DIR/operations.db"
|
||||
|
||||
START=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
@@ -716,8 +716,8 @@ END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
STATUS="success"
|
||||
[ $EXIT_CODE -ne 0 ] && STATUS="failure"
|
||||
|
||||
cd $HOME/fn_registry
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution add \
|
||||
cd /home/lucas/fn_registry
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
|
||||
--db "$OPS_DB" \
|
||||
--pipeline-id "{pipeline_id}" \
|
||||
--status "$STATUS" \
|
||||
@@ -728,7 +728,7 @@ FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution add \
|
||||
### Bash
|
||||
|
||||
```bash
|
||||
APP_DIR="$HOME/fn_registry/apps/{app_name}"
|
||||
APP_DIR="/home/lucas/fn_registry/apps/{app_name}"
|
||||
OPS_DB="$APP_DIR/operations.db"
|
||||
PIPELINE_ID="{pipeline_id}"
|
||||
|
||||
@@ -741,8 +741,8 @@ END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
STATUS="success"
|
||||
[ $EXIT_CODE -ne 0 ] && STATUS="failure"
|
||||
|
||||
cd $HOME/fn_registry
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution add \
|
||||
cd /home/lucas/fn_registry
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
|
||||
--db "$OPS_DB" \
|
||||
--pipeline-id "$PIPELINE_ID" \
|
||||
--status "$STATUS" \
|
||||
@@ -758,10 +758,10 @@ Antes de ejecutar, verifica que los snapshots de tipos en operations.db estan al
|
||||
|
||||
```bash
|
||||
# Verificar snapshots
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops snapshot check --db apps/{app_name}/operations.db
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot check --db apps/{app_name}/operations.db
|
||||
|
||||
# Actualizar si estan desactualizados
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops snapshot update --db apps/{app_name}/operations.db --id "TYPE_ID"
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot update --db apps/{app_name}/operations.db --id "TYPE_ID"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -800,7 +800,7 @@ Crea una proposal cuando detectes:
|
||||
### Como crear proposals
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
cd /home/lucas/fn_registry
|
||||
|
||||
# Proposal para nueva funcion
|
||||
./fn proposal add \
|
||||
@@ -840,7 +840,7 @@ Cuando la proposal viene de un fallo o anomalia en una ejecucion, incluye la evi
|
||||
|
||||
```bash
|
||||
# Obtener el ID de la ejecucion que evidencia el problema
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution list \
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list \
|
||||
--db apps/{app_name}/operations.db --status failure
|
||||
|
||||
# Incluir evidencia en la descripcion
|
||||
@@ -858,19 +858,19 @@ Usa el contexto de la tabla apps para comparar y detectar patrones:
|
||||
|
||||
```bash
|
||||
# Ver que funciones usan las apps — detectar patrones comunes
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, uses_functions FROM apps WHERE uses_functions != '[]';"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, uses_functions FROM apps WHERE uses_functions != '[]';"
|
||||
|
||||
# Ver funciones mas usadas por apps (candidatas a mejora)
|
||||
sqlite3 $HOME/fn_registry/registry.db "
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "
|
||||
SELECT f.value as func_id, COUNT(*) as uso
|
||||
FROM apps, json_each(apps.uses_functions) f
|
||||
GROUP BY f.value ORDER BY uso DESC;"
|
||||
|
||||
# Ver apps que NO tienen funciones del registry (candidatas a extraccion)
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, description FROM apps WHERE uses_functions = '[]';"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, description FROM apps WHERE uses_functions = '[]';"
|
||||
|
||||
# Ver si ya existe una proposal para algo similar
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, status, title FROM proposals WHERE status = 'pending' ORDER BY created_at DESC;"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, status, title FROM proposals WHERE status = 'pending' ORDER BY created_at DESC;"
|
||||
```
|
||||
|
||||
### Flujo de deteccion al ejecutar
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
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: opus
|
||||
model: sonnet
|
||||
tools: Read, Bash, Grep, Glob
|
||||
---
|
||||
|
||||
@@ -43,12 +43,12 @@ APP_ID="<input>"
|
||||
RUN_ID="<input>"
|
||||
|
||||
# dir_path desde registry
|
||||
DIR_PATH=$(sqlite3 $HOME/fn_registry/registry.db \
|
||||
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/fn_registry/registry.db \
|
||||
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/fn_registry/$DIR_PATH/operations.db"
|
||||
APP_DB="/home/lucas/fn_registry/$DIR_PATH/operations.db"
|
||||
[ ! -f "$APP_DB" ] && APP_DB="/tmp/$(basename $DIR_PATH)_e2e_runs.db"
|
||||
|
||||
# Sanity check
|
||||
@@ -93,7 +93,7 @@ Por cada fallo:
|
||||
Antes de crear proposal, verificar que no haya una identica abierta:
|
||||
|
||||
```bash
|
||||
sqlite3 $HOME/fn_registry/registry.db "
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "
|
||||
SELECT id FROM proposals
|
||||
WHERE status = 'pending'
|
||||
AND target_id = '$APP_ID'
|
||||
@@ -139,7 +139,7 @@ Sugerencia generica en `description` (NO codigo concreto, solo direccion):
|
||||
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/fn_registry/registry.db "
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "
|
||||
SELECT COUNT(*) FROM proposals
|
||||
WHERE target_id = '$APP_ID'
|
||||
AND title LIKE '%::$CHECK_ID%'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
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: opus
|
||||
model: sonnet
|
||||
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||
---
|
||||
|
||||
@@ -30,16 +30,6 @@ Referencia completa: `dev/issues/0069-autonomous-agent-loop-self-iterating-tasks
|
||||
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.
|
||||
9. **NUNCA paths absolutos fuera del worktree**. SIEMPRE rutas relativas o absolutas que apunten dentro de `/tmp/fn_orq_<issue>_<ts>/`. Si necesitas leer algo del repo principal (ej. plantillas docs), copialo al worktree primero. Refuerzo del piloto 1 (2026-05-15): orquestador modifico hooks bash del repo principal usando paths absolutos `$HOME/fn_registry/bash/functions/...` para destrancar pre-commit. Solucion correcta: el fix vive en el worktree, NO en main.
|
||||
10. **Pre-commit hook compartido**. Worktrees comparten `.git/hooks/` con main repo. Si el hook llama scripts via path absoluto a main (ej. `$HOME/fn_registry/bash/functions/cybersecurity/scan_secrets_in_dirty.sh`), el hook ejecutara la version de MAIN, no la del worktree. Opciones legitimas:
|
||||
a. Aplicar el fix del hook EN EL WORKTREE y commitearlo en `auto/*` — al mergear el PR, main heredara el fix.
|
||||
b. Si el hook bloquea progreso y el fix del hook excede tu scope, `git commit --no-verify` para ESE commit SOLO, documentando excepcion en `task_runs.events_json[].decision="skip_hook"` con razon.
|
||||
NO modificar archivos en main directamente.
|
||||
11. **Post-iteracion sanity check**. Tras cada commit en `auto/*`, verificar:
|
||||
```bash
|
||||
git -C $HOME/fn_registry status --short
|
||||
```
|
||||
Si la salida cambia respecto al baseline (capturado al inicio del piloto), HAS contaminado el repo principal. ABORT con `status=sandbox_breach` y reporta los archivos afectados en el output al humano.
|
||||
|
||||
---
|
||||
|
||||
@@ -49,24 +39,24 @@ Antes de arrancar el bucle, comprobar:
|
||||
|
||||
```bash
|
||||
# 1. Migration 006_task_runs.sql existe
|
||||
ls $HOME/fn_registry/fn_operations/migrations/006_task_runs.sql 2>/dev/null \
|
||||
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/fn_registry/.claude/agents/$a/SKILL.md \
|
||||
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/fn_registry fetch origin master --quiet
|
||||
LOCAL=$(git -C $HOME/fn_registry rev-parse master)
|
||||
REMOTE=$(git -C $HOME/fn_registry rev-parse origin/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/fn_registry rev-parse --verify "auto/${ISSUE_ID}" >/dev/null 2>&1 \
|
||||
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)
|
||||
@@ -116,7 +106,7 @@ 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/fn_registry"
|
||||
REPO="/home/lucas/fn_registry"
|
||||
|
||||
# Crear worktree aislado desde master (no toca el principal)
|
||||
git -C "$REPO" worktree add -b "$BRANCH" "$WT_ROOT" master \
|
||||
@@ -187,13 +177,13 @@ while iter < max_iterations and elapsed < max_minutes:
|
||||
|
||||
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/fn_registry/`.
|
||||
**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/fn_registry
|
||||
Working dir: <WT_ROOT> # NO /home/lucas/fn_registry
|
||||
Branch: auto/<issue_id>
|
||||
Repo principal (solo lectura para registry.db): $HOME/fn_registry
|
||||
Repo principal (solo lectura para registry.db): /home/lucas/fn_registry
|
||||
...
|
||||
```
|
||||
|
||||
@@ -346,7 +336,7 @@ Cada `progress_json` entry:
|
||||
|---|---|---|
|
||||
| `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/fn_registry/` en vez de worktree | prompt sin `WT_ROOT` explicito | rebriefing con working dir explicito |
|
||||
| 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 |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
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. 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: opus
|
||||
model: sonnet
|
||||
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||
---
|
||||
|
||||
@@ -40,10 +40,10 @@ apps/{app_name}/
|
||||
|
||||
```bash
|
||||
# Listar todas las apps
|
||||
ls -d $HOME/fn_registry/apps/*/
|
||||
ls -d /home/lucas/fn_registry/apps/*/
|
||||
|
||||
# Verificar que cada app tiene app.md
|
||||
for app in $HOME/fn_registry/apps/*/; do
|
||||
for app in /home/lucas/fn_registry/apps/*/; do
|
||||
name=$(basename "$app")
|
||||
echo "=== $name ==="
|
||||
[ -f "$app/app.md" ] && echo " app.md: OK" || echo " app.md: FALTA"
|
||||
@@ -82,8 +82,8 @@ sqlite3 "$APP_DB" "SELECT * FROM schema_migrations ORDER BY version;" 2>/dev/nul
|
||||
**Si faltan tablas**, aplicar migraciones:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
||||
cd /home/lucas/fn_registry
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||
```
|
||||
|
||||
### 3. Integridad de Entities
|
||||
@@ -96,7 +96,7 @@ sqlite3 "$APP_DB" "SELECT id, name, type_ref, status, domain, source FROM entiti
|
||||
|
||||
# Validar que type_ref existe en registry.db
|
||||
sqlite3 "$APP_DB" "SELECT DISTINCT type_ref FROM entities;" | while read ref; do
|
||||
EXISTS=$(sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM types WHERE id = '$ref';")
|
||||
EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM types WHERE id = '$ref';")
|
||||
if [ -z "$EXISTS" ]; then
|
||||
echo "ERROR: type_ref '$ref' no existe en registry.db"
|
||||
fi
|
||||
@@ -129,7 +129,7 @@ sqlite3 "$APP_DB" "SELECT r.id, r.name, r.to_entity FROM relations r WHERE r.to_
|
||||
|
||||
# Validar que 'via' referencia una funcion/pipeline del registry
|
||||
sqlite3 "$APP_DB" "SELECT DISTINCT via FROM relations WHERE via != '';" | while read via; do
|
||||
EXISTS=$(sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$via';")
|
||||
EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$via';")
|
||||
if [ -z "$EXISTS" ]; then
|
||||
echo "ERROR: relation.via '$via' no existe en registry.db"
|
||||
fi
|
||||
@@ -156,7 +156,7 @@ sqlite3 "$APP_DB" "SELECT id, pipeline_id, status, started_at, duration_ms, reco
|
||||
|
||||
# Validar que pipeline_id existe en registry.db
|
||||
sqlite3 "$APP_DB" "SELECT DISTINCT pipeline_id FROM executions;" | while read pid; do
|
||||
EXISTS=$(sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$pid';")
|
||||
EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$pid';")
|
||||
if [ -z "$EXISTS" ]; then
|
||||
echo "ERROR: pipeline_id '$pid' no existe en registry.db"
|
||||
fi
|
||||
@@ -230,7 +230,7 @@ sqlite3 "$APP_DB" "SELECT id, version, lang, algebraic, snapped_at FROM types_sn
|
||||
|
||||
# Comparar con registry.db — detectar snapshots desactualizados
|
||||
sqlite3 "$APP_DB" "SELECT id, version FROM types_snapshot;" | while IFS='|' read id ver; do
|
||||
REG_VER=$(sqlite3 $HOME/fn_registry/registry.db "SELECT version FROM types WHERE id = '$id';")
|
||||
REG_VER=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT version FROM types WHERE id = '$id';")
|
||||
if [ -z "$REG_VER" ]; then
|
||||
echo "WARN: snapshot '$id' ya no existe en registry.db"
|
||||
elif [ "$ver" != "$REG_VER" ]; then
|
||||
@@ -252,14 +252,14 @@ done
|
||||
|
||||
```bash
|
||||
# Verificar que la app esta en registry.db
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, lang, domain, entry_point, dir_path FROM apps WHERE name = '{app_name}';"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, domain, entry_point, dir_path FROM apps WHERE name = '{app_name}';"
|
||||
|
||||
# Verificar que uses_functions del app.md coincide con lo indexado
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT uses_functions FROM apps WHERE name = '{app_name}';"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT uses_functions FROM apps WHERE name = '{app_name}';"
|
||||
|
||||
# Verificar que todas las funciones referenciadas existen
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT f.value FROM apps, json_each(apps.uses_functions) f WHERE apps.name = '{app_name}';" | while read fid; do
|
||||
EXISTS=$(sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$fid';")
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT f.value FROM apps, json_each(apps.uses_functions) f WHERE apps.name = '{app_name}';" | while read fid; do
|
||||
EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$fid';")
|
||||
if [ -z "$EXISTS" ]; then
|
||||
echo "ERROR: app usa funcion '$fid' que no existe en registry"
|
||||
fi
|
||||
@@ -273,7 +273,7 @@ done
|
||||
Patron para auditar TODAS las apps de una vez:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
cd /home/lucas/fn_registry
|
||||
|
||||
echo "========================================="
|
||||
echo "AUDITORIA DE APPS — fn-recopilador"
|
||||
@@ -327,7 +327,7 @@ for app_dir in apps/*/; do
|
||||
[ "$ERROR_LOGS" -gt 0 ] 2>/dev/null && echo " [WARN] $ERROR_LOGS logs de error"
|
||||
|
||||
# 9. App indexada en registry.db
|
||||
INDEXED=$(sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM apps WHERE name = '$APP_NAME';" 2>/dev/null)
|
||||
INDEXED=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM apps WHERE name = '$APP_NAME';" 2>/dev/null)
|
||||
[ -n "$INDEXED" ] && echo " [OK] Indexada en registry.db" || echo " [WARN] NO indexada en registry.db"
|
||||
done
|
||||
|
||||
@@ -393,25 +393,25 @@ echo "========================================="
|
||||
El recopilador puede sugerir o ejecutar estas reparaciones:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
cd /home/lucas/fn_registry
|
||||
|
||||
# Aplicar migraciones faltantes
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||
|
||||
# Actualizar snapshot desactualizado
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops snapshot update --db apps/{app_name}/operations.db --id "TYPE_ID"
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot update --db apps/{app_name}/operations.db --id "TYPE_ID"
|
||||
|
||||
# Verificar snapshots
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops snapshot check --db apps/{app_name}/operations.db
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot check --db apps/{app_name}/operations.db
|
||||
|
||||
# Evaluar assertions pendientes
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops assertion eval --db apps/{app_name}/operations.db --entity-id "ENTITY_ID"
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval --db apps/{app_name}/operations.db --entity-id "ENTITY_ID"
|
||||
|
||||
# Re-indexar para que la app aparezca en registry.db
|
||||
./fn index
|
||||
|
||||
# Ver grafo de la app (util para diagnostico visual)
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops graph --db apps/{app_name}/operations.db
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops graph --db apps/{app_name}/operations.db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+13
-13
@@ -38,13 +38,13 @@ Antes de crear nada, recopilar contexto:
|
||||
|
||||
```bash
|
||||
# Buscar funciones relevantes por descripcion
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, lang, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:TERMINO* OR name:TERMINO*') ORDER BY name;"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, lang, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:TERMINO* OR name:TERMINO*') ORDER BY name;"
|
||||
|
||||
# Buscar apps similares
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, lang, description, uses_functions FROM apps WHERE id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, description, uses_functions FROM apps WHERE id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
|
||||
# Verificar que el nombre no esta tomado
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM apps WHERE name = 'NOMBRE';"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM apps WHERE name = 'NOMBRE';"
|
||||
```
|
||||
|
||||
4. **Presentar plan al usuario** antes de ejecutar:
|
||||
@@ -79,7 +79,7 @@ Usar el Agent tool con `subagent_type: "fn-constructor"` pasando:
|
||||
Despues de que fn-constructor termine, verificar que todo se indexo:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry && ./fn index
|
||||
cd /home/lucas/fn_registry && ./fn index
|
||||
# Verificar cada funcion creada
|
||||
./fn show {id_de_cada_funcion}
|
||||
```
|
||||
@@ -91,7 +91,7 @@ cd $HOME/fn_registry && ./fn index
|
||||
### Estructura base (todos los lenguajes)
|
||||
|
||||
```bash
|
||||
mkdir -p $HOME/fn_registry/apps/{app_name}
|
||||
mkdir -p /home/lucas/fn_registry/apps/{app_name}
|
||||
```
|
||||
|
||||
### app.md (OBLIGATORIO — siempre primero)
|
||||
@@ -143,7 +143,7 @@ build/
|
||||
|
||||
**Go (CLI/TUI):**
|
||||
```bash
|
||||
cd $HOME/fn_registry/apps/{app_name}
|
||||
cd /home/lucas/fn_registry/apps/{app_name}
|
||||
go mod init fn_registry/apps/{app_name}
|
||||
# Crear main.go, app/, config/, views/ segun necesidad
|
||||
```
|
||||
@@ -151,7 +151,7 @@ go mod init fn_registry/apps/{app_name}
|
||||
**Go (Wails — desktop con UI):**
|
||||
```bash
|
||||
# Usar scaffold del registry
|
||||
cd $HOME/fn_registry
|
||||
cd /home/lucas/fn_registry
|
||||
./fn run scaffold_wails_app -- --name {app_name} --dir apps/{app_name}
|
||||
```
|
||||
|
||||
@@ -165,20 +165,20 @@ cd $HOME/fn_registry
|
||||
```bash
|
||||
# Crear main.sh con source a funciones del registry
|
||||
# Pattern: source "$REGISTRY_ROOT/bash/functions/{domain}/{func}.sh"
|
||||
chmod +x $HOME/fn_registry/apps/{app_name}/main.sh
|
||||
chmod +x /home/lucas/fn_registry/apps/{app_name}/main.sh
|
||||
```
|
||||
|
||||
### Inicializar operations.db
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
||||
cd /home/lucas/fn_registry
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||
```
|
||||
|
||||
### Indexar en registry.db
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry && ./fn index
|
||||
cd /home/lucas/fn_registry && ./fn index
|
||||
# Verificar
|
||||
sqlite3 registry.db "SELECT id, name, lang, domain FROM apps WHERE name = '{app_name}';"
|
||||
```
|
||||
@@ -241,7 +241,7 @@ Usar el Agent tool con `subagent_type: "gitea"` pasando:
|
||||
```bash
|
||||
# 1. Crear repo en Gitea (via API)
|
||||
# 2. Inicializar git en la app
|
||||
cd $HOME/fn_registry/apps/{app_name}
|
||||
cd /home/lucas/fn_registry/apps/{app_name}
|
||||
git init
|
||||
git add -A
|
||||
git commit -m "Initial commit: {app_name} — {descripcion}"
|
||||
@@ -256,7 +256,7 @@ git push -u origin master
|
||||
**Despues de publicar**, actualizar el `repo_url` en app.md y re-indexar:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry && ./fn index
|
||||
cd /home/lucas/fn_registry && ./fn index
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../projects/aurgi/.claude/commands
|
||||
@@ -1,36 +1,121 @@
|
||||
---
|
||||
description: "DEPRECADO 2026-05-19 — usa /autopilot. Wrapper directo a fn-orquestador conservado solo como debug primitive."
|
||||
# /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.
|
||||
|
||||
---
|
||||
|
||||
# /autonomous-task — DEPRECADO (sustituido por `/autopilot`)
|
||||
## Argumento
|
||||
|
||||
**ESTADO:** deprecado 2026-05-19. Usa `/autopilot <NNNN>` en su lugar.
|
||||
`$ARGUMENTS` — `<issue_id>` o `<task_spec_path>` + flags opcionales.
|
||||
|
||||
## Por que deprecado
|
||||
```
|
||||
/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
|
||||
```
|
||||
|
||||
`/autopilot` (v2, 2026-05-19) absorbe la funcionalidad y anade:
|
||||
- Pre-flight DoD readiness check (gate STOP — no arranca sin DoD).
|
||||
- Detector issue vs flow.
|
||||
- Reporte estructurado al humano post-delegate.
|
||||
- Self-Q&A migrado a fn-orquestador.
|
||||
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
|
||||
|
||||
Behaviour orquestador-side es identico. La unica diferencia es que `/autopilot` valida antes de delegar; `/autonomous-task` delegaba ciego.
|
||||
---
|
||||
|
||||
## Sustitucion 1:1
|
||||
## Comportamiento
|
||||
|
||||
| Antes | Ahora |
|
||||
|---|---|
|
||||
| `/autonomous-task 0070` | `/autopilot 0070` |
|
||||
| `/autonomous-task 0070 --max-iterations 15 --max-minutes 90` | `/autopilot 0070 --max-iterations 15 --max-minutes 90` |
|
||||
| `/autonomous-task 0070 --dry-run` | `/autopilot 0070 --dry-run` |
|
||||
| `/autonomous-task 0070 --auto-apply-proposals safe` | `/autopilot 0070 --auto-apply-proposals safe` |
|
||||
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`
|
||||
|
||||
## Modo debug
|
||||
---
|
||||
|
||||
Si `/autopilot` falla en pre-flight pero quieres forzar dispatch sin DoD check (debug / experimentos), puedes seguir usando `/autonomous-task` que va directo a `fn-orquestador` sin validar. NO RECOMENDADO para uso normal.
|
||||
## Reglas duras (no negociables)
|
||||
|
||||
## Migration deadline
|
||||
- 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/`.
|
||||
|
||||
Sin deadline duro — `/autonomous-task` seguira funcionando hasta que un commit lo elimine. Pero NO se anaden nuevas features aqui; cualquier mejora va a `/autopilot`.
|
||||
---
|
||||
|
||||
Ver `.claude/commands/autopilot.md` para spec completa.
|
||||
## 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
|
||||
```
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
---
|
||||
name: autopilot
|
||||
description: Modo full-auto. Pre-flight DoD check, detecta issue vs flow, SIEMPRE delega a fn-orquestador (worktree aislado + PR Gitea). Sin Path inline. Sustituye a /autonomous-task.
|
||||
---
|
||||
|
||||
# /autopilot — Comando autonomo unificado
|
||||
|
||||
Comando UNICO para ejecutar issue o flow autonomo end-to-end. Sustituye a `/autonomous-task` (deprecado). Hace dos cosas:
|
||||
|
||||
1. **Pre-flight DoD readiness check** — sin DoD claro, no arranca.
|
||||
2. **Delega SIEMPRE a `fn-orquestador`** via Agent tool — worktree aislado en `/tmp/fn_orq_<NNNN>_<ts>/`, branch `auto/<NNNN>-<slug>`, PR draft Gitea al converger.
|
||||
|
||||
NO ejecuta nada inline. NO muta cwd del shell del humano. NO duplica worktrees. Toda la complejidad de bucle + paths protegidos + sanity check vive en `fn-orquestador`.
|
||||
|
||||
## Por que solo delegar
|
||||
|
||||
Historico: versiones anteriores de `/autopilot` tenian Path A (delegate a orquestador), Path B (registry-only inline), Path C (flow inline). Los Path B/C reimplementaban lo que ya hace `fn-orquestador` (worktree, branch, PR) y arrastraban un bug: `cd` en Bash de Claude Code PERSISTE entre llamadas → si autopilot hace `cd "$WT"`, todos los Bash subsiguientes operan en branch incorrecta. Solucion: NO hacer Path inline, delegar siempre.
|
||||
|
||||
`fn-orquestador` ahora soporta dos `task_type`:
|
||||
- `issue` — flujo CONSTRUIR→EJECUTAR→RECOPILAR→ANALIZAR→MEJORAR (default).
|
||||
- `flow` — parsea `dev/flows/<NNNN>-*.md` ## Flow y ejecuta steps (Path C absorbido).
|
||||
|
||||
## Sintaxis
|
||||
|
||||
```
|
||||
/autopilot <NNNN> # issue NNNN (default si no hay prefijo)
|
||||
/autopilot issue:<NNNN> # issue explicito
|
||||
/autopilot i:<NNNN> # alias
|
||||
/autopilot flow:<NNNN> # flow NNNN
|
||||
/autopilot f:<NNNN> # alias
|
||||
/autopilot check <target> # solo audita DoD readiness, no delega
|
||||
/autopilot <target> --max-iterations N --max-minutes M --dry-run
|
||||
```
|
||||
|
||||
Detector:
|
||||
- `^\d{4}[a-z]?$` → issue (sin prefijo = issue por defecto).
|
||||
- `^(issue|i):\d{4}[a-z]?$` → issue.
|
||||
- `^(flow|f):\d{4}$` → flow.
|
||||
- Otra cosa → ABORT con error de sintaxis.
|
||||
|
||||
## Pre-flight DoD readiness check (OBLIGATORIO)
|
||||
|
||||
Sin DoD claro, autopilot no delega. Verificacion es STOP-gate.
|
||||
|
||||
### Issue (`dev/issues/<NNNN>-*.md`)
|
||||
|
||||
1. Archivo existe en `dev/issues/` (no en `completed/`).
|
||||
2. Frontmatter con `status`, `priority`.
|
||||
3. Al menos UNA de:
|
||||
- `## DoD` o `## Definition of Done` con >=1 bullet/checkbox concreto.
|
||||
- `## Acceptance` con checkboxes `[ ]`.
|
||||
- `## Tests` + `## Tareas` ambas no vacias.
|
||||
4. Tipo declarado/inferible soportado por `fn-orquestador`: `feature_app_simple`, `bugfix_with_repro`, `refactor_safe`, `add_e2e_check`, `feature_registry_only`.
|
||||
5. NO contiene criterios no-verificables ("queda bonito", "intuitivo", "UX mejor"). Grep simple; si match → ABORT con warning.
|
||||
|
||||
### Flow (`dev/flows/<NNNN>-*.md`)
|
||||
|
||||
1. Archivo existe en `dev/flows/`.
|
||||
2. Frontmatter valido.
|
||||
3. `## Acceptance` con >=1 checkbox.
|
||||
4. `## Flow` no vacio.
|
||||
5. Pre-requisitos declarados.
|
||||
6. Tabla de funciones recomendadas sin `FALTA: crear <id>` (si los hay → ABORT salvo `--allow-construct-missing`).
|
||||
|
||||
Si falla:
|
||||
|
||||
```
|
||||
=== /autopilot check 0125 ===
|
||||
status: NOT READY
|
||||
target: issue 0125 (skill-tree-dashboard-panel)
|
||||
gaps:
|
||||
- Sin seccion DoD/Acceptance
|
||||
- "UX intuitiva" linea 47 — no verificable
|
||||
fix:
|
||||
- Anadir ## DoD con 3-5 bullets programaticamente verificables
|
||||
- Reemplazar criterios subjetivos por mediciones concretas
|
||||
```
|
||||
|
||||
Si OK:
|
||||
|
||||
```
|
||||
=== /autopilot check 0107c ===
|
||||
status: READY
|
||||
target: issue 0107c (refactor data_table)
|
||||
dod_items: 5 checkboxes
|
||||
task_type: refactor_safe
|
||||
estimated_iter: 3-5
|
||||
```
|
||||
|
||||
## Dispatch a fn-orquestador
|
||||
|
||||
Tras pre-flight OK, ejecuta:
|
||||
|
||||
```
|
||||
Agent(
|
||||
subagent_type="fn-orquestador",
|
||||
prompt="""
|
||||
Issue/Flow: <path al .md>
|
||||
Modo: REAL (o --dry-run)
|
||||
task_type: <issue|flow>
|
||||
Pre-condiciones verificadas: 7/7 verde
|
||||
Master: <sha> sync con origin
|
||||
Working tree principal: limpio (baseline)
|
||||
Max iter: N
|
||||
Max min: M
|
||||
Auto-apply proposals: safe
|
||||
Token Gitea: pass gitea/dataforge-git-token
|
||||
DB task_runs: apps/deploy_server/operations.db (schema task_id)
|
||||
Reglas duras: autonomous_loop.md (11 reglas)
|
||||
""",
|
||||
run_in_background=true
|
||||
)
|
||||
```
|
||||
|
||||
Cuando termine, reporta al humano con output canonico del orquestador:
|
||||
|
||||
```
|
||||
=== /autopilot 0121b ===
|
||||
target: issue 0121b (fn doctor e2e-coverage)
|
||||
delegated_to: fn-orquestador
|
||||
status: converged
|
||||
iterations: 1 / 8
|
||||
duration: 4 min / 30
|
||||
task_run_id: task_d285372493cce2e6
|
||||
branch: auto/0121b-orquestador
|
||||
worktree: /tmp/fn_orq_0121b_1779147778
|
||||
PR draft: https://gitea-.../dataforge/fn_registry/pulls/3
|
||||
|
||||
Siguiente: revisar PR, mergear, mover issue a completed/
|
||||
```
|
||||
|
||||
## Reglas duras (autopilot-level)
|
||||
|
||||
1. **Cero cwd mutation**. Autopilot NUNCA hace `cd`. Usa `git -C <repo>` siempre si necesita inspeccionar.
|
||||
2. **Cero ejecucion inline de bucle**. Todo va via `fn-orquestador`. Si autopilot necesita ejecutar algo (pre-flight scripts), es read-only.
|
||||
3. **Cero AskUserQuestion**. Self-pick "Recommended". Si no hay, ABORT con `status=needs_human`.
|
||||
4. **DoD es contrato**. Si DoD no se cumple al final, `task_run.status` queda `partial` y autopilot reporta NOT_DONE — humano decide.
|
||||
5. **Worktree gestion delegada al orquestador**. Autopilot NO crea worktrees propios. NO toca branches.
|
||||
6. **Trazabilidad**: cada decision pre-delegate (especialmente abort de DoD check) se persiste en `task_runs.events_json[]` con `agent: autopilot`.
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Default | Que hace |
|
||||
|---|---|---|
|
||||
| `--max-iterations N` | 10 | Pasado al orquestador |
|
||||
| `--max-minutes M` | 60 | Pasado al orquestador |
|
||||
| `--dry-run` | off | Pasado al orquestador |
|
||||
| `--allow-construct-missing` | off | Flow con `FALTA: crear <id>` → spawn fn-constructor antes |
|
||||
| `--auto-apply-proposals` | `safe` | Pasado al orquestador |
|
||||
|
||||
## Errores canonicos
|
||||
|
||||
| Codigo | Significado | Accion |
|
||||
|---|---|---|
|
||||
| `NOT_READY` | DoD insuficiente | Humano edita .md y relanza |
|
||||
| `needs_human` | Decision ambigua | Humano resuelve y relanza |
|
||||
| `delegated_failed` | fn-orquestador devolvio fail/stall/timeout | Humano lee `task_runs.events_json` |
|
||||
| (resto) | Heredados del orquestador (stalled/timeout/aborted_protected_path/...) | Idem |
|
||||
|
||||
## Anti-patrones
|
||||
|
||||
| Anti-patron | Por que es malo |
|
||||
|---|---|
|
||||
| Hacer Path B/C inline | Mismo bug de cwd mutation que paso 2026-05-19 |
|
||||
| Saltar pre-flight DoD | Trabajar sin contrato = bucle infinito |
|
||||
| Mergear sin tests verde | fn-orquestador ya impide esto, NO bypaseas |
|
||||
| `AskUserQuestion` desde autopilot | Rompe contrato autonomo |
|
||||
| Crear worktree propio en autopilot | Duplica + colision con orquestador (paso 2026-05-19) |
|
||||
|
||||
## Ejemplos
|
||||
|
||||
```bash
|
||||
# Issue con DoD claro
|
||||
/autopilot 0107c
|
||||
|
||||
# Flow con piezas faltantes — autoriza creacion antes
|
||||
/autopilot flow:0008 --allow-construct-missing
|
||||
|
||||
# Solo audit
|
||||
/autopilot check 0125
|
||||
/autopilot check flow:0008
|
||||
|
||||
# Dry run
|
||||
/autopilot 0107c --dry-run
|
||||
```
|
||||
|
||||
## Relacion con otras reglas
|
||||
|
||||
- [[autonomous_loop]] — politica del bucle (sandbox, paths protegidos, watchdog). fn-orquestador la aplica.
|
||||
- [[apps_tbd]] — politica TBD por tipo de cambio.
|
||||
- [[apps_subrepo]] — `git init` dentro de apps nuevas antes de limpiar worktree.
|
||||
- [[feature_flags]] — codigo incompleto detras de flag OFF.
|
||||
- [[registry_calls]] — invocaciones canonicas.
|
||||
- [[e2e_validation]] — `e2e_checks` consumidos por fn-analizador.
|
||||
- [[delegation]] — spawn fn-constructor antes que escribir inline.
|
||||
|
||||
## Migracion desde `/autonomous-task`
|
||||
|
||||
`/autonomous-task` queda DEPRECADO. Sustitucion 1:1:
|
||||
|
||||
| Antes | Ahora |
|
||||
|---|---|
|
||||
| `/autonomous-task 0070` | `/autopilot 0070` |
|
||||
| `/autonomous-task 0070 --max-iterations 15` | `/autopilot 0070 --max-iterations 15` |
|
||||
| `/autonomous-task 0070 --dry-run` | `/autopilot 0070 --dry-run` |
|
||||
|
||||
`/autopilot` anade pre-flight DoD check + detect flow. Behaviour orquestador-side idem.
|
||||
|
||||
## Historico
|
||||
|
||||
- v1 (2026-05-15): introducido con Path A/B/C inline + self-Q&A.
|
||||
- v2 (2026-05-19): simplificado tras incidente cwd mutation en piloto 0121b. Solo delega a fn-orquestador. Self-Q&A movido al orquestador. Sustituye a `/autonomous-task`.
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
description: "Lista todos los slash commands disponibles en el repo: globales de fn_registry + namespaced de cada project. Filtra por substring o por namespace."
|
||||
---
|
||||
|
||||
# /commands — Catalogo de slash commands del repo
|
||||
|
||||
Inventario unificado. Lista los `.md` bajo `.claude/commands/` (recursivo, sigue symlinks) y agrupa por namespace.
|
||||
|
||||
## Sintaxis
|
||||
|
||||
```
|
||||
/commands # listado completo agrupado por namespace
|
||||
/commands <substring> # filtra por substring en nombre o descripcion
|
||||
/commands --ns <namespace> # solo un namespace (global, aurgi, ...)
|
||||
/commands --json # salida JSON para agentes
|
||||
```
|
||||
|
||||
## Implementacion
|
||||
|
||||
Bash + awk. Parsea frontmatter `description:` de cada `.md`. Agrupa por subdirectorio (subdir = namespace, root = `global`).
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="${FN_REGISTRY_ROOT:-/home/egutierrez/fn_registry}"
|
||||
CMD_DIR="$ROOT/.claude/commands"
|
||||
|
||||
# Recolecta: ns|name|description
|
||||
collect() {
|
||||
find -L "$CMD_DIR" -type f -name '*.md' | while read -r f; do
|
||||
rel="${f#$CMD_DIR/}"
|
||||
case "$rel" in
|
||||
*/*) ns="${rel%%/*}"; name="${rel#*/}"; name="${name%.md}" ;;
|
||||
*) ns="global"; name="${rel%.md}" ;;
|
||||
esac
|
||||
desc=$(awk '/^description:/ {sub(/^description:[[:space:]]*/, ""); gsub(/^"|"$/, ""); print; exit}' "$f")
|
||||
printf '%s|%s|%s\n' "$ns" "$name" "${desc:-(sin descripcion)}"
|
||||
done | sort
|
||||
}
|
||||
|
||||
collect | awk -F'|' '
|
||||
{
|
||||
if ($1 != prev_ns) {
|
||||
if (prev_ns) print ""
|
||||
if ($1 == "global") print "## global (/<cmd>)"
|
||||
else print "## " $1 " (/" $1 ":<cmd>)"
|
||||
prev_ns = $1
|
||||
}
|
||||
printf "- /%s%s — %s\n", ($1=="global"?"":$1":"), $2, $3
|
||||
}'
|
||||
```
|
||||
|
||||
Filtros:
|
||||
|
||||
- Substring: `grep -i "<substring>"` sobre stdout.
|
||||
- `--ns X`: filtrar antes del `awk` por `$1 == "X"`.
|
||||
- `--json`: reemplazar el `awk` por `jq -Rsn` que construya array `{namespace, name, description, invocation}`.
|
||||
|
||||
## Salida (formato humano)
|
||||
|
||||
```
|
||||
## global (/<cmd>)
|
||||
- /app — Crear, configurar y desplegar apps del registry
|
||||
- /autopilot — Modo full-auto...
|
||||
- /commands — Catalogo de slash commands del repo
|
||||
...
|
||||
|
||||
## aurgi (/aurgi:<cmd>)
|
||||
- /aurgi:anadir_contexto_aurgi — Anade o modifica contexto...
|
||||
- /aurgi:aumentar_task — Enriquece tarea Aurgi con preguntas...
|
||||
- /aurgi:contexto_aurgi — Aprende el contexto de Aurgi...
|
||||
```
|
||||
|
||||
## Cuando usarlo
|
||||
|
||||
- Sesion nueva: ver de un vistazo que slash commands hay disponibles.
|
||||
- Antes de inventar logica inline: comprobar si ya existe un command.
|
||||
- Auditoria: verificar que los projects exponen sus commands correctamente.
|
||||
- Onboarding: nuevo PC clonado, descubrir capacidades del repo sin abrir N archivos.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Sigue symlinks (`find -L`). Si un symlink apunta a directorio inexistente, devuelve vacio para esa rama — verificar con `ls -L .claude/commands/<ns>/`.
|
||||
- Solo escanea `<root>/.claude/commands/`. Commands user-global en `~/.claude/commands/` NO entran (son personales, fuera del repo).
|
||||
- Namespace = nombre del subdirectorio bajo `.claude/commands/`. Coincide con el project pero no por mecanismo — por convencion. Ver `.claude/rules/project_commands.md`.
|
||||
- Para que un command de project aparezca aqui desde la raiz, hace falta el symlink (`.claude/commands/<project>` -> `../../projects/<project>/.claude/commands`).
|
||||
+22
-59
@@ -1,74 +1,37 @@
|
||||
---
|
||||
description: "Compila app del registry (C++ o Wails Go), copia el .exe a Desktop/apps/<app>/ y relanza en Windows. Wrapper sobre compile_cpp_app o compile_wails_app segun framework declarado en app.md."
|
||||
---
|
||||
# /compile — Compila app C++ y la copia al escritorio de Windows
|
||||
|
||||
# /compile — Compila app C++ o Wails y la copia al escritorio de Windows
|
||||
|
||||
Wrapper sobre 2 pipelines del registry segun el framework:
|
||||
|
||||
- **C++ (imgui / cmake)** → `compile_cpp_app_bash_pipelines`. Cross-compile MinGW + assets/enrichers/runtime + taskkill, NO relanza.
|
||||
- **Wails Go (matrix_client_pc, matrix_admin_panel, etc.)** → `compile_wails_app_bash_pipelines`. `wails build -platform windows/amd64` con `-tags goolm` si E2EE + taskkill + **RELANZA** la app tras copy.
|
||||
|
||||
Toda la logica vive en el registry (resolver app desde CWD/arg, build, deploy con preservacion de `local_files/`).
|
||||
|
||||
## Dispatch
|
||||
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/fn_registry
|
||||
|
||||
# Detecta framework via wails.json o CMakeLists.txt en el dir del app
|
||||
APP="$ARGUMENTS"
|
||||
RESOLVED=$(bash -c '
|
||||
source bash/functions/infra/resolve_cpp_app_dir.sh
|
||||
resolve_cpp_app_dir "'"$APP"'"
|
||||
' 2>/dev/null) || true
|
||||
APP_DIR="$(echo "$RESOLVED" | cut -f2)"
|
||||
|
||||
if [ -n "$APP_DIR" ] && [ -f "$APP_DIR/wails.json" ]; then
|
||||
./fn run compile_wails_app "$ARGUMENTS"
|
||||
elif [ -n "$APP_DIR" ] && [ -f "$APP_DIR/CMakeLists.txt" ]; then
|
||||
./fn run compile_cpp_app "$ARGUMENTS"
|
||||
else
|
||||
echo "ERROR: no se detecto framework (falta wails.json o CMakeLists.txt en $APP_DIR)" >&2
|
||||
exit 1
|
||||
fi
|
||||
cd /home/lucas/fn_registry
|
||||
./fn run compile_cpp_app "$ARGUMENTS"
|
||||
```
|
||||
|
||||
## Argumento
|
||||
|
||||
`$ARGUMENTS` — opcional. Nombre de app (ej: `chart_demo`, `matrix_client_pc`).
|
||||
`$ARGUMENTS` — opcional. Nombre de app (ej: `chart_demo`).
|
||||
|
||||
- Sin argumento: deduce desde `pwd` si estas dentro de `cpp/apps/<X>/`, `apps/<X>/` o `projects/*/apps/<X>/`.
|
||||
- Si no se puede deducir y no se pasa argumento, lista las apps disponibles en stderr y aborta.
|
||||
- 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.
|
||||
|
||||
## Que hace el pipeline (C++)
|
||||
## Qué hace el pipeline
|
||||
|
||||
1. `resolve_cpp_app_dir_bash_infra` — resuelve `<app_name>` y `<dir absoluto>`.
|
||||
2. Verifica `CMakeLists.txt`.
|
||||
3. `build_cpp_windows_bash_infra <app>` — cross-compila con MinGW.
|
||||
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`.
|
||||
- Copia `<app>.exe` + DLLs.
|
||||
- rsync `assets/`, `enrichers/`, `runtime/` (si aplica).
|
||||
- Preserva `local_files/`.
|
||||
- **NO** relanza.
|
||||
|
||||
## Que hace el pipeline (Wails)
|
||||
|
||||
1. `resolve_cpp_app_dir_bash_infra` (reusado — sirve para Wails apps tambien).
|
||||
2. Verifica `wails.json` + `go.mod`.
|
||||
3. Detecta `-tags goolm` automaticamente (grep `matrix_crypto_init` en `app.md` o `build:tags` en `wails.json`).
|
||||
4. `wails build -platform windows/amd64 [-tags goolm]`.
|
||||
5. `deploy_wails_exe_to_windows_bash_infra <app> <dir>`:
|
||||
- `taskkill.exe /IM <app>.exe /F`.
|
||||
- Copia `<app>.exe` (+ `appicon.ico` si existe).
|
||||
- **Relanza** via `cmd.exe /c start "" <app>.exe`.
|
||||
- Preserva `local_files/`.
|
||||
- `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. Linux ya lo da `wails build` / `cpp/build/` nativo.
|
||||
- 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 C++ no esta registrada en `cpp/CMakeLists.txt`, el build falla — registrar siguiendo `.claude/rules/cpp_apps.md` §5.
|
||||
- Si la app Wails falla build con `no required module provides package`, correr `go mod tidy` en el dir del app primero.
|
||||
- Para tocar la logica: editar `bash/functions/{infra,pipelines}/{resolve_cpp_app_dir,build_cpp_windows,deploy_{cpp,wails}_exe_to_windows,compile_{cpp,wails}_app}.sh`, no este wrapper.
|
||||
- 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.
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
# /cpp-app — Crear o modificar app C++ del registry sin olvidar nada
|
||||
|
||||
Recopila TODOS los datos necesarios (frontmatter, trio app_hub, panels, AppConfig, service block, e2e_checks, uses_functions) **antes** de tocar el disco. Tras confirmar, ejecuta scaffolder o edits, regenera iconos, refresca app_hub, compila y deploya a Windows.
|
||||
|
||||
Sustituye al flujo manual "edito main.cpp + app.md + CMakeLists.txt a mano". Wrapper sobre `init_cpp_app_bash_pipelines` (create) o edits directos sobre `app.md` (modify) + `regenerate_app_icons` + `refresh_app_hub` + `redeploy_cpp_app_windows`.
|
||||
|
||||
---
|
||||
|
||||
## Uso
|
||||
|
||||
```
|
||||
/cpp-app # interactivo, modo create
|
||||
/cpp-app <name> # interactivo, modo create con name pre-rellenado
|
||||
/cpp-app modify <name> # editar app existente
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Modo CREATE — flujo turno a turno
|
||||
|
||||
Si `$ARGUMENTS` no empieza por `modify`, es create. Si trae `<name>`, lo usas como default; si no, pregunta name.
|
||||
|
||||
### Paso 0 — verificar que no existe
|
||||
|
||||
```bash
|
||||
test -d "$HOME/fn_registry/apps/<name>" \
|
||||
|| ls $HOME/fn_registry/projects/*/apps/<name> 2>/dev/null
|
||||
```
|
||||
|
||||
Si existe en cualquier ubicacion: **abortar** y sugerir `/cpp-app modify <name>`. NO sobreescribir.
|
||||
|
||||
### Paso 1 — Identidad (AskUserQuestion)
|
||||
|
||||
1. **name** (texto libre — valida snake_case + contiene verbo segun `ids_naming.md`). Verbos canonicos: `show, render, view, plot, edit, manage, monitor, browse, explore, run, launch, scan, audit, debug, profile, ...`. Si no trae verbo, sugerir alternativas (`viewer` -> `<name>_viewer`).
|
||||
2. **project** (select: ninguno / lista de `projects/*/`). Si ninguno -> `apps/<name>/`.
|
||||
3. **domain** (select: `tools` (default), `gfx`, `tui`, `infra`, `finance`, `datascience`, `cybersecurity`, `shell`, `pipelines`, `browser`).
|
||||
4. **description** 1 linea (texto libre, max 80 chars). **OBLIGATORIO** — sin esto el hub muestra tarjeta vacia.
|
||||
|
||||
### Paso 2 — Trio app_hub OBLIGATORIO
|
||||
|
||||
Regla dura `cpp_apps.md`: description + icon.phosphor + icon.accent SIEMPRE juntos.
|
||||
|
||||
5. **icon.phosphor** glyph name. Antes de preguntar, ofrece busqueda:
|
||||
```bash
|
||||
ls $HOME/fn_registry/sources/phosphor-core/assets/fill/ | grep -i "<keyword>"
|
||||
```
|
||||
Sugiere 3-5 candidatos basados en `description`. Default segun domain: `gfx`->`palette`, `tui`->`terminal`, `tools`->`wrench`, `infra`->`gear`, `finance`->`chart-line-up`, `datascience`->`graph`, `cybersecurity`->`shield`.
|
||||
6. **icon.accent** hex `#rrggbb` (palette select):
|
||||
- sky `#0ea5e9`, indigo `#4f46e5`, violet `#7c3aed`, pink `#ec4899`, rose `#f43f5e`, red `#dc2626`, orange `#ea580c`, amber `#d97706`, green `#16a34a`, teal `#0d9488`, cyan `#0891b2`, slate `#475569`. Default segun domain.
|
||||
|
||||
### Paso 3 — Tags
|
||||
|
||||
7. **tags** (multiSelect): `service`, `launcher`, `dashboard`, `viewer`, `editor`, `monitor`, `debug`, `prototype`. Si selecciona `service` -> activar bloque service (Paso 7).
|
||||
|
||||
### Paso 4 — Panels iniciales
|
||||
|
||||
8. **panels** (texto libre o select):
|
||||
- Default: 1 panel `Main` (Ctrl+1).
|
||||
- Opcion lista: hasta 4 paneles. Por cada uno: `{label, shortcut}`. Generara `PanelToggle k_panels[]` en `main.cpp`.
|
||||
|
||||
### Paso 5 — AppConfig flags
|
||||
|
||||
9. (multiSelect):
|
||||
- `init_gl_loader` (true si la app llama `gl*` directo, ej. shaders, GPU renderer custom). Default false.
|
||||
- `viewports` true (default) / false (single-window).
|
||||
- `auto_dockspace` true (default) / false (solo si gestiona DockSpace propio tipo `shaders_lab`).
|
||||
- `fps_overlay` activo de inicio? (controla solo el default; el menu Settings lo toggle).
|
||||
|
||||
### Paso 6 — Funciones del registry a usar
|
||||
|
||||
10. **uses_functions** lista IDs. Antes de preguntar, busca candidatas segun description:
|
||||
```
|
||||
mcp__registry__fn_search query="<keyword>" entity="functions"
|
||||
```
|
||||
Y muestra capability groups relevantes (`docs/capabilities/INDEX.md`). El usuario puede aceptar lista, anadir IDs, o dejar vacio (se rellena tras codear).
|
||||
|
||||
Cada ID que no este en el registry -> ofrecer spawn `fn-constructor` antes de continuar (regla `delegation.md`).
|
||||
|
||||
### Paso 7 — Bloque `service:` (solo si tag=service)
|
||||
|
||||
11. Si paso 3 marco `service`, recopilar (regla `function_tags.md` + issue 0105):
|
||||
- `port` int o null
|
||||
- `health_endpoint` ruta GET o null
|
||||
- `health_timeout_s` (default 3)
|
||||
- `runtime` (select: `systemd-user`, `systemd-system`, `docker-compose`, `stdio`, `manual`)
|
||||
- `systemd_unit` (obligatorio si runtime empieza por `systemd-`)
|
||||
- `systemd_scope` (`user|system|null`)
|
||||
- `restart_policy` (select: `always` (Recommended — gotcha: `on-failure` NO reinicia SIGTERM limpio), `on-failure`, `none`)
|
||||
- `pc_targets` (multiSelect de pc_locations actuales: `aurgi-pc`, `home-wsl`, ...)
|
||||
- `is_local_only` (true/false default false)
|
||||
|
||||
### Paso 8 — Persistencia
|
||||
|
||||
12. (multiSelect):
|
||||
- BD propia SQLite `<name>.db` en `local_files/`? -> recordar usar `fn::local_path("<name>.db")` (cpp_apps.md §7)
|
||||
- operations.db (para entities/relations)? -> ejecutar `fn ops init` tras crear
|
||||
- Archivos config en `local_files/`?
|
||||
|
||||
### Paso 9 — e2e_checks (issue 0068)
|
||||
|
||||
13. Default sugerido (modificable):
|
||||
```yaml
|
||||
e2e_checks:
|
||||
- id: build
|
||||
cmd: "cmake --build cpp/build --target <name> -j"
|
||||
timeout_s: 300
|
||||
- id: self_test
|
||||
cmd: "./cpp/build/apps/<name>/<name> --self-test"
|
||||
timeout_s: 30
|
||||
severity: warning # si todavia no implementa --self-test
|
||||
```
|
||||
Pregunta: ¿anadir mas checks (ops_audit, pytest, smoke)?
|
||||
|
||||
### Paso 10 — Resumen y confirmacion
|
||||
|
||||
Mostrar bloque YAML completo del `app.md` que se va a generar + flags del scaffolder + post-acciones. Pedir confirmacion antes de ejecutar.
|
||||
|
||||
---
|
||||
|
||||
## Modo CREATE — ejecucion
|
||||
|
||||
Una vez confirmado:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
|
||||
# 1. Scaffolder
|
||||
./fn run init_cpp_app <name> \
|
||||
[--project <p>] \
|
||||
[--domain <d>] \
|
||||
--desc "<description>" \
|
||||
[--tags "<csv>"]
|
||||
|
||||
# 2. Editar app.md generado para anadir:
|
||||
# - icon: {phosphor, accent}
|
||||
# - service: {...} (si aplica)
|
||||
# - uses_functions: [...]
|
||||
# - e2e_checks: [...]
|
||||
# (el scaffolder no rellena estos; editarlos con Edit tool)
|
||||
|
||||
# 3. Editar main.cpp generado para reflejar:
|
||||
# - panels[] custom (si != default)
|
||||
# - cfg.init_gl_loader / cfg.auto_dockspace / cfg.viewports
|
||||
# - includes de funciones registry usadas
|
||||
|
||||
# 4. Editar CMakeLists.txt para anadir paths de funciones del registry:
|
||||
# ${CMAKE_SOURCE_DIR}/functions/<d>/<f>.cpp
|
||||
|
||||
# 5. Si es service -> ofrecer crear systemd unit (skipear si runtime=stdio|manual)
|
||||
|
||||
# 6. Si pidio operations.db
|
||||
./fn ops init apps/<name> # o projects/<p>/apps/<name>
|
||||
|
||||
# 7. Generar icono
|
||||
./fn run generate_app_icon "<phosphor>" "<accent>" "<dir>/appicon.ico"
|
||||
|
||||
# 8. Indexar
|
||||
./fn index
|
||||
|
||||
# 9. Compilar Windows
|
||||
./fn run redeploy_cpp_app_windows <name> <dir> --build
|
||||
|
||||
# 10. Refrescar app_hub
|
||||
./fn run refresh_app_hub
|
||||
|
||||
# 11. Auditoria
|
||||
./fn doctor cpp-apps
|
||||
[[ "<tag>" == *service* ]] && ./fn doctor services-spec
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Modo MODIFY — flujo
|
||||
|
||||
`/cpp-app modify <name>`
|
||||
|
||||
### Paso 0 — Localizar
|
||||
|
||||
```bash
|
||||
# Buscar apps/<name>/ o projects/*/apps/<name>/
|
||||
sqlite3 $HOME/fn_registry/registry.db \
|
||||
"SELECT id, dir_path FROM apps WHERE name='<name>' AND lang='cpp';"
|
||||
```
|
||||
|
||||
Si no existe: abortar, sugerir `/cpp-app` (sin args) para crear.
|
||||
|
||||
### Paso 1 — Mostrar config actual
|
||||
|
||||
```bash
|
||||
mcp__registry__fn_show id="<id>"
|
||||
cat <dir>/app.md
|
||||
```
|
||||
|
||||
### Paso 2 — Que cambiar (multiSelect)
|
||||
|
||||
- `description` (1 linea)
|
||||
- `icon.phosphor` o `icon.accent`
|
||||
- `tags` (anadir/quitar; si toca `service` -> Paso 7 del create)
|
||||
- `uses_functions` (anadir/quitar — recordar editar CMakeLists.txt)
|
||||
- `panels` (anadir/quitar/renombrar)
|
||||
- `service:` block (si tag=service)
|
||||
- `e2e_checks`
|
||||
- `domain`
|
||||
- `rename` (cambia name, dir, IDs derivados, repo Gitea — operacion delicada, requiere doble confirmacion)
|
||||
|
||||
### Paso 3 — Aplicar cambios
|
||||
|
||||
Para cada cambio: usa `Edit` sobre los archivos correspondientes. NUNCA `Write` completo de `app.md` (preserva campos que no toques).
|
||||
|
||||
### Paso 4 — Post-acciones (segun lo que toco)
|
||||
|
||||
```bash
|
||||
# Siempre
|
||||
cd $HOME/fn_registry && ./fn index
|
||||
|
||||
# Si toco icon.* -> regenerar appicon
|
||||
./fn run generate_app_icon "<phosphor>" "<accent>" "<dir>/appicon.ico"
|
||||
|
||||
# Si toco trio o panels o uses_functions o cambia code:
|
||||
./fn run redeploy_cpp_app_windows <name> <dir> --build
|
||||
|
||||
# Si toco description o icon o tags:
|
||||
./fn run refresh_app_hub
|
||||
|
||||
# Si toco service: o tag service
|
||||
./fn doctor services-spec
|
||||
|
||||
# Siempre al final
|
||||
./fn doctor cpp-apps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reglas duras
|
||||
|
||||
- **NUNCA** crear `main.cpp` + `CMakeLists.txt` + `app.md` a mano. Siempre via `init_cpp_app_bash_pipelines` (regla `cpp_apps.md`).
|
||||
- **NUNCA** poner el codigo en `cpp/apps/<n>/`. Solo `apps/<n>/` o `projects/<p>/apps/<n>/`.
|
||||
- **NUNCA** dejar `app.md` sin el trio (description + icon.phosphor + icon.accent). Tarjeta del hub queda gris.
|
||||
- **NUNCA** declarar funciones del registry en `uses_functions` sin listar su `.cpp` en `CMakeLists.txt` (drift detectado por `fn doctor uses-functions`).
|
||||
- **NUNCA** usar `Restart=on-failure` en systemd unit de un service C++ — gotcha 2026-05-17 (`sqlite_api.service` cayo 20h). Default `Restart=always`.
|
||||
- Despues de **cualquier** cambio en el trio: `regenerate_app_icons <name>` + `refresh_app_hub`.
|
||||
|
||||
---
|
||||
|
||||
## Auto-verificacion final
|
||||
|
||||
Tras crear o modificar, reportar al usuario:
|
||||
|
||||
```
|
||||
=== app <name> ===
|
||||
dir: <abs_dir>
|
||||
domain: <d>
|
||||
description: "<desc>"
|
||||
icon: <phosphor> + <accent>
|
||||
tags: [<csv>]
|
||||
uses_functions: N funciones (<list_top_5>)
|
||||
panels: N (<labels>)
|
||||
e2e_checks: N checks
|
||||
service: <si/no — port:<p> health:<h>>
|
||||
|
||||
Acciones ejecutadas:
|
||||
[✓] scaffolder / edits
|
||||
[✓] generate_app_icon
|
||||
[✓] fn index (registry.db actualizado)
|
||||
[✓] redeploy_cpp_app_windows (Desktop/apps/<name>/<name>.exe)
|
||||
[✓] refresh_app_hub (tarjeta visible en hub)
|
||||
[✓] fn doctor cpp-apps (limpio | N warnings)
|
||||
|
||||
Siguiente paso sugerido:
|
||||
- Abrir app_hub_launcher en Windows y verificar tarjeta
|
||||
- Anadir tests visuales si la app tiene paneles propios (cpp/PATTERNS.md §11)
|
||||
```
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -38,19 +38,19 @@ Consultar `registry.db` para encontrar funciones existentes relevantes y evitar
|
||||
|
||||
```bash
|
||||
# Buscar funciones similares por nombre y descripcion (OBLIGATORIO — usar multiples terminos)
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, lang, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO1* OR description:TERMINO1* OR name:TERMINO2* OR description:TERMINO2*') ORDER BY name;"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, lang, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO1* OR description:TERMINO1* OR name:TERMINO2* OR description:TERMINO2*') ORDER BY name;"
|
||||
|
||||
# Buscar tipos relacionados
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, algebraic, lang, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, lang, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
|
||||
# Funciones del dominio objetivo
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, signature, description FROM functions WHERE domain = 'DOMINIO' AND lang = 'LANG' ORDER BY name;"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, signature, description FROM functions WHERE domain = 'DOMINIO' AND lang = 'LANG' ORDER BY name;"
|
||||
|
||||
# Tipos del dominio objetivo
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'DOMINIO' ORDER BY name;"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'DOMINIO' ORDER BY name;"
|
||||
|
||||
# Funciones que podrian componerse (misma firma de retorno)
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, purity, signature FROM functions WHERE returns LIKE '%TIPO%' OR signature LIKE '%TIPO%' ORDER BY name;"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, purity, signature FROM functions WHERE returns LIKE '%TIPO%' OR signature LIKE '%TIPO%' ORDER BY name;"
|
||||
```
|
||||
|
||||
**Clasificar resultados en:**
|
||||
@@ -103,7 +103,7 @@ Para cada batch del plan, lanzar agentes `fn-constructor` **en paralelo** (un ag
|
||||
Usar el Agent tool con `subagent_type: "fn-constructor"` pasando un prompt completo con:
|
||||
|
||||
```
|
||||
Crea la siguiente funcion para el registry fn_registry en $HOME/fn_registry:
|
||||
Crea la siguiente funcion para el registry fn_registry en /home/lucas/fn_registry:
|
||||
|
||||
Funcion: {nombre}
|
||||
Kind: {kind}
|
||||
@@ -149,7 +149,7 @@ Despues de que TODOS los fn-constructor terminen:
|
||||
|
||||
```bash
|
||||
# Indexar todo de una vez
|
||||
cd $HOME/fn_registry && ./fn index
|
||||
cd /home/lucas/fn_registry && ./fn index
|
||||
```
|
||||
|
||||
Si el indexer reporta errores, corregirlos antes de continuar. Errores comunes:
|
||||
@@ -166,7 +166,7 @@ Si el indexer reporta errores, corregirlos antes de continuar. Errores comunes:
|
||||
|
||||
```bash
|
||||
# Verificar cada funcion creada
|
||||
cd $HOME/fn_registry
|
||||
cd /home/lucas/fn_registry
|
||||
./fn show {id_de_cada_funcion}
|
||||
|
||||
# Verificar que no hay funciones sin params_schema
|
||||
@@ -178,7 +178,7 @@ cd $HOME/fn_registry
|
||||
Para cada funcion con tests, ejecutar:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
cd /home/lucas/fn_registry
|
||||
|
||||
# Go
|
||||
CGO_ENABLED=1 go test -tags fts5 -v -run TestNombreDelTest ./functions/{domain}/
|
||||
@@ -197,13 +197,13 @@ bash bash/functions/{domain}/{nombre}_test.sh
|
||||
|
||||
```bash
|
||||
# Verificar que todas las funciones nuevas estan en la BD
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, tested FROM functions WHERE id IN ('id1','id2','id3') ORDER BY name;"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, tested FROM functions WHERE id IN ('id1','id2','id3') ORDER BY name;"
|
||||
|
||||
# Verificar que los tests estan indexados
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, function_id, name FROM unit_tests WHERE function_id IN ('id1','id2','id3') ORDER BY function_id;"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, function_id, name FROM unit_tests WHERE function_id IN ('id1','id2','id3') ORDER BY function_id;"
|
||||
|
||||
# Verificar dependencias
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, uses_functions, uses_types FROM functions WHERE id IN ('id1','id2','id3') AND uses_functions != '[]';"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, uses_functions, uses_types FROM functions WHERE id IN ('id1','id2','id3') AND uses_functions != '[]';"
|
||||
```
|
||||
|
||||
### 6.4 Si algo fallo
|
||||
|
||||
@@ -45,7 +45,7 @@ Antes de escribir nada, repasar la conversacion y juntar:
|
||||
|
||||
2. **Cambios concretos** desde git:
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
cd /home/lucas/fn_registry
|
||||
git status --short
|
||||
git diff --stat
|
||||
git log --since="6 hours ago" --oneline
|
||||
@@ -70,7 +70,7 @@ Si el material es solo conversacion exploratoria sin artefactos tocados, ir dire
|
||||
Para cada artefacto identificado, localizar su `.md` consultando `registry.db`:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
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*');"
|
||||
@@ -180,7 +180,7 @@ Para cada `.md` identificado:
|
||||
Si los cambios de la sesion incluyen creacion de funciones/tipos/apps/projects/analysis/vaults o modificacion de frontmatter:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry && ./fn index
|
||||
cd /home/lucas/fn_registry && ./fn index
|
||||
```
|
||||
|
||||
Y verificar:
|
||||
|
||||
@@ -17,7 +17,7 @@ Suite ya instalada en `cpp/vendor/imgui_test_engine/`. Integracion en framework:
|
||||
### 1. Resolver app y directorio
|
||||
|
||||
```bash
|
||||
ROOT=$HOME/fn_registry
|
||||
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)
|
||||
@@ -173,39 +173,23 @@ Si el build falla:
|
||||
- "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 preferente — sin parpadeo)
|
||||
### 8. Ejecutar (headless en WSL)
|
||||
|
||||
`fn::run_app_test` crea la ventana GLFW **oculta por defecto** (`GLFW_VISIBLE=FALSE`, ver `cpp/framework/app_base.cpp`). El contexto GL real se crea igual, así que el render que ejercita el Test Engine es fiel, pero la ventana nunca se mapea en pantalla: cero parpadeo, no roba foco. Por eso los tests de frontend C++ corren headless por defecto, sin tocar el código de cada app.
|
||||
|
||||
Dos formas de lanzar, según el entorno:
|
||||
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; }
|
||||
|
||||
if [ -n "$DISPLAY" ] && command -v glxinfo >/dev/null 2>&1 \
|
||||
&& glxinfo 2>/dev/null | grep -q "OpenGL core profile version"; then
|
||||
# Host con GL nativo (PC enmanuel, X11 + GPU): binario directo.
|
||||
# La ventana ya nace oculta -> sin parpadeo, y usa la GPU real (rapido).
|
||||
timeout 90 "$TEST_BIN" 2>&1
|
||||
else
|
||||
# CI / WSL sin GLX 4.3 nativo: display virtual en RAM + software Mesa.
|
||||
timeout 90 xvfb-run -a -s "-screen 0 1280x800x24" \
|
||||
env LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe \
|
||||
"$TEST_BIN" 2>&1
|
||||
fi
|
||||
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"
|
||||
```
|
||||
|
||||
Ambas vías son headless. `xvfb-run` sigue siendo seguro en host con display (corre en su propio display virtual), así que si el sniff de GL falla puedes usar siempre la rama xvfb.
|
||||
|
||||
**Para depurar un test a ojo** (ver la UI mientras el engine la maneja), desactiva el headless con `FN_HEADLESS=0`:
|
||||
|
||||
```bash
|
||||
FN_HEADLESS=0 timeout 90 "$TEST_BIN" 2>&1
|
||||
```
|
||||
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
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ Wrapper sobre `append_diary_entry_bash_infra`. La función del registry maneja t
|
||||
|
||||
2. **Llamar la función del registry**:
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
cd /home/lucas/fn_registry
|
||||
source bash/functions/infra/append_diary_entry.sh
|
||||
append_diary_entry "<TITULO>" "$(cat <<'EOF'
|
||||
<CUERPO>
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
---
|
||||
name: fix-issue
|
||||
description: Implementar un issue de dev/issues/ end-to-end. Crea rama, ejecuta tareas, bumpa version si toca modulos/framework/apps (via /version), tests, cierra issue, integra a master.
|
||||
---
|
||||
|
||||
# /fix-issue
|
||||
|
||||
Ejecuta el flujo completo de implementacion/cierre de un issue de `dev/issues/`. Adaptado al stack del registry: Go (`-tags fts5 CGO_ENABLED=1`), Python (`python/.venv/bin/python3`), Bash, TypeScript (`pnpm`), C++ (`cmake`+`mingw-w64` toolchain).
|
||||
|
||||
## Inputs
|
||||
|
||||
```
|
||||
/fix-issue <NNNN[a|b|c...]>
|
||||
```
|
||||
|
||||
- `NNNN`: numero del issue (ej. `0107`).
|
||||
- Si es sub-issue, sufijo letra: `0107a`, `0107b`, ...
|
||||
|
||||
Si no se proporciona, preguntar.
|
||||
|
||||
## Flujo obligatorio
|
||||
|
||||
### 1. Resolver el issue
|
||||
|
||||
- `dev/issues/<NNNN>-*.md` → si no existe, STOP.
|
||||
- Si ya en `dev/issues/completed/`, STOP.
|
||||
- Si es sub-issue, leer tambien el principal para contexto.
|
||||
|
||||
### 2. Leer y extraer
|
||||
|
||||
- Objetivo, tareas, arquitectura, prerequisitos, riesgos.
|
||||
- Identificar archivos afectados — anotar si toca:
|
||||
- `modules/<X>/` o `cpp/framework/` → bumpa version (paso 8).
|
||||
- `functions/`, `python/functions/`, `bash/functions/`, `frontend/functions/` → indexer + `fn index` al cerrar.
|
||||
- Apps en `apps/<X>/` o `projects/*/apps/<X>/` → requiere rama TBD (regla `apps_tbd.md`) **+ bumpa version per-app (paso 8)**. Si el issue toca multiples apps, una llamada `/version` por app.
|
||||
- Registry meta (CLAUDE.md, rules, templates) → push directo a master OK.
|
||||
|
||||
### 3. Estrategia de rama
|
||||
|
||||
**Registry-only changes** (functions/types/docs/rules):
|
||||
- Push directo a master OK. NO crear rama.
|
||||
|
||||
**Apps changes** (apps/, projects/*/apps/):
|
||||
- Crear rama TBD:
|
||||
```bash
|
||||
git checkout master
|
||||
git pull --rebase
|
||||
git checkout -b issue/<NNNN>-<slug>
|
||||
```
|
||||
La rama es del registry. Si la app es sub-repo, ademas crear rama dentro del sub-repo.
|
||||
|
||||
**Modules/framework changes** (`modules/`, `cpp/framework/`):
|
||||
- Rama TBD obligatoria (afecta a todas las apps que linkean).
|
||||
|
||||
### 4. Plan con TaskCreate
|
||||
|
||||
- Crear tarea por bloque logico del issue.
|
||||
- Incluir SIEMPRE:
|
||||
- Tarea de tests (unit + smoke).
|
||||
- Tarea de `fn index` si toco metadata.
|
||||
- Tarea de `/version` si toco `modules/`, `cpp/framework/`, `apps/<X>/` o `projects/*/apps/<X>/` (una llamada por target).
|
||||
- Tarea de cleanup/docs.
|
||||
|
||||
### 5. Implementar
|
||||
|
||||
Reglas registry-first (CLAUDE.md):
|
||||
- ANTES de escribir codigo reutilizable → `mcp__registry__fn_search` para encontrar lo que existe.
|
||||
- Si falta funcion reutilizable → spawn `fn-constructor` (no escribir inline).
|
||||
- Si patron se repite >2x → propose nueva funcion.
|
||||
- NUNCA `sqlite3 registry.db "SELECT ..."` plano — usar MCP.
|
||||
|
||||
Convenciones del stack:
|
||||
|
||||
| Stack | Build/test |
|
||||
|---|---|
|
||||
| Go | `CGO_ENABLED=1 go build -tags fts5 -o fn ./cmd/fn/` y `CGO_ENABLED=1 go test -tags fts5 ./...` |
|
||||
| Python | `python/.venv/bin/python3 -m pytest <path>` |
|
||||
| Bash | `bash -n <script>.sh` + tests inline |
|
||||
| TypeScript | `cd frontend && pnpm build && pnpm test` |
|
||||
| C++ (Linux) | `cmake --build build --target <app>` |
|
||||
| C++ (Windows MinGW) | `cmake -B build/windows -DCMAKE_TOOLCHAIN_FILE=cpp/toolchains/mingw-w64.cmake && cmake --build build/windows --target <app>` |
|
||||
|
||||
Commits atomicos por bloque logico con prefijos: `feat:`, `fix:`, `test:`, `docs:`, `refactor:`, `chore:`. Mensajes en espanol. NO WIP.
|
||||
|
||||
### 6. Tests
|
||||
|
||||
Stack-dependent (ver arriba). Si tests pasan parcialmente con failures pre-existentes no causadas por la rama, documentar en cuerpo del commit/PR.
|
||||
|
||||
### 7. Feature flags (si aplica)
|
||||
|
||||
Si el issue forma parte de un feature multi-issue:
|
||||
- Editar `dev/feature_flags.json` con el flag (desactivado).
|
||||
- Activar el flag en el ultimo sub-issue del set.
|
||||
|
||||
Flag != WIP. Codigo detras de flag debe compilar + testear.
|
||||
|
||||
### 8. Version bump (si toco modulos/framework/apps)
|
||||
|
||||
**OBLIGATORIO si el issue toco** alguno de:
|
||||
- `modules/<X>/` → bumpa `modules/<X>/module.md::version`.
|
||||
- `cpp/framework/` → bumpa `modules/framework/module.md::version`.
|
||||
- `apps/<X>/` → bumpa `apps/<X>/app.md::version`.
|
||||
- `projects/<P>/apps/<X>/` → bumpa `projects/<P>/apps/<X>/app.md::version`.
|
||||
|
||||
```
|
||||
/version <path> <major|minor|patch> "<reason>"
|
||||
```
|
||||
|
||||
Reglas (modulos/framework):
|
||||
- Major: breaking ABI/API publica.
|
||||
- Minor: additive (nuevo helper, refactor interno sin cambio de API, nuevo miembro).
|
||||
- Patch: bugfix puro.
|
||||
|
||||
Reglas (apps):
|
||||
- Major: breaking observable (CLI args, schema BBDD propia, formato wire).
|
||||
- Minor: feature aditiva visible (nuevo panel, endpoint, opcion).
|
||||
- Patch: bugfix sin cambio observable, refactor interno, mejora perf.
|
||||
|
||||
**Una llamada `/version` por target afectado**. Si el issue toca 1 modulo + 2 apps -> 3 llamadas a `/version` (cada una con su `reason` y bump-type apropiado; pueden diferir).
|
||||
|
||||
Diff guard: cambios que solo tocan el `app.md` (correccion typo descripcion, anadir tag) NO requieren bump — son metadata, no comportamiento. Detectar con `git diff --name-only | grep -v '\.md$'` para decidir si hay cambio de codigo real.
|
||||
|
||||
`/version` solo edita + stage. NO commit. El bump va junto con el codigo correspondiente en el mismo commit (`feat:` o `fix:` o `refactor:`).
|
||||
|
||||
Si NO toco modulos/framework/apps, saltar este paso.
|
||||
|
||||
### 9. Cerrar el issue
|
||||
|
||||
Mover archivo:
|
||||
```bash
|
||||
mv dev/issues/<NNNN>-<slug>.md dev/issues/completed/
|
||||
```
|
||||
|
||||
Actualizar `dev/issues/README.md`:
|
||||
- Link → `completed/<NNNN>-<slug>.md`
|
||||
- Estado → `completado`
|
||||
|
||||
Si es feature multi-issue y este es el ultimo sub-issue:
|
||||
- Flip flag en `dev/feature_flags.json` a `enabled: true` con `enabled_at: <YYYY-MM-DD>`.
|
||||
- Verificar que todos los sub-issues estan en `completed/`.
|
||||
|
||||
### 10. Integrar
|
||||
|
||||
**Registry-only changes**: push directo a master.
|
||||
|
||||
**Apps/modules/framework changes**: `/full-git-push` o `/git-push` (merge --no-ff de la rama a master, push, delete rama).
|
||||
|
||||
### 11. Verificar post-cierre
|
||||
|
||||
- `fn index` — registry.db al dia.
|
||||
- `fn doctor` (subcomandos relevantes: `artefacts`, `services`, `cpp-apps`, `uses-functions`).
|
||||
- Si toco modulos: `fn doctor modules` (post 0107a) — 0 drift.
|
||||
|
||||
## Reglas criticas
|
||||
|
||||
- **Registry-first**: SIEMPRE buscar antes de escribir; delegar a `fn-constructor` antes que inline.
|
||||
- **TBD para apps**: NUNCA push directo a master en apps. Rama corta, merge --no-ff.
|
||||
- **TBD NO para registry**: push directo OK para functions/types/docs/rules.
|
||||
- **`/version` obligatorio** si tocas modulos, framework o apps (con cambio de codigo real, no solo metadata). Si no, drift entre `version:` y `## Capability growth log` y se pierde trazabilidad.
|
||||
- **Tests siempre**: no cerrar issue sin tests pasando (salvo failures pre-existentes documentados).
|
||||
- **Commits atomicos**: 1 commit = 1 bloque logico. No mezclar `feat:` + `test:` en mismo commit.
|
||||
- **Cerrar siempre**: nunca dejar issue implementado sin mover a `completed/` + actualizar README.
|
||||
|
||||
## Referenciado desde
|
||||
|
||||
- `.claude/commands/version.md` — bump semver de modulos.
|
||||
- `.claude/commands/full-git-push.md` — push del registry + sub-repos.
|
||||
- `.claude/rules/apps_tbd.md` — politica de TBD por tipo de cambio.
|
||||
|
||||
## Ejemplo: implementar 0107c (refactor data_table)
|
||||
|
||||
```
|
||||
/fix-issue 0107c
|
||||
|
||||
1. Resolver: dev/issues/0107c-split-data-table.md ✓
|
||||
2. Extraer: refactor 4777 LOC → 6 sub-funciones. Toca modules/ → /version obligatorio.
|
||||
3. Rama: issue/0107c-split-data-table desde master.
|
||||
4. Plan: 8 tareas (lectura + 6 sub-funciones + entrypoint thin + version bump).
|
||||
5. Implementar: spawn fn-constructor en paralelo si hay >1 sub-funcion independiente.
|
||||
6. Tests: build + smoke + primitives_gallery --capture diff.
|
||||
7. Flag: parte de modules-v2, NO activar todavia (espera 0107a-f cerrar).
|
||||
8. /version modules/data_table major "split data_table.cpp into 6 sub-functions"
|
||||
9. Cerrar: mv → completed/ + README.
|
||||
10. /git-push.
|
||||
11. fn index + fn doctor modules → 0 drift en consumidores limpiados.
|
||||
```
|
||||
@@ -1,131 +0,0 @@
|
||||
---
|
||||
description: "Gestiona flows (casos de uso multi-app reutilizables) en dev/flows/. Subcomandos: create, list, show, status, done. Runner automatizado en fase 2."
|
||||
---
|
||||
|
||||
# /flow — Gestionar flows del registry
|
||||
|
||||
Flows = casos de uso end-to-end que prueban / ejercitan el sistema multi-app. Viven en `dev/flows/NNNN-<slug>.md`. Cada flow describe Goal + Flow steps + Acceptance checkboxes + Telemetria.
|
||||
|
||||
**OBLIGATORIO antes de `create`**: lee `dev/flows/AGENT_GUIDE.md`. Define donde buscar piezas (capability groups, FTS por tag, apps existentes, vaults), reglas duras para no inventar IDs, y plantilla de razonamiento para recomendar extractor / transformer / sink / scheduler / notify por flow.
|
||||
|
||||
Cada flow nuevo cita IDs reales del registry. Si una pieza falta, escribir `FALTA: crear <id>` en la tabla correspondiente. Nada de inventar nombres.
|
||||
|
||||
Diferencia con `dev/issues/`:
|
||||
- Issues = bugs / features de implementacion.
|
||||
- Flows = trabajos reutilizables que cruzan varias apps.
|
||||
|
||||
## Sintaxis
|
||||
|
||||
```
|
||||
/flow create <slug> # nuevo flow desde template, ID auto
|
||||
/flow list # tabla resumen
|
||||
/flow show <NNNN> # imprime contenido + acceptance %
|
||||
/flow status <NNNN> # status + acceptance % + ultima run
|
||||
/flow done <NNNN> [--notes "..."] # cierra flow (status=done, mueve a completed/)
|
||||
/flow run <NNNN> # fase 2 — runner automatizado (NO IMPLEMENTADO)
|
||||
```
|
||||
|
||||
## Implementacion por subcomando
|
||||
|
||||
### `create <slug>`
|
||||
|
||||
Pasos:
|
||||
1. Valida `<slug>` es kebab-case: `^[a-z][a-z0-9-]*$`. Si no, error.
|
||||
2. Comprueba que no existe ya: `ls dev/flows/*-<slug>.md`. Si existe, error.
|
||||
3. Calcula siguiente ID libre:
|
||||
- `ls dev/flows/*.md dev/flows/completed/*.md | grep -oE '^dev/flows/(completed/)?[0-9]{4}' | sort -u | tail -1`
|
||||
- Suma 1, zero-pad a 4 digitos.
|
||||
4. Lee `dev/flows/template.md`.
|
||||
5. Sustituye `<slug>`, `NNNN`, `YYYY-MM-DD` (hoy).
|
||||
6. Escribe `dev/flows/NNNN-<slug>.md`.
|
||||
7. Append fila a `dev/flows/INDEX.md` (mantener orden por ID asc).
|
||||
8. Reporta path nuevo + recordatorio "edita Goal / Flow / Acceptance".
|
||||
|
||||
### `list`
|
||||
|
||||
Lee `dev/flows/INDEX.md` y lo imprime tal cual. Si flag `--pending` solo pending, `--done` solo done, `--app <name>` filtra por app.
|
||||
|
||||
Tambien anade columna `Accept%` calculada desde body:
|
||||
- Para cada flow .md, cuenta `[ ]` y `[x]` en seccion `## Acceptance`.
|
||||
- `% = checked / total * 100` redondeo entero.
|
||||
|
||||
### `show <NNNN>`
|
||||
|
||||
`cat dev/flows/NNNN-*.md` (busca con glob NNNN-*). Si no existe, prueba `dev/flows/completed/NNNN-*.md`. Si no, error.
|
||||
|
||||
### `status <NNNN>`
|
||||
|
||||
Imprime resumen del frontmatter + acceptance %:
|
||||
|
||||
```
|
||||
=== flow 0001 ===
|
||||
name: hn-top-stories
|
||||
status: pending
|
||||
risk: low
|
||||
priority: high
|
||||
apps: navegator_dashboard, dag_engine, data_factory, agents_and_robots
|
||||
acceptance: 2/6 (33%)
|
||||
updated: 2026-05-16
|
||||
|
||||
Pending checks:
|
||||
- [ ] Recipe creada y validada
|
||||
- [ ] DAG corre OK 2 veces consecutivas via scheduler
|
||||
- [ ] data_factory.runs tiene >=2 entries
|
||||
- [ ] Schema extraido cubre 6/6 fields
|
||||
```
|
||||
|
||||
### `done <NNNN> [--notes "..."]`
|
||||
|
||||
Pasos:
|
||||
1. Verifica todos los `[ ]` estan checked. Si no, prompt "X checks pendientes, --force para cerrar igualmente".
|
||||
2. Edita frontmatter: `status: done`, `updated: <hoy>`.
|
||||
3. Si `--notes`, append a seccion `## Notas`.
|
||||
4. `git mv dev/flows/NNNN-<slug>.md dev/flows/completed/`.
|
||||
5. Actualiza `dev/flows/INDEX.md`: cambia status del flow + mueve fila a seccion Completed (mantener tabla principal solo con pending/running/failed/deferred).
|
||||
|
||||
### `run <NNNN>` — FASE 2 (NO IMPLEMENTADO AUN)
|
||||
|
||||
Hoy: imprime `/flow run no implementado todavia. Sigue los pasos manualmente y marca acceptance con sed/edit.`
|
||||
|
||||
Diseño futuro:
|
||||
- Parsea `## Flow` en pasos.
|
||||
- Cada paso tipo `function: <id>` -> ejecuta `./fn run <id>`.
|
||||
- Cada paso tipo `cmd: <bash>` -> ejecuta subprocess.
|
||||
- Texto libre -> "MANUAL: <text>" + pause user input.
|
||||
- Persistencia ejecuciones en `dev/flows/runs/<id>-<timestamp>.jsonl`.
|
||||
- Update acceptance checkboxes automaticamente segun heuristics (count runs en data_factory, etc.).
|
||||
|
||||
## Conventions
|
||||
|
||||
- Numeracion 0001+, propia (no comparte con `dev/issues/`).
|
||||
- Status: `pending | running | done | failed | deferred`.
|
||||
- Risk: `low` (publico) | `medium` (auth no sensible) | `high` (datos personales).
|
||||
- Apps listadas en frontmatter — `/flow list --app navegator_dashboard` filtra.
|
||||
- Acceptance es la fuente de verdad del progreso.
|
||||
|
||||
## Output style
|
||||
|
||||
Caveman. Tablas markdown. Sin emojis. Sin verbosidad.
|
||||
|
||||
Errores: 1 linea con el problema + sugerencia.
|
||||
|
||||
## Ejemplos
|
||||
|
||||
```
|
||||
/flow create reddit-sentiment-tracker
|
||||
# crea dev/flows/0008-reddit-sentiment-tracker.md
|
||||
# anade fila a INDEX
|
||||
|
||||
/flow list --pending
|
||||
# muestra solo flows no cerrados
|
||||
|
||||
/flow status 0001
|
||||
# acceptance 0/6, todos los checks pendientes
|
||||
|
||||
# Tras correr el flow manualmente:
|
||||
# editas el .md, marcas [x] los checks completados
|
||||
/flow status 0001
|
||||
# acceptance 6/6
|
||||
/flow done 0001 --notes "smoke pass; LLM tardo 14s; recipe robusta"
|
||||
# mueve a completed/, marca status=done
|
||||
```
|
||||
@@ -50,7 +50,7 @@ Issue 0085 fase autocompleta. Reemplaza el flujo manual de "veo un patron, decid
|
||||
### 1. AUDIT — ¿estoy siendo registrado?
|
||||
|
||||
```bash
|
||||
ROOT="$HOME/fn_registry"
|
||||
ROOT="/home/lucas/fn_registry"
|
||||
MON="$ROOT/projects/fn_monitoring/apps/call_monitor/operations.db"
|
||||
|
||||
# Pre-condiciones
|
||||
@@ -152,20 +152,6 @@ Tambien actualiza `call_monitor.copied_code` + `function_stats` corriendo:
|
||||
cd "$ROOT/projects/fn_monitoring/apps/call_monitor" && ./call_monitor copied-code && ./call_monitor propose
|
||||
```
|
||||
|
||||
### 5b. MEMORIZE — anadir cada funcion nueva a MEMORY.md (issue 0087 pieza 6)
|
||||
|
||||
Por cada funcion creada con exito, llama:
|
||||
|
||||
```bash
|
||||
bash "$ROOT/.claude/scripts/append_fn_to_memory.sh" "<fn_id>" "<one-line purpose>"
|
||||
```
|
||||
|
||||
El script es idempotente (si la fn ya esta linkeada, no duplica). Crea `reference_fn_<id>.md` con metadata `type: reference` e indexa la entrada en `MEMORY.md` como linea `- [fn-<id>](reference_fn_<id>.md) — <purpose>`. Asi proximas sesiones cargan MEMORY.md y ven el catalogo de funciones recien creadas sin segunda lookup.
|
||||
|
||||
`purpose` = 1 frase derivada del `description` del .md de la funcion (max 80 chars). Si description es larga, recorta. Ejemplo:
|
||||
- fn_id: `parse_http_log_go_infra`
|
||||
- purpose: "parsea log Apache/Nginx a struct; pure"
|
||||
|
||||
Reporta:
|
||||
- N funciones nuevas creadas (con IDs)
|
||||
- N proposals nuevas en `registry.db.proposals`
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Wrapper sobre el pipeline `full_git_pull_bash_pipelines`. Toda la lógica vive en el registry. Este comando solo ejecuta:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
cd /home/lucas/fn_registry
|
||||
./fn run full_git_pull_bash_pipelines
|
||||
```
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Wrapper sobre el pipeline `full_git_push_bash_pipelines`. Toda la lógica vive en el registry. Este comando solo ejecuta:
|
||||
|
||||
```bash
|
||||
cd "${FN_REGISTRY_ROOT:-$HOME/fn_registry}"
|
||||
cd /home/lucas/fn_registry
|
||||
./fn run full_git_push_bash_pipelines "$ARGUMENTS"
|
||||
```
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
---
|
||||
description: "Gestiona issues del registry en dev/issues/. Subcomandos: list, show, status, board, dep, roadmap, tag, done, stale, create. Frontmatter YAML canonico (issue 0100)."
|
||||
---
|
||||
|
||||
# /issue — Gestionar issues del registry
|
||||
|
||||
Issues viven en `dev/issues/NNNN-<slug>.md` con frontmatter YAML canonico (id, title, status, type, domain, scope, priority, depends, blocks, related, created, updated, tags).
|
||||
|
||||
Allowlists en `dev/TAXONOMY.md` (no inventar valores).
|
||||
|
||||
Diferencia con `dev/flows/`:
|
||||
- **Issues** = bugs, features, refactors, chores, epics de implementacion.
|
||||
- **Flows** = casos de uso end-to-end multi-app.
|
||||
|
||||
## Sintaxis
|
||||
|
||||
```
|
||||
/issue list [--domain X] [--type Y] [--status Z] [--prio P] [--epic NNNN]
|
||||
/issue show NNNN
|
||||
/issue status NNNN # acceptance % + estado deps
|
||||
/issue board # kanban pendiente/in-progress/bloqueado/done
|
||||
/issue dep NNNN # arbol bloquea/depende
|
||||
/issue roadmap NNNN # epic + sub-IDs (NNNNa, NNNNb, ...)
|
||||
/issue tag NNNN +X -Y # mantenimiento tags/domain
|
||||
/issue done NNNN # mueve a completed/, valida deps
|
||||
/issue stale [--days 30]
|
||||
/issue create <slug> --type T --domain D [--prio P] [--depends NNNN]
|
||||
```
|
||||
|
||||
## Implementacion
|
||||
|
||||
**Fase 1 (manual via Claude):**
|
||||
|
||||
El agente lee `dev/issues/*.md`, parsea frontmatter YAML con `yaml.safe_load`, aplica el filtro, imprime tabla.
|
||||
|
||||
```python
|
||||
import yaml, pathlib, re
|
||||
issues = []
|
||||
for f in pathlib.Path("dev/issues").glob("*.md"):
|
||||
if f.name in {"README.md", "template.md"}: continue
|
||||
txt = f.read_text()
|
||||
m = re.match(r"^---\n(.*?)\n---", txt, re.S)
|
||||
if not m: continue
|
||||
fm = yaml.safe_load(m.group(1)) or {}
|
||||
fm["_path"] = str(f)
|
||||
issues.append(fm)
|
||||
# filter + print
|
||||
```
|
||||
|
||||
**Fase 2 (cuando 0101 dev_console exista):**
|
||||
|
||||
Cada subcomando se mapea a `./apps/dev_console/dev_console issue <subcomando> $ARGS`.
|
||||
|
||||
## Subcomandos clave
|
||||
|
||||
### `list`
|
||||
|
||||
Imprime tabla `id | title | status | type | domain | priority | depends_pending`. Filtrable por flags.
|
||||
|
||||
### `show NNNN`
|
||||
|
||||
Read directo del .md + render del frontmatter como tabla + body como markdown.
|
||||
|
||||
### `status NNNN`
|
||||
|
||||
Cuenta checkboxes en `## Acceptance` + chequea si todos los `depends` estan en `status: completado`. Si alguno no, marca `bloqueado`.
|
||||
|
||||
### `board`
|
||||
|
||||
Tabla 4 columnas (pendiente / in-progress / bloqueado / completado_hoy). Card por issue: id + title + prio. Status `bloqueado` se calcula on-the-fly desde `depends`.
|
||||
|
||||
### `roadmap NNNN`
|
||||
|
||||
Si `type: epic`: lista sub-issues `NNNNa`, `NNNNb`, etc. con su estado. Si no epic: error "not an epic".
|
||||
|
||||
### `done NNNN`
|
||||
|
||||
1. Lee frontmatter.
|
||||
2. Verifica todos `depends` cerrados (sino, error).
|
||||
3. Cuenta `## Acceptance` 100% (sino, error).
|
||||
4. `git mv dev/issues/NNNN-*.md dev/issues/completed/`.
|
||||
5. Actualiza `status: completado` + `updated: today`.
|
||||
|
||||
### `create <slug> --type T --domain D`
|
||||
|
||||
Genera siguiente ID libre (max existing + 1, zero-padded 4). Scaffold desde plantilla minima con frontmatter rellenado.
|
||||
|
||||
## Reglas
|
||||
|
||||
- Domain debe estar en `dev/TAXONOMY.md` allowlist.
|
||||
- Scope/type/priority idem.
|
||||
- `id` siempre string `"NNNN"` (zero-padded, sub-IDs con sufijo `a-z`).
|
||||
- Modificar frontmatter SIEMPRE preserva campos no tocados (no overwrite).
|
||||
@@ -1,159 +0,0 @@
|
||||
---
|
||||
description: "Modo launcher: das ordenes en lenguaje natural y Claude responde SOLO con la procedencia (registry/bash/heredoc) + el comando exacto, y lo ejecuta. Agiliza el lanzamiento de comandos y audita en vivo el Reg % (uso real de funciones del registry)."
|
||||
---
|
||||
|
||||
# /modo_launcher — lanzamiento rápido registry-first
|
||||
|
||||
Activa un **modo de comportamiento** persistente. Mientras estás dentro, el usuario da órdenes en lenguaje natural y Claude responde con el **mínimo absoluto**: la procedencia del comando + el comando exacto + por qué, y lo ejecuta. Sin prosa, sin explicaciones largas, sin preámbulos.
|
||||
|
||||
El objetivo es doble:
|
||||
|
||||
1. **Agilizar** el lanzamiento de comandos (cero verborrea entre orden y ejecución).
|
||||
2. **Auditar en vivo** que de verdad pasamos por funciones del registry antes que por bash inline — sube `Reg %` (objetivo 1 del Norte) y expone gaps reutilizables (objetivo 3).
|
||||
|
||||
## Activación
|
||||
|
||||
Al invocar `/modo_launcher` entras en **MODO LAUNCHER**. El modo permanece activo en todos los turnos siguientes hasta que el usuario escriba `salir` o `fin launcher`. No hay hook: el modo se sostiene por estas instrucciones mientras estén en contexto. Si el comportamiento se diluye tras muchos turnos, el usuario puede re-invocar `/modo_launcher` para reanclarlo.
|
||||
|
||||
Al entrar, responde con una sola línea de confirmación y queda a la espera:
|
||||
|
||||
```
|
||||
MODO LAUNCHER activo. Da ordenes. 'salir' para terminar.
|
||||
```
|
||||
|
||||
## Comportamiento por orden (regla dura)
|
||||
|
||||
Para CADA orden del usuario mientras el modo esté activo:
|
||||
|
||||
1. **Registry-first.** Mapea la orden a una capacidad y busca primero en el registry vía FTS (`mcp__registry__fn_search`) o reconoce un ID conocido. Las funciones del registry SIEMPRE tienen prioridad sobre bash inline.
|
||||
2. **Clasifica la procedencia** según la taxonomía de abajo.
|
||||
3. **Ejecuta directo.** Identificado el comando, ejecútalo sin pedir permiso — salvo que sea destructivo (ver guarda).
|
||||
4. **Responde en el formato fijo** (abajo), con la salida cruda del comando. Nada más.
|
||||
|
||||
## Formato de respuesta (OBLIGATORIO en cada orden)
|
||||
|
||||
```
|
||||
FUENTE: <etiqueta>
|
||||
CMD: <comando exacto>
|
||||
WHY: <razón: match FTS, ID conocido, o "sin función → bash">
|
||||
──────────
|
||||
<salida cruda del comando>
|
||||
```
|
||||
|
||||
- `FUENTE` es una de las etiquetas de la taxonomía.
|
||||
- `CMD` es el comando literal lanzado (forma `./fn run <id> [args]` para legibilidad aunque la ejecución real vaya por MCP).
|
||||
- `WHY` es una línea: qué match de búsqueda o qué ID justifica esa elección. Si fue un gap, dilo.
|
||||
- Tras la regla `──────────`, la salida cruda. Cero comentario después salvo que el usuario pregunte.
|
||||
|
||||
## Taxonomía de procedencia
|
||||
|
||||
| Etiqueta | Qué es | Cómo se ejecuta |
|
||||
|---|---|---|
|
||||
| `registry-run` | Ejecutar UNA función o pipeline del registry | `mcp__registry__fn_run <id> [args]` (preferido); fallback `./fn run <id> [args]` |
|
||||
| `registry-mcp` | Inspeccionar el registro (buscar, ver, código, deps, dominios) | `mcp__registry__fn_search` / `fn_show` / `fn_code` / `fn_uses` / `fn_list_domains` |
|
||||
| `heredoc` | Componer N funciones con lógica intermedia (loops, dispatch) | Heredoc `python/.venv/bin/python3 - <<'PY' ... PY` importando del registry |
|
||||
| `bash` | Comando shell puro: no existe función que lo cubra | Bash directo |
|
||||
| `gap` | No hay función Y el patrón parece reutilizable | Ejecuta el bash equivalente y marca el candidato (ver abajo) |
|
||||
|
||||
### Preferencia de ejecución para `registry-run`
|
||||
|
||||
- Usa `mcp__registry__fn_run` cuando esté disponible (queda registrado en `call_monitor`, alimenta el bucle reactivo).
|
||||
- Si el MCP `fn_run` no está habilitado (requiere `--enable-run`), cae a `./fn run <id>` por terminal. La línea `CMD` muestra siempre la forma `./fn run <id>` por legibilidad.
|
||||
|
||||
## Gaps: orden sin función en el registry
|
||||
|
||||
Cuando una orden no tenga función que la cubra:
|
||||
|
||||
1. Ejecuta el bash equivalente (`FUENTE: bash`).
|
||||
2. Si el patrón parece **reutilizable** (firma genérica, se repetiría en otras tareas, ≥5 líneas de lógica), añade tras la salida UNA línea:
|
||||
|
||||
```
|
||||
CANDIDATO → <nombre_propuesto>_<lang>_<domain>: <1 frase de qué haría>
|
||||
```
|
||||
|
||||
No lances `fn-constructor` automáticamente dentro del modo (rompería el ritmo de lanzamiento). Solo marca. El usuario decide al salir si promueve los candidatos.
|
||||
|
||||
## Guarda de comandos destructivos
|
||||
|
||||
Ejecuta directo SALVO que el comando sea irreversible o de alto impacto. En esos casos, NO ejecutes: muestra el bloque con `FUENTE`/`CMD`/`WHY` y añade `⚠ DESTRUCTIVO — confirma con 'ok'` en vez de la salida. Espera el `ok` explícito del usuario antes de lanzar.
|
||||
|
||||
Patrones que exigen confirmación:
|
||||
|
||||
- `rm -rf`, borrado de archivos versionados, `> archivo` sobre archivos trackeados.
|
||||
- `git push --force`, `git reset --hard`, `git clean`, borrado de ramas.
|
||||
- SQL `DROP`, `DELETE` sin `WHERE`, `TRUNCATE`, borrar cualquier `.db`.
|
||||
- `deploy`, `systemctl stop/restart/disable` de services, `fn sync` (escribe en el servidor).
|
||||
- `kill -9` masivo, `format`, `mkfs`, `dd`, cambios en `fstab`.
|
||||
|
||||
Para todo lo demás (lecturas, búsquedas, `fn run` de funciones puras o idempotentes, `git status/add/commit`, listados), ejecuta directo.
|
||||
|
||||
## Salida del modo
|
||||
|
||||
Cuando el usuario escriba `salir` o `fin launcher`, cierra el modo con un resumen caveman de una tabla:
|
||||
|
||||
```
|
||||
=== fin MODO LAUNCHER ===
|
||||
ordenes: N
|
||||
registry: X (run A / mcp B)
|
||||
bash: Y
|
||||
gaps: Z → [lista de candidatos marcados]
|
||||
Reg %: X/(X+Y) de las ordenes ejecutables golpearon el registry
|
||||
```
|
||||
|
||||
Si hubo candidatos a función (`gap`), recuérdalos y pregunta si promover alguno vía `fn-constructor`.
|
||||
|
||||
## Reglas duras del modo
|
||||
|
||||
- **Registry-first siempre.** Nunca escribas bash inline para lógica que ya es función del registry. Busca antes de teclear.
|
||||
- **Cero prosa fuera del bloque.** Nada de "Claro, voy a…". Solo el bloque `FUENTE/CMD/WHY/salida`.
|
||||
- **Una orden = un bloque.** Si la orden necesita varios pasos, encadénalos en un solo `CMD` o numera los bloques, pero mantén el formato.
|
||||
- **No inventes IDs.** Si no encuentras función por búsqueda, es `gap`, no un `registry-run` falso.
|
||||
- **El modo no exime de las reglas del repo** (`registry_calls.md`, `purity.md`, etc.). Solo cambia el estilo de respuesta.
|
||||
|
||||
## Ejemplo de sesión
|
||||
|
||||
```
|
||||
tú: /modo_launcher
|
||||
yo: MODO LAUNCHER activo. Da ordenes. 'salir' para terminar.
|
||||
|
||||
tú: busca funciones de slice
|
||||
yo: FUENTE: registry-mcp
|
||||
CMD: mcp__registry__fn_search query="slice"
|
||||
WHY: inspección directa del registro
|
||||
──────────
|
||||
filter_slice_go_core — Filtra slice por predicado
|
||||
chunk_slice_go_core — Parte slice en lotes de N
|
||||
...
|
||||
|
||||
tú: mata lo que escuche en el puerto 8484
|
||||
yo: FUENTE: registry-run
|
||||
CMD: ./fn run port_kill_bash_infra 8484
|
||||
WHY: match FTS 'port kill' → port_kill_bash_infra
|
||||
──────────
|
||||
killed pid 33120 on :8484
|
||||
|
||||
tú: enséñame el uso de disco de home
|
||||
yo: FUENTE: bash
|
||||
CMD: du -sh /home/enmanuel/* | sort -rh | head
|
||||
WHY: sin función → utilidad de sistema puntual
|
||||
──────────
|
||||
12G /home/enmanuel/fn_registry
|
||||
...
|
||||
CANDIDATO → disk_usage_top_bash_shell: top-N directorios por tamaño en una ruta
|
||||
|
||||
tú: salir
|
||||
yo: === fin MODO LAUNCHER ===
|
||||
ordenes: 3
|
||||
registry: 2 (run 1 / mcp 1)
|
||||
bash: 1
|
||||
gaps: 1 → disk_usage_top_bash_shell
|
||||
Reg %: 2/3 (67%)
|
||||
1 candidato marcado. ¿Promuevo disk_usage_top_bash_shell vía fn-constructor?
|
||||
```
|
||||
|
||||
## Relación con otras reglas
|
||||
|
||||
- `registry_calls.md` — el modo es una capa de estilo sobre los tres patrones canónicos (inspect / run / compose).
|
||||
- `registry_first.md` — el modo materializa "buscar antes de escribir" en cada orden.
|
||||
- `function_growth_and_self_docs.md` — los candidatos marcados alimentan la promoción de patrones inline a funciones.
|
||||
- `kiss.md` — sin hook, sin estado en disco: el modo vive solo en estas instrucciones.
|
||||
@@ -3,7 +3,7 @@
|
||||
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/fn_registry
|
||||
cd /home/lucas/fn_registry
|
||||
./fn run init_cpp_app $ARGUMENTS
|
||||
```
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ Si vacio: detectar app desde `pwd` (si estas dentro de `apps/<X>/` o `projects/*
|
||||
### 1. Resolver app objetivo
|
||||
|
||||
```bash
|
||||
ROOT=$HOME/fn_registry
|
||||
ROOT=/home/lucas/fn_registry
|
||||
ARG="$ARGUMENTS"
|
||||
|
||||
if [ -z "$ARG" ]; then
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
---
|
||||
name: version
|
||||
description: Bumpear semver de un modulo, framework, paquete o app del registry. Edita <target>.md::version + ## Capability growth log. NO commitea.
|
||||
---
|
||||
|
||||
# /version
|
||||
|
||||
Bumpea la version de un **modulo, framework, paquete o app** del registry siguiendo SemVer estricto y mantiene el `## Capability growth log` sincronizado con `<target>.md::version`.
|
||||
|
||||
Disenado para usarse desde `/fix-issue` cuando el cambio afecte:
|
||||
- `modules/<X>/` (cualquier modulo C++) — edita `module.md`
|
||||
- `cpp/framework/` — edita `modules/framework/module.md`
|
||||
- `apps/<X>/` o `projects/<P>/apps/<X>/` — edita `app.md`
|
||||
- Otros paquetes versionados con `<target>.md` y campo `version:`
|
||||
|
||||
## Inputs
|
||||
|
||||
```
|
||||
/version <path> <major|minor|patch> "<reason>"
|
||||
```
|
||||
|
||||
- `<path>`: directorio del target (ej. `modules/data_table`, `cpp/framework`, `apps/chart_demo`, `projects/fn_monitoring/apps/registry_dashboard`).
|
||||
- `<major|minor|patch>`: tipo de bump SemVer.
|
||||
- `<reason>`: 1-frase humana — lo que cambia. Se inserta en el log.
|
||||
|
||||
## Resolucion del archivo target
|
||||
|
||||
| Path empieza por | Archivo a editar |
|
||||
|---|---|
|
||||
| `modules/` | `<path>/module.md` |
|
||||
| `cpp/framework` | `modules/framework/module.md` |
|
||||
| `apps/` | `<path>/app.md` |
|
||||
| `projects/*/apps/` | `<path>/app.md` |
|
||||
| `projects/*/analysis/` | `<path>/analysis.md` |
|
||||
|
||||
Si no encuentra archivo target -> ERROR.
|
||||
|
||||
## Reglas SemVer
|
||||
|
||||
### Modulos / framework
|
||||
|
||||
| Bump | Cuando |
|
||||
|---|---|
|
||||
| `major` | Cambios breaking en API publica: firma de entry function, layout de State struct expuesto, eliminacion de members, cambio incompatible de comportamiento. |
|
||||
| `minor` | Adiciones backwards-compatible: nuevo evento opt-in, nuevo renderer, nuevo helper, nuevo miembro. |
|
||||
| `patch` | Bugfix sin cambio de API. |
|
||||
|
||||
Refactor interno SIN cambio de API publica -> `minor` (no major).
|
||||
|
||||
### Apps
|
||||
|
||||
| Bump | Cuando |
|
||||
|---|---|
|
||||
| `major` | Breaking observable por usuarios: CLI args incompatibles, schema BBDD propia rompe lectores viejos, formato wire (HTTP/gRPC) incompatible, eliminacion de panel/feature que la gente usaba. |
|
||||
| `minor` | Feature aditiva: nuevo panel, nuevo endpoint, nueva opcion CLI, nueva tab, mejora visible no rompedora. |
|
||||
| `patch` | Bugfix sin cambio observable. Refactor interno. Mejoras de perf. |
|
||||
|
||||
Bump de **dependencia** (modulo/funcion del registry) que mejora la app pero la app no cambia su API -> `patch` (la app no es responsable de la mejora; el modulo si).
|
||||
|
||||
## Flujo
|
||||
|
||||
### 1. Validar input
|
||||
|
||||
- `<target_file>` existe -> si no, ERROR.
|
||||
- Bump type en {major, minor, patch} -> si no, ERROR.
|
||||
- Reason no vacia -> si no, ERROR.
|
||||
|
||||
### 2. Leer version actual
|
||||
|
||||
Parsear frontmatter. Buscar `version: X.Y.Z`. Si no existe:
|
||||
- Para `module.md` -> ERROR "module.md sin campo version".
|
||||
- Para `app.md` -> asumir `0.1.0` (baseline) e insertar el campo despues de `domain:`.
|
||||
|
||||
### 3. Calcular proxima version
|
||||
|
||||
```
|
||||
1.4.0 + major = 2.0.0
|
||||
1.4.0 + minor = 1.5.0
|
||||
1.4.0 + patch = 1.4.1
|
||||
```
|
||||
|
||||
Major bump -> minor y patch a 0. Minor bump -> patch a 0.
|
||||
|
||||
### 4. Editar `<target_file>`
|
||||
|
||||
Cambiar linea `version: <old>` por `version: <new>`.
|
||||
|
||||
### 5. Anadir entrada a `## Capability growth log`
|
||||
|
||||
Insertar al inicio de la lista (lineas posteriores al header `## Capability growth log`):
|
||||
|
||||
```markdown
|
||||
- v<new> (<fecha YYYY-MM-DD>) — <reason>
|
||||
```
|
||||
|
||||
Si la seccion no existe -> crearla al final del archivo antes de `## Notes` (o al final si no hay Notes).
|
||||
|
||||
### 6. Verificar drift de members (solo modulos, opcional)
|
||||
|
||||
Si la herramienta `fn doctor modules` existe (post 0107a) y el target es modulo:
|
||||
- Compara `members:` actual vs ultima version registrada en `registry.db::modules_history`.
|
||||
- Si hay diff en members y bump es `patch` -> WARNING.
|
||||
- Si hay diff en API publica y bump no es `major` -> ERROR (require `--force`).
|
||||
|
||||
No aplica a apps (no tienen `members:`).
|
||||
|
||||
### 7. Stage en git
|
||||
|
||||
`git add <target_file>`. NO commit. El commit final lo hace el flujo padre.
|
||||
|
||||
### 8. Reportar
|
||||
|
||||
```
|
||||
/version apps/chart_demo minor "anade tab radar chart"
|
||||
|
||||
apps/chart_demo/app.md
|
||||
version: 1.2.0 -> 1.3.0
|
||||
## Capability growth log: + v1.3.0 (2026-05-18) — anade tab radar chart
|
||||
|
||||
Staged. NO committed.
|
||||
Next: terminar el fix-issue y hacer commit con el resto de cambios.
|
||||
```
|
||||
|
||||
## Reglas criticas
|
||||
|
||||
- **NUNCA commit**. `/version` solo edita + stage. El commit lo hace el flujo padre (`/fix-issue`, `/git-push`).
|
||||
- **NUNCA saltar version**. No 1.4.0 -> 1.4.2 directo.
|
||||
- **NUNCA bajar version**. Si rollback, crea nueva version superior con comportamiento viejo restaurado.
|
||||
- **fecha = HOY** (`date +%Y-%m-%d`).
|
||||
- **reason** comprensible sin contexto del PR actual.
|
||||
|
||||
## Referenciado desde
|
||||
|
||||
- `/fix-issue` — al detectar cambios en `modules/`, `cpp/framework/`, `apps/<X>/` o `projects/*/apps/<X>/`, sugiere ejecutar `/version` antes del commit final.
|
||||
- `.claude/rules/cpp_apps.md` — politica de bump.
|
||||
- `dev/issues/0107-modules-standardization.md` — origen del flujo (modulos).
|
||||
|
||||
## Ejemplos
|
||||
|
||||
```
|
||||
# Bug fix en data_table (modulo)
|
||||
/version modules/data_table patch "fix off-by-one en seleccion multi-row con shift+click"
|
||||
# -> 1.4.0 -> 1.4.1
|
||||
|
||||
# Feature opt-in en framework
|
||||
/version cpp/framework minor "anade cfg.auto_dockspace para overlay de paneles flotantes"
|
||||
# -> 1.1.0 -> 1.2.0
|
||||
|
||||
# Feature en app C++
|
||||
/version apps/chart_demo minor "anade tab radar chart con datos sinteticos"
|
||||
# -> 1.2.0 -> 1.3.0
|
||||
|
||||
# Bug fix en app de proyecto
|
||||
/version projects/fn_monitoring/apps/registry_dashboard patch "fix tooltip que mostraba duration_ms en segundos"
|
||||
# -> 0.4.1 -> 0.4.2
|
||||
|
||||
# Breaking en app: cambia schema de su BBDD propia
|
||||
/version apps/kanban major "cards.assignee_id pasa a ser TEXT[] (era TEXT); requiere migracion 008"
|
||||
# -> 1.0.0 -> 2.0.0
|
||||
```
|
||||
|
||||
## Anti-patrones
|
||||
|
||||
| Anti-patron | Por que es malo |
|
||||
|---|---|
|
||||
| Editar `version:` a mano sin `## Capability growth log` | Drift entre version y log; nadie sabe que cambio. |
|
||||
| Bumpear major en app por refactor interno | Confunde al usuario; refactor es patch. |
|
||||
| Patch para feature visible | Usuario no se entera que esta disponible. |
|
||||
| Reason "cambios varios" / "mejoras" | Inutil para auditar. Una frase concreta. |
|
||||
| Bump de app sin tocar codigo de la app (solo dep) | Bump va al modulo, no a la app. |
|
||||
@@ -1,67 +0,0 @@
|
||||
---
|
||||
description: "Vista cross-cutting de issues + flows. Subcomandos: today, weekly, search, dashboard. Mezcla los dos universos en una lista priorizable."
|
||||
---
|
||||
|
||||
# /work — Vista cross-cutting issues + flows
|
||||
|
||||
Issues = trabajo de implementacion. Flows = casos de uso multi-app. `/work` los muestra juntos para responder "que hago ahora" sin saltar entre dos sitios.
|
||||
|
||||
## Sintaxis
|
||||
|
||||
```
|
||||
/work today # top items prio alta + deps satisfechas (issues + flows)
|
||||
/work weekly # review semanal: closed vs planeados
|
||||
/work search "texto" # FTS sobre issues + flows + completed
|
||||
/work dashboard # JSON consumible por tab Work (issue 0102)
|
||||
```
|
||||
|
||||
## Implementacion
|
||||
|
||||
**Fase 1 (manual via Claude):**
|
||||
|
||||
El agente lee `dev/issues/*.md` + `dev/flows/*.md`, parsea frontmatter YAML, ordena por:
|
||||
|
||||
1. `priority: alta` primero.
|
||||
2. `status: pendiente` con `depends` todos `completado` (no bloqueados).
|
||||
3. Items con DoD/Acceptance >=80% (a punto de cerrar).
|
||||
4. Fecha `updated` mas reciente.
|
||||
|
||||
Imprime tabla unificada:
|
||||
|
||||
```
|
||||
KIND | ID | TITLE | PRIO | STATUS | NEXT STEP
|
||||
issue| 0099 | datahub app launcher | alta | pendiente | revisar deps
|
||||
flow | 0001 | hn-top-stories | high | pending | cerrar DoD user-facing
|
||||
issue| 0100 | migrate issue frontmatter | alta | pendiente | ejecutar pipeline
|
||||
...
|
||||
```
|
||||
|
||||
**Fase 2 (cuando 0101 dev_console exista):**
|
||||
|
||||
`./apps/dev_console/dev_console work <subcomando> $ARGS`.
|
||||
|
||||
## Subcomandos
|
||||
|
||||
### `today`
|
||||
|
||||
Filtro: `priority in (alta, media)` + `status: pendiente` + dependencias resueltas. Max 10 items. Si hay >10, prioriza `alta` y avisa "N items pendientes en cola".
|
||||
|
||||
### `weekly`
|
||||
|
||||
Git log `--since='1 week ago'` sobre `dev/issues/completed/` y `dev/flows/completed/` -> tabla de items cerrados. Comparado con `created: <esta semana>` -> ratio in/out.
|
||||
|
||||
### `search "texto"`
|
||||
|
||||
`grep -ri` sobre `dev/issues/` + `dev/flows/` (incluido completed/), filtra por title/body. Output: `path:line: match`.
|
||||
|
||||
### `dashboard`
|
||||
|
||||
Output JSON estructurado para consumo por tab Work del `registry_dashboard` (issue 0102). Estructura:
|
||||
|
||||
```json
|
||||
{
|
||||
"issues": {"pendiente": [...], "in-progress": [...], "bloqueado": [...], "completado_24h": [...]},
|
||||
"flows": [{"id": "0001", "dod_percent": 50, "user_facing_percent": 0, "...": ...}],
|
||||
"telemetry": {"calls_24h": N, "violations_24h": N, "pending_proposals": N}
|
||||
}
|
||||
```
|
||||
@@ -21,7 +21,6 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
||||
| 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) |
|
||||
| 17b | [apps_subrepo.md](apps_subrepo.md) | Apps son sub-repos Gitea (apps/* gitignored). El padre NUNCA trackea contenido de artefactos hijos (solo `.gitkeep`); nada de `git add -f` sobre apps/analysis/projects o deja el padre dirty. `git init` dentro de cada app nueva ANTES de limpiar worktree, sino se pierde el codigo |
|
||||
| 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) |
|
||||
@@ -35,8 +34,3 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
||||
| 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 |
|
||||
| 31 | [autonomous_loop.md](autonomous_loop.md) | Reglas para `fn-orquestador` + `/autonomous-task`: sandbox obligatorio, paths protegidos, filtro proposals auto-aplicables, watchdog, idempotencia. Issue 0069 |
|
||||
| 32 | [../../dev/TAXONOMY.md](../../dev/TAXONOMY.md) | Allowlist canonica para dominios/tipos/scopes/estados/prioridades + flow patterns. Aplica a `dev/issues/` y `dev/flows/`. Issues 0100 + 0103 |
|
||||
| 33 | [project_commands.md](project_commands.md) | Slash commands por project (`.claude/commands/<project>/`) expuestos via symlink. Desde fn_registry: `/<project>:foo`. Desde el project: `/foo`. Sin colision. |
|
||||
| 34 | [dod_quality.md](dod_quality.md) | DoD Quality Triada: Mecanica + Cobertura (golden + edge + error path con evidencia ejecutable) + Vida util validada (>=7 dias uso real). Cierra anti-criterios contra checkbox vago. Aplica a `dev/flows/` y issues user-facing. |
|
||||
| 35 | [llm_invocation.md](llm_invocation.md) | Invocacion de LLM: SIEMPRE `ask_llm` (grupo `claude-direct`, API directa, arranque 0), NUNCA `claude -p` (lento, cold start). One-shot/streaming/tool-loop + legacy `claude_stream_go_core` deprecado. |
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
## Apps son sub-repos Gitea independientes — gotcha al usar worktrees
|
||||
|
||||
**Regla operativa critica** descubierta el 2026-05-18 durante implementacion del flow 0008.
|
||||
|
||||
### El gotcha
|
||||
|
||||
`apps/*/` esta en `.gitignore` del repo `fn_registry`. Cada app es **su propio repo Gitea** en `dataforge/<app_name>` con su `.git/` dentro de `apps/<app_name>/`. Esto significa:
|
||||
|
||||
- Cuando un agente trabaja en un git **worktree** del repo padre y crea `apps/<nueva_app>/`, los archivos viven SOLO en el working directory del worktree.
|
||||
- Como `apps/*/` esta gitignored en el repo padre, los archivos **no se pueden commitear** al worktree del repo padre.
|
||||
- Cuando se hace `git worktree remove --force worktrees/<slug>/`, el working directory entero se borra — **el codigo de la app desaparece**.
|
||||
|
||||
**Consecuencia**: una app creada dentro de un worktree del repo padre se pierde al limpiar el worktree salvo que se haya promovido a su propio sub-repo Gitea ANTES.
|
||||
|
||||
### El patron correcto al crear apps en worktrees
|
||||
|
||||
```bash
|
||||
# 1. Agente trabaja en worktree del repo padre
|
||||
cd $HOME/fn_registry/worktrees/<slug>
|
||||
|
||||
# 2. Scaffold la app via pipeline canonico
|
||||
./fn run init_cpp_app <name> # apps C++
|
||||
# o ./fn run init_jupyter_analysis ... # analysis
|
||||
# o crear apps/<name>/ a mano (Go service, etc.)
|
||||
|
||||
# 3. ANTES de salir del worktree: inicializa la app como sub-repo
|
||||
cd apps/<name>
|
||||
git init -b master
|
||||
git add -A
|
||||
git -c user.email="agent@fn_registry" -c user.name="agent" \
|
||||
commit -m "feat: initial scaffold of <name>"
|
||||
|
||||
# 4. Trabajo continua en sub-repo (commits dentro de apps/<name>/.git)
|
||||
# 5. Cerrar issue en repo padre (mv .md a completed/), commit del padre con cambios en cpp/CMakeLists.txt, etc.
|
||||
```
|
||||
|
||||
Cuando el humano corre `/full-git-push` despues del merge, el script `ensure_repo_synced_bash_infra` detecta que `apps/<name>/.git` existe + no tiene remote + crea repo Gitea en `dataforge/<name>` + pushea master.
|
||||
|
||||
### Que ESTA SI versionado en el repo padre
|
||||
|
||||
- `cpp/CMakeLists.txt` (el `if(EXISTS ...) add_subdirectory(apps/<name>) endif()`).
|
||||
- `dev/issues/completed/<NNNN>-<slug>.md` (cierre del issue).
|
||||
- `docs/capabilities/*.md` si la app aporta a un capability group.
|
||||
- `dev/feature_flags.json` si introduce flags.
|
||||
|
||||
Todo lo demas (codigo de la app + app.md + appicon + service unit + tests propios de la app) vive en `apps/<name>/.git` independiente.
|
||||
|
||||
### REGLA DURA: el repo padre NUNCA trackea contenido de artefactos hijos
|
||||
|
||||
El repo padre `fn_registry` solo versiona codigo del registry (`functions/`, `types/`, `registry/`, `cmd/`, `docs/`, `.claude/`, `dev/`, `migrations/`, y el framework/functions/vendor de `cpp/`). NUNCA debe trackear el contenido de un artefacto hijo:
|
||||
|
||||
- apps: `apps/*`, `cpp/apps/*`, `projects/*/apps/*`
|
||||
- analyses: `analysis/*`, `projects/*/analysis/*`
|
||||
- projects: `projects/*`
|
||||
|
||||
Cada artefacto es un sub-repo Gitea independiente con su propio `.git`; su contenido completo (codigo, `app.md`, `analysis.md`, `appicon.*`, binarios, frontend, `local_files/`, tests propios) vive SOLO en ese sub-repo. `fn index` lee los `.md` de registro directamente del disco — no necesitan estar en el git del padre. Lo unico que el padre versiona dentro de esos arboles son los marcadores `.gitkeep` (mantienen `apps/` y `analysis/` presentes cuando estan vacios) y, en `projects/`, los `project.md` template si los hubiera.
|
||||
|
||||
**Como se rompe (sintoma = repo padre permanentemente dirty):** un `git add -f apps/<x>/...` (forzado, saltandose el `.gitignore`) o un commit que mete contenido del hijo al padre. Como el archivo ya queda en el indice, el `.gitignore` NO lo vuelve a ignorar y aparece para siempre en `git status` del padre como modificado cada vez que el sub-repo cambia (doble-tracking). Caso real (2026-06-03): `apps/dag_engine/` (31 archivos: Go + frontend + app.md) y `apps/shaders_lab/` (app.md + un binario `.exe` de 23 MB) quedaron forzados al indice del padre y lo dejaban dirty en cada cambio del sub-repo.
|
||||
|
||||
**Auditoria (cero salida = sano):**
|
||||
|
||||
```bash
|
||||
git ls-files 'apps/*' 'analysis/*' 'projects/*/apps/*' 'projects/*/analysis/*' 'cpp/apps/*' \
|
||||
| grep -vE '(^|/)\.gitkeep$'
|
||||
```
|
||||
|
||||
**Fix si aparece contenido trackeado:**
|
||||
|
||||
```bash
|
||||
# --cached SIEMPRE: saca del indice del padre sin borrar el working tree.
|
||||
# El codigo sigue a salvo en el .git del sub-repo.
|
||||
git rm -r --cached apps/<x>
|
||||
git commit -m "chore: untrack contenido del artefacto <x> (es sub-repo Gitea)"
|
||||
```
|
||||
|
||||
NUNCA `git rm` sin `--cached` (borraria el working tree del sub-repo). **Prevencion:** jamas usar `git add -f` sobre paths de artefactos; las reglas `apps/*/`, `analysis/*/`, `projects/*/` del `.gitignore` ya cubren el caso por defecto y solo un force las salta.
|
||||
|
||||
### Sintomas de la perdida
|
||||
|
||||
Si limpias el worktree y luego corres `ls apps/<name>/`, devuelve "No such file or directory" pese a que el issue aparece cerrado en `dev/issues/completed/`. **Patron** = scaffold sin sub-repo init = trabajo perdido.
|
||||
|
||||
### Recovery si pasa
|
||||
|
||||
1. Re-crear worktree desde master.
|
||||
2. Re-spawn agente con instruccion explicita: **`git init` dentro de la app antes de terminar**.
|
||||
3. NO eliminar el worktree hasta confirmar que `apps/<name>/.git` esta inicializado con al menos un commit.
|
||||
|
||||
### Aplica tambien a analysis
|
||||
|
||||
`analysis/*/` y `projects/*/analysis/*/` siguen mismo patron (cada analysis es repo Gitea). El pipeline `init_jupyter_analysis_bash_pipelines` ya hace `git init` automatico — por eso no hubo perdidas alli. Las apps C++/Go scaffolded a mano NO inicializan el sub-repo automaticamente — es responsabilidad del agente.
|
||||
|
||||
### Lo que aprende `parallel-fix-issues`
|
||||
|
||||
El template del prompt de cada agente DEBE incluir la instruccion:
|
||||
|
||||
> "Si tu issue crea una app nueva en `apps/<name>/`, inicializa el sub-repo (`cd apps/<name> && git init -b master && git add -A && git commit ...`) antes de terminar. Sin esto, `apps/*` esta gitignored y el codigo se perdera cuando el orquestador limpie el worktree."
|
||||
|
||||
Aplicar este parrafo al template del skill — ver `.claude/skills/parallel-fix-issues/SKILL.md` (o equivalente).
|
||||
|
||||
### Relacion con otras reglas
|
||||
|
||||
- [[apps_tbd]] — TBD en apps, esta regla complementa con el patron de sub-repo init.
|
||||
- [[artefactos]] — apps son artefactos, esta regla especifica gotcha de su sub-repo.
|
||||
- [[apps_vs_functions]] — apps en `apps/`, esta regla refuerza por que apps/* gitignored.
|
||||
@@ -1,102 +0,0 @@
|
||||
## Bucle autonomo (`fn-orquestador` + `/autonomous-task`) — issue 0069
|
||||
|
||||
`fn-orquestador` recorre el ciclo reactivo (CONSTRUIR → EJECUTAR → RECOPILAR → ANALIZAR → MEJORAR) sin intervencion humana, hasta convergencia (suite verde), estancamiento (no progreso N iteraciones), timeout, o tope de iteraciones. Trabaja SIEMPRE en sandbox `auto/<issue>`, NUNCA merge a master.
|
||||
|
||||
### Cuando se invoca
|
||||
|
||||
- Skill `/autonomous-task <issue_id>` (humano lanza explicitamente).
|
||||
- Cron / dag_engine (`schedule:` en YAML; planificable, no implementado por defecto).
|
||||
- NO se invoca como reaccion a hooks ni a fallos de tests "en caliente". Siempre tarea explicita.
|
||||
|
||||
### Reglas duras
|
||||
|
||||
1. **Sandbox obligatorio**: rama `auto/<issue_id>-<slug>`. Si la rama existe -> reset hard contra master y reanudar. NUNCA commits a master, NUNCA push --force-with-lease a master.
|
||||
2. **Paths protegidos**: respetar `dev/autonomous_protected_paths.json` exactamente. Cualquier intento de modificar un path protegido aborta la iteracion y registra `task_runs.status='aborted_protected_path'`.
|
||||
3. **Filtro de proposals auto-aplicables**: el orquestador SOLO aplica proposals que cumplen:
|
||||
- `kind in (bug_fix, e2e_check_add, doc_update, capability_tag_add)` -> auto-aplicable.
|
||||
- `kind in (new_function, deprecate_function, refactor, schema_change)` -> NO auto-aplicable (queda `pending` para humano).
|
||||
- `priority in (low, medium)` -> auto-aplicable. `high|critical` -> requiere humano salvo override `--allow-high`.
|
||||
4. **Watchdog**: si la metrica de progreso (`checks_pass / checks_total`) no sube en `N=3` iteraciones consecutivas -> abort. Registrar `task_runs.status='stalled'`.
|
||||
5. **Tiempo**: cada `task_run` con timeout default 30 min. Override con `--timeout-min N` hasta max 4h.
|
||||
6. **Idempotencia**: re-ejecutar `/autonomous-task <id>` sobre la misma issue reanuda desde la ultima iteracion exitosa, NO reinicia desde cero (lookup en `task_runs` por `issue_id`).
|
||||
7. **Trazabilidad**: cada decision se persiste en `task_runs.events_json[]` con `{ts, agent, action, evidence, diff_summary}`. El humano puede leer el log entero para auditar.
|
||||
8. **No self-modification**: orquestador NUNCA modifica `.claude/agents/`, `.claude/commands/`, `.claude/rules/`, `.claude/scripts/`, `.claude/CLAUDE.md`. Reforzado en `autonomous_protected_paths.json`.
|
||||
9. **NUNCA paths absolutos fuera del worktree**. Refuerzo del piloto 1 (2026-05-15): el orquestador uso `/home/lucas/fn_registry/bash/functions/...` para fixear hooks bash y contamino el repo principal. Solucion correcta: fix vive solo en el worktree. Post-cada-iteracion: `git -C <main_repo> status --short` debe permanecer igual al baseline; cualquier diff = `status=sandbox_breach` -> ABORT.
|
||||
10. **Pre-commit hooks compartidos**. Worktrees comparten `.git/hooks/` con main. Si un hook llama scripts via path absoluto, ejecutara la version de main. Si el hook bloquea progreso por bug en main: aplica el fix EN EL WORKTREE (commit en auto/*); si el bug del hook excede scope: `git commit --no-verify` para ESE commit con `task_runs.events_json[].decision="skip_hook"` + razon. NO editar main.
|
||||
|
||||
### Sub-repos vs worktree padre
|
||||
|
||||
Cuando el issue toca `app.md` o codigo dentro de `apps/<name>/`, `projects/<p>/apps/<name>/`, `cpp/apps/<name>/`, o `analysis/<a>/` — estos directorios son **sub-repos Gitea independientes** y estan `.gitignore`d en el repo padre `fn_registry` (regla `apps_subrepo.md`). El orquestador:
|
||||
|
||||
- **Crea worktree padre** `auto/<issue>` en `/tmp/fn_orq_<issue>_<ts>/` por protocolo, **pero no escribe alli** porque los cambios no se versionan en el padre.
|
||||
- **Opera DIRECTAMENTE en el sub-repo** de la app/analysis target. Branch `auto/<issue>-<slug>` se crea dentro de `apps/<name>/.git`, NO en el padre.
|
||||
- **PR draft sale al sub-repo** en `dataforge/<name>` (NO a `dataforge/fn_registry`). Humano revisa+mergea en el sub-repo.
|
||||
- **Worktree padre queda vacio** y se limpia normal con `git worktree remove` al terminar.
|
||||
|
||||
Validado en piloto 0120 (`add_e2e_check` sobre `chart_demo`): PR creado en `dataforge/chart_demo/pulls/1`, sanity check del main repo `fn_registry` confirmo cero contaminacion.
|
||||
|
||||
Si el issue toca AMBOS lados (codigo del registry padre + app de sub-repo), el orquestador commitea separado: cambios del padre en `auto/<issue>` (worktree padre), cambios de la app en `auto/<issue>-<slug>` (sub-repo). Dos PRs draft. Humano coordina merge.
|
||||
|
||||
### Gitea API vs `gh`
|
||||
|
||||
Pre-condicion `gh auth status` es smoke check (target github.com). Mecanismo real de PR es `curl` a Gitea API:
|
||||
|
||||
```bash
|
||||
GITEA_TOKEN=$(pass gitea/dataforge-git-token | head -n1)
|
||||
curl -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title":"...","head":"auto/<issue>-<slug>","base":"master","draft":true,"body":"..."}' \
|
||||
"https://gitea-.../api/v1/repos/dataforge/<repo>/pulls"
|
||||
```
|
||||
|
||||
Validado en pilotos 0076 y 0120.
|
||||
|
||||
### Estructura task_run
|
||||
|
||||
Migration `fn_operations/migrations/006_task_runs.sql`. Campos minimos: `id`, `issue_id`, `branch`, `started_at`, `finished_at`, `status` (`running|done|failed|aborted_protected_path|stalled|timeout`), `iterations`, `checks_pass`, `checks_fail`, `proposals_applied_json`, `proposals_skipped_json`, `events_json`, `final_diff_sha`.
|
||||
|
||||
### Fases por iteracion
|
||||
|
||||
```
|
||||
loop:
|
||||
1. fn-constructor (Read+Edit+Write+Bash limitados) - aplica fix segun ultima proposal seleccionada
|
||||
2. fn-executor - corre build + tests + smoke
|
||||
3. fn-recopilador - audita operations.db de la app
|
||||
4. fn-analizador - corre e2e_checks (registra e2e_runs)
|
||||
5. SI todos los checks pasan -> commit + push rama + abre PR. status=done. exit.
|
||||
6. SI no progreso N iteraciones -> abort. status=stalled.
|
||||
7. fn-mejorador - crea proposals desde fallos
|
||||
8. orquestador filtra proposals auto-aplicables -> selecciona la primera -> goto 1.
|
||||
```
|
||||
|
||||
### Output al humano
|
||||
|
||||
```
|
||||
=== /autonomous-task 0068 ===
|
||||
task_run_id: run_e2e_a1b2c3
|
||||
branch: auto/0068-e2e-validation
|
||||
iterations: 4
|
||||
status: done
|
||||
checks_pass: 8/8
|
||||
proposals_applied: 3 (run_e2e_run_001, run_e2e_run_002, run_e2e_run_003)
|
||||
proposals_skipped: 1 (refactor — needs human review)
|
||||
PR: https://gitea.../pulls/42
|
||||
```
|
||||
|
||||
### Anti-patrones
|
||||
|
||||
| Anti-patron | Por que es malo |
|
||||
|---|---|
|
||||
| Mergear `auto/<issue>` a master sin PR + humano | Salta gate, riesgo de regresion |
|
||||
| Auto-aplicar proposal `kind=refactor` | Cambios sistemicos requieren revision |
|
||||
| Modificar `go.sum`, `package-lock.json`, `uv.lock` | Cambios de deps requieren CVE/license review |
|
||||
| Bucle infinito sin watchdog | Coste descontrolado de tokens |
|
||||
| Borrar archivos sin backup en `task_runs.events_json` | Pierde auditoria |
|
||||
| Override de paths protegidos via env var | Bypass de seguridad |
|
||||
|
||||
### Relacion con otras reglas
|
||||
|
||||
- [[e2e_validation]] — fn-analizador (fase 4) lee el contrato `e2e_checks` que el orquestador usa como gate.
|
||||
- [[apps_tbd]] — el orquestador opera en rama `auto/*`, no exenta de TBD.
|
||||
- [[feature_flags]] — si el fix no esta terminado, el orquestador puede meterlo detras de flag OFF antes de PR.
|
||||
- [[registry_calls]] — toda invocacion del orquestador y sub-agentes pasa por MCP/`fn run`/heredoc canonico, registrada en call_monitor.
|
||||
+13
-234
@@ -20,14 +20,14 @@ Razones:
|
||||
|
||||
Pipeline: `init_cpp_app_bash_pipelines`. Slash command equivalente: `/new-cpp-app`. Auditoria: `fn doctor cpp-apps`.
|
||||
|
||||
### 1. Ubicacion (issue 0096 estandarizada)
|
||||
### 1. Ubicacion
|
||||
|
||||
| Caso | Donde vive |
|
||||
|---|---|
|
||||
| App independiente | `apps/<nombre>/` |
|
||||
| App independiente | `cpp/apps/<nombre>/` |
|
||||
| App de un proyecto | `projects/<proyecto>/apps/<nombre>/` |
|
||||
|
||||
NUNCA en `cpp/apps/<nombre>/` (deprecado tras issue 0096) ni en cualquier otra carpeta nombrada por lenguaje (`python/apps/`, `bash/apps/`, etc.). Las carpetas por lenguaje son solo para codigo del registry (`cpp/functions/`, `python/functions/`, etc.), nunca para artefactos. Ver `apps_location` en memoria + regla `apps_vs_functions.md`.
|
||||
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
|
||||
|
||||
@@ -84,7 +84,6 @@ Plantilla minima para apps C++:
|
||||
name: <name>
|
||||
lang: cpp
|
||||
domain: <gfx|tui|tools|infra|...>
|
||||
version: 0.1.0 # semver per-app, bumped via /version
|
||||
description: "Frase corta — lo que hace y por que existe."
|
||||
tags: [imgui, ...] # si es service, anadir 'service'
|
||||
uses_functions: # IDs del registry — el indexer NO deduce C++
|
||||
@@ -103,7 +102,6 @@ Reglas:
|
||||
- `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).
|
||||
- `version`: semver per-app. Baseline `0.1.0` para apps nuevas. Bump obligatorio via `/version apps/<name> {major|minor|patch} "<reason>"` cuando `/fix-issue` toque codigo de la app. Trazabilidad humana en seccion `## Capability growth log` al final del `app.md` (una linea por bump). Ver `.claude/commands/version.md`.
|
||||
|
||||
### 5. Registro en `cpp/CMakeLists.txt`
|
||||
|
||||
@@ -131,7 +129,7 @@ El `if(EXISTS ...)` hace el registro tolerante a apps no clonadas (cada app es s
|
||||
### 6. Sub-repo Gitea (TBD obligatorio)
|
||||
|
||||
Cada app C++ es su propio repo en `dataforge/<name>` con branch `master`. Esto significa:
|
||||
- TODO el directorio `<app_dir>/` (incluido `app.md`, `appicon.*`, binarios y `local_files/`) esta en el `.gitignore` de `fn_registry`: el repo padre NUNCA versiona contenido del artefacto. `fn index` lee `app.md` directo del disco, no del git. NO forzar con `git add -f` — deja el padre dirty. Ver la regla dura en `apps_subrepo.md`.
|
||||
- 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`.
|
||||
@@ -191,105 +189,20 @@ WMs). Activado por defecto, sin opt-in:
|
||||
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 per HWND** (`#ifdef _WIN32`) — observa
|
||||
`WM_ENTERSIZEMOVE` / `WM_EXITSIZEMOVE` que AltSnap fakea alrededor de cada
|
||||
drag. El subclass se instala en la ventana principal Y en cada HWND
|
||||
secundario que el backend de ImGui crea cuando un panel se arrastra fuera
|
||||
del main (escaneo per-frame de `pio.Viewports`). Mientras el bracket esta
|
||||
abierto en CUALQUIER HWND propio, el main loop SKIPEA `render_fn` +
|
||||
`glfwSwapBuffers` globalmente, replicando el contrato del title-bar drag
|
||||
native (DefWindowProc bloquea el hilo, DWM compositor mueve el framebuffer
|
||||
existente). El flag `g_in_sizemove` es global a proposito: una sola
|
||||
sesion de sizemove externo pausa todo el render para que ninguna ventana
|
||||
compita con el OS.
|
||||
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).
|
||||
|
||||
Estado del subclass:
|
||||
- `g_subclassed` = `unordered_map<HWND, WNDPROC>`. Chain a la proc
|
||||
original via `CallWindowProcW`.
|
||||
- `install_sizemove_subclass_hwnd(HWND)` idempotente (skip si ya en mapa).
|
||||
- Per-frame: `prune_dead_subclassed()` con `IsWindow` + install en cada
|
||||
`pio.Viewports[i]->PlatformHandle` nuevo.
|
||||
- `uninstall_sizemove_subclass_all()` restaura cada HWND al exit.
|
||||
|
||||
#### Iconified main no pierde paneles flotantes (2026-05-16)
|
||||
|
||||
El legacy `glfwWaitEvents + continue` al detectar `GLFW_ICONIFIED` paraba TODO
|
||||
el frame loop. Con multi-viewport activo eso significa que
|
||||
`ImGui::UpdatePlatformWindows + RenderPlatformWindowsDefault` dejan de
|
||||
refrescar los viewports secundarios — los floating panels aparecen congelados
|
||||
o son agrupados/ocultados por el WM. Fix actual: el iconified-gate cuenta
|
||||
viewports secundarios primero; si hay alguno, fall-through al frame normal
|
||||
(la swap del main HWND minimizado es harmless, los contexts GL secundarios
|
||||
siguen pintando). Solo cuando NO hay flotantes dormimos en `glfwWaitEvents`.
|
||||
|
||||
#### Alt + RMB / Alt + LMB anywhere → modal nativo (2026-05-16)
|
||||
|
||||
WndProc del subclass tambien intercepta clicks con Alt held (`GetAsyncKeyState(VK_MENU) & 0x8000`):
|
||||
|
||||
- `WM_LBUTTONDOWN` + Alt → `ReleaseCapture()` +
|
||||
`PostMessage(WM_SYSCOMMAND, SC_MOVE | HTCAPTION)`. Modal MOVE nativo.
|
||||
- `WM_RBUTTONDOWN` + Alt → calcula direccion por cuadrante (TOPLEFT/TOPRIGHT/
|
||||
BOTTOMLEFT/BOTTOMRIGHT relativo al centro del client rect) y emite
|
||||
`PostMessage(WM_SYSCOMMAND, SC_SIZE | dir)`. Modal RESIZE nativo.
|
||||
|
||||
Ambos retornan 0 (consumen el click — ImGui NO lo ve). Aplica a main y a
|
||||
cada viewport flotante porque el subclass per-frame ya cubre todos los HWND.
|
||||
El modal nativo dispara `WM_ENTERSIZEMOVE`, que el gate existente pausa
|
||||
render → cero jitter automatico, mismo contrato que el title-bar drag.
|
||||
|
||||
**Caveat**: cualquier Alt+click se consume — perdes Alt+click como shortcut
|
||||
UI. Aceptable porque Alt-modifier en clicks UI es muy raro.
|
||||
|
||||
#### Title-bar-only move para ImGui windows (2026-05-16)
|
||||
|
||||
`fn::run_app` setea `io.ConfigWindowsMoveFromTitleBarOnly = true`. Critico
|
||||
para viewports secundarios: un viewport flotante = OS window borderless con
|
||||
UNA ventana ImGui rellenandolo. Sin el flag, ImGui mueve sus ventanas
|
||||
arrastrando cualquier client-pixel — como la ventana ImGui ES el viewport
|
||||
entero, el OS window sigue al cursor sin modifier. Con el flag, floating
|
||||
panels obedecen el contrato "solo header arrastra" (igual que main que tiene
|
||||
title bar nativo de Windows). Alt+LMB anywhere sigue funcionando (consumido
|
||||
antes por el subclass).
|
||||
|
||||
#### Test observability — `fn::internal::*` (2026-05-16)
|
||||
|
||||
Counters monotonicos para validar el subclass desde tests headless,
|
||||
zero-cost en prod:
|
||||
|
||||
```cpp
|
||||
namespace fn::internal {
|
||||
int sizemove_enter_count(); // ++ en cada WM_ENTERSIZEMOVE
|
||||
int alt_rmb_resize_count(); // ++ en cada Alt+RMB consumido
|
||||
int alt_lmb_move_count(); // ++ en cada Alt+LMB consumido
|
||||
int rbuttondown_seen_count(); // diagnostico — todo WM_RBUTTONDOWN
|
||||
void set_force_alt_for_test(bool); // bypass GetAsyncKeyState para tests
|
||||
}
|
||||
```
|
||||
|
||||
En test mode (`set_force_alt_for_test(true)`), los handlers de Alt cuentan
|
||||
pero NO postean `SC_SIZE`/`SC_MOVE` — el harness no se queda atrapado en el
|
||||
modal de Windows. Path real en prod sigue posteandolos.
|
||||
|
||||
Tests: `apps/altsnap_jitter_test/` corre seis fases:
|
||||
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` sobre el
|
||||
HWND principal, asserta que `render()` no se llama durante el bracket.
|
||||
- `p3.secondary` (Windows): fuerza viewport secundario
|
||||
(`ConfigViewportsNoAutoMerge=true`), localiza su HWND y repite el bracket
|
||||
sobre el. Valida que el subclass per-viewport tambien pausa el render.
|
||||
- `p4.minimize` (Windows): state machine 4 steps — captura
|
||||
`IsWindow(secondary_hwnd)` antes/durante/despues de `glfwIconifyWindow +
|
||||
glfwRestoreWindow`. Asserta los 3 estados vivos y `renders_iconified > 0`.
|
||||
- `p5.alt_rmb` (Windows): `set_force_alt_for_test(true)` +
|
||||
`SendMessage(WM_RBUTTONDOWN)` sincrono mismo-hilo. Asserta
|
||||
`alt_rmb_resize_count` incrementa.
|
||||
- `p6.alt_lmb` (Windows): mismo patron para `WM_LBUTTONDOWN`. Asserta
|
||||
`alt_lmb_move_count` incrementa.
|
||||
burst de `SetWindowPos(SWP_ASYNCWINDOWPOS)` + `WM_EXITSIZEMOVE`, asserta
|
||||
que `render()` no se llama durante el bracket.
|
||||
|
||||
Lanzar con `source bash/functions/infra/e2e_run_cpp_windows.sh &&
|
||||
e2e_run_cpp_windows altsnap_jitter_test`.
|
||||
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
|
||||
@@ -348,137 +261,3 @@ de antes: `imgui.ini` es la unica fuente.
|
||||
- App headless / capture mode: `cfg.auto_layouts = false`.
|
||||
- Cambiar nombre del archivo: `cfg.auto_layouts_db = "<algo>.db"` (relativo a
|
||||
`local_files/`).
|
||||
|
||||
### 11. Icono Windows (.ico embebido en el .exe) — 2026-05-16
|
||||
|
||||
Cada app C++ desplegada a Windows tiene su propio icono. El icono vive en
|
||||
`<app_dir>/appicon.ico` (multi-resolucion: 16/24/32/48/64/128/256). El macro
|
||||
`add_imgui_app` de `cpp/CMakeLists.txt` lo detecta automaticamente: si
|
||||
`WIN32` + existe `<CMAKE_CURRENT_SOURCE_DIR>/appicon.ico`, genera un
|
||||
`<target>_appicon.rc` en `CMAKE_CURRENT_BINARY_DIR` apuntando al `.ico` con
|
||||
`IDI_ICON1 ICON "<path>"` y lo anade a `add_executable`. El compilador RC
|
||||
(`x86_64-w64-mingw32-windres` configurado en `cpp/toolchains/mingw-w64.cmake`)
|
||||
lo enlaza al `.exe` como recurso `.rsrc`.
|
||||
|
||||
Verificar: `x86_64-w64-mingw32-objdump -h <app>.exe | grep rsrc` debe
|
||||
mostrar la seccion. El project line en `cpp/CMakeLists.txt` declara
|
||||
`LANGUAGES C CXX RC` solo en WIN32 (Linux ignora la `.rc`).
|
||||
|
||||
#### Crear `.ico` para una app nueva
|
||||
|
||||
Fuente de glyphs: **Phosphor Icons** (`sources/phosphor-core/`, clonado de
|
||||
`https://github.com/phosphor-icons/core.git`). 1512 SVGs en weight `regular`,
|
||||
`bold`, `fill`, `light`, `thin`, `duotone`. Usamos `fill` por defecto — mejor
|
||||
legibilidad a 16/24px.
|
||||
|
||||
Funcion del registry: `generate_app_icon_py_infra` rasteriza un SVG Phosphor
|
||||
sobre fondo redondeado del color accent y exporta `.ico` multi-res. Una
|
||||
linea por app:
|
||||
|
||||
```python
|
||||
from infra import generate_app_icon
|
||||
generate_app_icon(
|
||||
phosphor_icon_name="chart-bar",
|
||||
accent_hex="#0ea5e9",
|
||||
out_ico_path="apps/chart_demo/appicon.ico",
|
||||
)
|
||||
```
|
||||
|
||||
Mapping vive en el frontmatter de cada `app.md` C++:
|
||||
|
||||
```yaml
|
||||
description: "Frase corta de 1 linea — que hace la app y por que existe."
|
||||
icon:
|
||||
phosphor: "chart-bar"
|
||||
accent: "#0ea5e9"
|
||||
```
|
||||
|
||||
### Trio obligatorio: description + icon.phosphor + icon.accent
|
||||
|
||||
**REGLA DURA:** TODA app C++/imgui declara los **3 campos JUNTOS** en su `app.md`:
|
||||
1. `description:` (string corta, 1 linea) — texto que el `app_hub_launcher` muestra en la tarjeta y que el dashboard usa para tooltips.
|
||||
2. `icon.phosphor:` (nombre del glyph Phosphor sin sufijo `-fill`) — glyph del icono.
|
||||
3. `icon.accent:` (hex `#rrggbb`) — color del fondo redondeado del icono **Y** color del boton/border de la tarjeta en `app_hub_launcher`.
|
||||
|
||||
Los 3 se consumen como un set unico: el icono visual + el texto + el color de marca de la app. Una app sin descripcion aparece como tarjeta gris sin texto; sin `icon:` cae al default (`app-window` slate); sin accent el boton del hub aparece blanco. **Documentar uno sin los otros es bug**, no estilo.
|
||||
|
||||
### Refrescar el App Hub tras editar el trio
|
||||
|
||||
`app_hub_launcher` cachea iconos (PNG) y manifest (TSV) al arrancar. Cambiar `description`/`icon.*` en un `app.md` requiere regenerar ambos sidecars + relanzar el hub. Pipeline canonico:
|
||||
|
||||
```bash
|
||||
./fn run refresh_app_hub # icons + manifest + restart hub
|
||||
./fn run refresh_app_hub --no-restart # solo regenera, util si el hub esta cerrado
|
||||
./fn run refresh_app_hub --size 128 # PNGs 128px en vez de 64
|
||||
```
|
||||
|
||||
ID: `refresh_app_hub_bash_pipelines`. Compone `export_hub_icons_py_infra` + `export_hub_manifest_py_infra` + `is_cpp_app_running_windows_bash_infra` + `launch_cpp_app_windows_bash_infra`.
|
||||
|
||||
Regeneracion batch via pipeline del registry — escanea `app.md`s y compone
|
||||
`generate_app_icon` por app. Anadir app nueva: declarar `icon:` en su
|
||||
`app.md` y lanzar:
|
||||
|
||||
```bash
|
||||
./fn run regenerate_app_icons # todas
|
||||
./fn run regenerate_app_icons chart_demo # solo una
|
||||
```
|
||||
|
||||
Convenciones:
|
||||
- **Glyph weight**: `fill` (mas legible a 16px que `regular` o `bold`).
|
||||
- **Color**: 1 accent_hex distinto por app — Tailwind palette 500-700
|
||||
funciona bien (`#0ea5e9` sky-500, `#16a34a` green-600, etc.).
|
||||
- **Padding**: glyph ocupa ~70% del canvas, fondo redondeado al 16% del lado.
|
||||
- **Glyph color**: siempre blanco sobre el fondo accent.
|
||||
|
||||
Si Phosphor no tiene el icono adecuado: buscar en `sources/phosphor-core/assets/fill/`
|
||||
con `ls | grep <keyword>` antes de inventar — 1512 disponibles.
|
||||
|
||||
#### Re-deploy tras cambiar icono
|
||||
|
||||
```bash
|
||||
# 1. Editar icon: en apps/chart_demo/app.md y regenerar
|
||||
./fn run regenerate_app_icons chart_demo
|
||||
# (o ./fn run generate_app_icon "chart-bar" "#0ea5e9" "apps/chart_demo/appicon.ico" para uno suelto sin tocar app.md)
|
||||
|
||||
# 2. Rebuild + redeploy (build dispara windres → nuevo .rsrc)
|
||||
./fn run redeploy_cpp_app_windows chart_demo apps/chart_demo --build
|
||||
```
|
||||
|
||||
Windows cachea iconos en `iconcache.db`. Si el nuevo icono no aparece tras
|
||||
desplegar, refresh con `ie4uinit.exe -show` o reiniciar Explorer.
|
||||
|
||||
#### Runtime attach: taskbar + title bar + Alt+Tab (2026-05-16)
|
||||
|
||||
Embeber `.ico` en el `.exe` (windres) basta para File Explorer / shortcuts —
|
||||
pero GLFW crea su WNDCLASS sin icono, asi que la **barra de tareas**, el
|
||||
**header de la ventana** y **Alt+Tab** muestran el icono GLFW por defecto a
|
||||
menos que adjuntemos el recurso al HWND en runtime.
|
||||
|
||||
`fn::run_app` lo hace automaticamente, sin opt-in. Tras `glfwCreateWindow`:
|
||||
|
||||
```cpp
|
||||
HICON hSmall = LoadImageW(GetModuleHandleW(NULL), MAKEINTRESOURCEW(101),
|
||||
IMAGE_ICON, GetSystemMetrics(SM_CXSMICON),
|
||||
GetSystemMetrics(SM_CYSMICON), LR_SHARED);
|
||||
HICON hBig = LoadImageW(..., SM_CXICON, SM_CYICON, LR_SHARED);
|
||||
SendMessageW(hwnd, WM_SETICON, ICON_SMALL, (LPARAM)hSmall); // title bar
|
||||
SendMessageW(hwnd, WM_SETICON, ICON_BIG, (LPARAM)hBig); // taskbar
|
||||
SetClassLongPtrW(hwnd, GCLP_HICONSM, (LONG_PTR)hSmall);
|
||||
SetClassLongPtrW(hwnd, GCLP_HICON, (LONG_PTR)hBig);
|
||||
```
|
||||
|
||||
Resource ID `101` lo emite `add_imgui_app` en el `.rc` generado
|
||||
(`101 ICON "<app_dir>/appicon.ico"`). Si la app no tiene `appicon.ico`, el
|
||||
`.rc` no se genera, `LoadImageW` devuelve NULL y el HWND queda con el icono
|
||||
GLFW por defecto (sin error).
|
||||
|
||||
Cobertura multi-viewport: el per-frame scan de `pio.Viewports` (mismo que
|
||||
instala el sizemove subclass) tambien llama `attach_app_icon_to_hwnd` sobre
|
||||
cada HWND secundario nuevo. Floating panels dragged-out heredan el icono
|
||||
sin codigo extra en la app.
|
||||
|
||||
Cache shell: el pipeline `redeploy_cpp_app_windows` llama
|
||||
`refresh_windows_icon_cache_bash_infra` tras copiar el .exe — invoca
|
||||
`ie4uinit.exe -show` para que Explorer recargue `iconcache.db` sin esperar
|
||||
a que detecte el cambio por timestamp. Si Explorer sigue mostrando el
|
||||
icono viejo: borrar `%LOCALAPPDATA%\IconCache.db` + reiniciar Explorer.
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
# DoD Quality Triada
|
||||
|
||||
**Definition of Done no es un checkbox que se marca a mano. Es un contrato de calidad con 3 capas obligatorias + evidencia ejecutable + uso real >=7 dias.**
|
||||
|
||||
Aplica a todos los `dev/flows/` y, por extension, a issues que cierran capabilities user-facing (`dev/issues/`). El registry mismo (funciones puras, tipos) queda exento: su DoD vive en sus tests unitarios.
|
||||
|
||||
---
|
||||
|
||||
## Por que existe esta regla
|
||||
|
||||
El antipatron a eliminar: "tarea hecha porque pase los tests una vez". Despues:
|
||||
- El flow funciona en `home-wsl` pero falla en `pc-aurgi`.
|
||||
- El error path declarado nunca se ejercito y cuando ocurre en produccion no esta manejado.
|
||||
- El dashboard de observabilidad lleva 30 dias sin abrirse.
|
||||
- El proceso muere cada noche y nadie lo ve hasta que el operador intenta usarlo.
|
||||
- El approval flow se salta porque "para test es mas comodo".
|
||||
|
||||
Resultado: deuda invisible. Cada flow "done" se rompe al primer uso real, el operador pierde confianza en el sistema, y el bucle reactivo no detecta nada porque la telemetria esta verde (los tests sintenticos pasan).
|
||||
|
||||
DoD Quality Triada cambia las reglas: cerrar = probar comportamiento + sobrevivir uso real, no = compilar verde.
|
||||
|
||||
---
|
||||
|
||||
## Las 3 capas
|
||||
|
||||
### Capa 1: Mecanica (pre-requisito, NO es DoD por si misma)
|
||||
|
||||
Compilar verde, tests verdes, indexado limpio, `fn doctor` verde, `uses_functions` sin drift.
|
||||
|
||||
**Regla**: la mecanica NO basta. Es la base para empezar a probar comportamiento. Si te quedas aqui, el flow no esta hecho.
|
||||
|
||||
### Capa 2: Cobertura de comportamiento
|
||||
|
||||
Cada escenario relevante con prueba ejecutable y assert material. NO smoke "el comando no peto". Minimo:
|
||||
|
||||
- **1 golden path** — el caso feliz documentado con assert sobre output concreto.
|
||||
- **>=2 edge cases** — inputs limite, estados raros, condiciones de borde.
|
||||
- **>=1 error path** — fallo provocado intencionalmente, manejado y observable (sin crash, sin silent-fail).
|
||||
|
||||
Formato canonico (tabla en `## Definition of Done` del flow/issue):
|
||||
|
||||
```markdown
|
||||
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||
|---|---|---|---|
|
||||
| Golden: <desc> | unit / e2e | `<cmd>` | <output concreto> |
|
||||
| Edge 1: <desc> | unit / e2e | `<cmd>` | <comportamiento concreto> |
|
||||
| Error 1: <desc> | e2e | `<cmd que rompe>` | <fallo manejado, no crash> |
|
||||
```
|
||||
|
||||
Cuando aplique, cada fila genera un `e2e_check` en el `app.md` correspondiente (issue 0068). `fn-analizador` los corre periodicamente y deja entry en `e2e_runs`.
|
||||
|
||||
### Capa 3: Vida util validada
|
||||
|
||||
El flow no esta hecho hasta que sobrevive **uso real durante >=7 dias** sin romperse silenciosamente. Cada metrica con umbral medible y dashboard observable.
|
||||
|
||||
Formato canonico:
|
||||
|
||||
```markdown
|
||||
| Metrica | Umbral | Donde se observa | Ventana |
|
||||
|---|---|---|---|
|
||||
| <metrica 1> | `>=N` | `<dashboard URL / app panel>` | 7 dias |
|
||||
| crashes | `0` | `journalctl -u <unit>` | 7 dias |
|
||||
| huecos audit chain | `0` | `cmd: <verify>` | continuo |
|
||||
```
|
||||
|
||||
Reglas:
|
||||
- Metricas NO se auto-reportan; las lee el operador del dashboard real.
|
||||
- Si el dashboard no existe o no se ha abierto en 30 dias, el item se invalida.
|
||||
- Crashes del proceso = 0, huecos en audit = 0, error_rate < umbral declarado.
|
||||
|
||||
### Capa transversal: User-facing reforzado
|
||||
|
||||
- Surface concreta NO BD ni log (UI app, room Matrix, dashboard, archivo en vault).
|
||||
- Usage real: humano usa en su PC, su contexto, >=N veces variadas en >=7 dias.
|
||||
- Variado: >=3 capabilities/casos distintos (no solo "abre dashboard y mira").
|
||||
- Onboarding: parrafo en `## Notas` que explica como usar la cosa sin leer el flow.
|
||||
- Latencia medida (no declarada).
|
||||
|
||||
---
|
||||
|
||||
## Reglas duras para marcar `status: done`
|
||||
|
||||
`/flow done` (y por extension cierres de issues user-facing) DEBE rechazar el cierre si:
|
||||
|
||||
1. Falta cualquiera de las 3 capas (mecanica + cobertura + vida).
|
||||
2. Cobertura tiene <1 golden, <2 edge, o <1 error path con evidencia.
|
||||
3. Vida util tiene tabla vacia o sin dashboard observable real.
|
||||
4. User-facing usage real <7 dias o <N usos declarados.
|
||||
5. Cualquier anti-criterio marcado como cierto.
|
||||
6. `## Notas` sin parrafo onboarding.
|
||||
7. Algun item de DoD sin comando/URL/log query asociado — solo texto.
|
||||
|
||||
Hoy parte de esta validacion es manual (revision humana del operador). La validacion programatica vive en `audit_dod_schema_go_infra` (issue 0114) + `fn doctor dod` y se ampliara hasta cubrir las 3 capas (TBD).
|
||||
|
||||
---
|
||||
|
||||
## Antipatrones (invalidan la DoD aunque los checkboxes esten verdes)
|
||||
|
||||
| Antipatron | Por que es malo | Sustituir por |
|
||||
|---|---|---|
|
||||
| Marcar `done` porque pasa una vez | Tarea "hecha" se rompe al primer uso real | Capa 3: >=7 dias de uso real |
|
||||
| Checkbox sin evidencia ejecutable | DoD se convierte en placebo | Cada item con `cmd:` / URL / log query |
|
||||
| Test que solo verifica camino feliz | El error path es donde se pierden datos | Capa 2: >=1 error path ejercitado |
|
||||
| Observabilidad declarada pero dashboard no abierto en 30 dias | Telemetria muerta = ceguera | Capa 3: dashboard real, operador lo abre |
|
||||
| "Repetible 3 veces consecutivas" con BD efimera | No prueba sobre datos reales acumulados | Capa 3: PC real del operador, datos vivos |
|
||||
| Approval saltado en algun camino | Security gate roto pero invisible | Anti-criterio explicito: `audit_log` lo prueba |
|
||||
| Error path manejado solo "en teoria" | Cuando ocurra en produccion el manejo no existe | Capa 2: entry real en `e2e_runs` o audit |
|
||||
| Solo-en-mi-PC | Falla en otra maquina del operador | Anti-criterio explicito, probar >=2 PCs |
|
||||
| Self-test que retorna `pass` sin asserts materiales | False positive sistemico | Asserts sobre output concreto, no exit-0 |
|
||||
| Silent-fail (proceso muere sin alerta) | Operador no se entera hasta intentar usar | Capa 3: crashes=0 + alerta visible |
|
||||
|
||||
---
|
||||
|
||||
## Relacion con otras reglas
|
||||
|
||||
- [[e2e_validation]] — los escenarios de Capa 2 cuando aplican a apps se materializan como `e2e_checks` en `app.md`. `fn-analizador` (fase 4 del bucle reactivo) los corre.
|
||||
- [[registry_calls]] — la evidencia de uso (`call_monitor.calls`) alimenta los umbrales de Capa 3.
|
||||
- [[function_growth_and_self_docs]] — cada funcion del registry tiene su propio contrato self-doc (Ejemplo + Cuando usarla + Gotchas). DoD del flow NO sustituye al self-doc de la funcion; lo complementa para el nivel sistema.
|
||||
- [[autonomous_loop]] — `fn-orquestador` autonomo NO puede marcar `done` sin que se cumplan las 3 capas. Su criterio de convergencia incluye DoD Quality.
|
||||
- [[apps_tbd]] — TBD garantiza master desplegable; DoD garantiza que lo desplegado funciona en uso real.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
1. **Mecanica** = compilar verde (pre-requisito, NO suficiente).
|
||||
2. **Cobertura** = golden + >=2 edge + >=1 error path con evidencia ejecutable.
|
||||
3. **Vida util** = >=7 dias de uso real sin romper silenciosamente, dashboard observable abierto.
|
||||
4. **User-facing reforzado** = humano usa en PC real, >=N veces variadas.
|
||||
5. **Anti-criterios** invalidan la DoD aunque todo este verde.
|
||||
6. Sin evidencia ejecutable (cmd/URL/log), NO es DoD: es deseo.
|
||||
@@ -20,18 +20,10 @@ 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)
|
||||
# + check BeginTable inline: CANDIDATE (no migrado) / MIXED (parcial) / silencio (limpio)
|
||||
|
||||
fn doctor --json # Salida JSON (cualquier subcomando) — para agentes/scripts
|
||||
```
|
||||
|
||||
`fn doctor cpp-apps` produce dos secciones:
|
||||
1. Conformance (cfg.about/log, fn::run_app, menubar, DockSpace) — una fila por app imgui.
|
||||
2. BeginTable migration (issue 0081) — solo apps con `ImGui::BeginTable` inline:
|
||||
- `CANDIDATE`: N tablas inline sin `data_table_cpp_viz` en uses_functions. Considerar migracion.
|
||||
- `MIXED`: N tablas inline con `data_table_cpp_viz` ya declarado. Migracion parcial OK.
|
||||
- silencio: 0 BeginTable inline (limpio o completamente migrado).
|
||||
|
||||
### Mapeo subcomando → funcion del registry
|
||||
|
||||
| Subcomando | Funcion |
|
||||
@@ -41,8 +33,7 @@ fn doctor --json # Salida JSON (cualquier subcomando) — para agentes
|
||||
| `sync` | `pc_locations_drift_go_infra` |
|
||||
| `uses-functions` | `audit_uses_functions_go_infra` |
|
||||
| `unused` | `find_unused_functions_go_infra` |
|
||||
| `cpp-apps` (conformance) | `audit_cpp_apps_go_infra` |
|
||||
| `cpp-apps` (table migration) | `audit_cpp_table_migration_go_infra` (inline en `audit_cpp_apps.go`) |
|
||||
| `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.
|
||||
|
||||
@@ -73,8 +64,6 @@ Texto humano por defecto (tabwriter). `--json` produce array/objeto serializable
|
||||
| `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 |
|
||||
| cpp-apps BeginTable `CANDIDATE` | App tiene N `ImGui::BeginTable` sin migrar. Abrir rama TBD, reemplazar tablas por `data_table::render()` via `fn_table_viz`, añadir `data_table_cpp_viz` a `uses_functions` en `app.md` |
|
||||
| cpp-apps BeginTable `MIXED` | Migracion parcial en curso. Continuar wave por wave hasta que no queden BeginTable inline |
|
||||
| Backup viejo | `backup_all_bash_pipelines ~/backups/fn_registry` |
|
||||
|
||||
### Para agentes
|
||||
|
||||
@@ -28,26 +28,3 @@ Documentar en el `app.md` del service:
|
||||
- El puerto que usa (si expone HTTP/gRPC)
|
||||
- Como lanzarlo y pararlo
|
||||
- Como comprobar que esta vivo (health check)
|
||||
|
||||
### Bloque `service:` obligatorio (issue 0105)
|
||||
|
||||
Toda app con `tag: service` declara el bloque `service:` en su frontmatter. El indexer lo persiste en columnas dedicadas de `apps` + tabla `service_targets`. Consumido por `services_api`/`services_monitor` (issue 0106) y por `fn doctor services-spec`.
|
||||
|
||||
```yaml
|
||||
service:
|
||||
port: 8484 # null si no expone HTTP (stdio, daemon sin API)
|
||||
health_endpoint: /api/databases # ruta GET, 2xx/3xx = sano; null si no aplica
|
||||
health_timeout_s: 3
|
||||
systemd_unit: sqlite_api.service # obligatorio si runtime empieza con `systemd-`
|
||||
systemd_scope: user # user|system|null (docker-compose)
|
||||
restart_policy: always # always|on-failure|none
|
||||
runtime: systemd-user # systemd-user|systemd-system|docker-compose|stdio|manual
|
||||
pc_targets: # >=1, pc_id de pc_locations
|
||||
- aurgi-pc
|
||||
- home-wsl
|
||||
is_local_only: false # true => no se monitoriza por SSH (siempre local)
|
||||
```
|
||||
|
||||
Validacion: `fn doctor services-spec` (`functions/infra/audit_services_spec.go`). Hoy 11/11 services con bloque completo.
|
||||
|
||||
**Gotcha critico:** usar `Restart=always` (no `on-failure`) en el unit systemd. Un `SIGTERM` limpio es exit success → `on-failure` NO reinicia y el service se queda muerto silenciosamente. `sqlite_api.service` cayo 20h asi el 2026-05-17.
|
||||
|
||||
@@ -1,35 +1,3 @@
|
||||
## ids_naming — formato predictible
|
||||
IDs siguen el formato `{name}_{lang}_{domain}` (ej: `filter_slice_go_core`).
|
||||
|
||||
IDs: `{name}_{lang}_{domain}` (ej: `filter_slice_go_core`). Predictibilidad alta -> Claude descubre por fuzzy match sin lookup. Issue 0087.
|
||||
|
||||
### Reglas
|
||||
|
||||
1. **snake_case**: `[a-z0-9_]+`. Nada de PascalCase, kebab-case, dot.notation.
|
||||
2. **Verbo obligatorio**: al menos un token del `name` debe ser un verbo de accion. El verbo puede ir delante (`get_user`) o detras (`user_lookup`). Ejemplos validos: `filter_slice`, `bank_login`, `metabase_get_dashboard`, `redeploy_cpp_app`. Invalidos: `slice` (sustantivo solo), `user` (sustantivo solo), `data` (sustantivo solo).
|
||||
3. **Dominio canonico**: el `domain` debe estar en la lista canonica (ver `mcp__registry__fn_list_domains`). Crear dominio nuevo solo si el bucket es claramente distinto y se anade en el mismo turno a CLAUDE.md.
|
||||
4. **Tipos en PascalCase Go**: `ResultGoCore`, `ErrorGoCore`. Aplica solo al codigo Go; el ID en el registry sigue siendo snake_case (`result_go_core`).
|
||||
|
||||
### Verbos canonicos (allowlist)
|
||||
|
||||
Lista no exhaustiva pero cubre la mayoria. Anadir aqui (y al validator en `apps/registry_mcp/naming.go`) cuando se introduzca un verbo nuevo recurrente.
|
||||
|
||||
`get, set, list, find, search, show, read, load, fetch, scan, query, lookup, parse, format, encode, decode, marshal, unmarshal, serialize, deserialize, validate, check, ensure, verify, audit, diagnose, test, match, filter, map, reduce, sort, group, count, sum, aggregate, compute, calculate, score, rank, cluster, classify, detect, init, create, make, build, generate, scaffold, install, setup, configure, register, add, insert, append, prepend, update, upsert, modify, edit, patch, replace, delete, remove, clear, drop, prune, clean, copy, move, rename, sync, clone, extract, inject, import, export, send, post, put, call, dispatch, exec, run, launch, start, stop, kill, restart, redeploy, deploy, open, close, connect, disconnect, login, logout, authenticate, enable, disable, toggle, lock, unlock, propose, promote, deprecate, approve, reject, emit, render, draw, paint, serve, host, pull, push, checkout, commit, tag, merge, rebase, watch, monitor, observe, log, trace, profile, benchmark, snapshot, backup, restore, archive, compress, decompress, hash, encrypt, decrypt, sign, taskkill, recopile, vault, propose, apply, gather, collect, fold, head, tail, take, drop, slice, chunk, batch, debounce, throttle, retry, await, sleep, ping, kill, prime, warm, refresh, invalidate, reload, reset, rollback, fork, spawn, daemon, observe, plot, draw, capture, replay, recopilate`
|
||||
|
||||
### Excepciones
|
||||
|
||||
- **Operadores matematicos/estadisticos** ampliamente reconocidos por acronimo: `sma`, `ema`, `rsi`, `vwap`, `adx`. Validator hace allowlist explicita.
|
||||
- **Tipos** (entity_type `type`): no requieren verbo. Validator lo salta cuando `kind=type`.
|
||||
- **Components** (`kind: component`): nombre describe artefacto UI (`button_primary`, `chat_panel`). Permite forma `<noun>_<modifier>`. Validator salta el check de verbo si `kind=component`.
|
||||
|
||||
### Validator
|
||||
|
||||
`mcp__registry__fn_create_function` ejecuta el validator antes de escribir archivos. Rechaza con error si:
|
||||
- name no es snake_case.
|
||||
- name no contiene verbo (excepto component/type).
|
||||
- domain no esta en lista canonica.
|
||||
|
||||
Error tipico:
|
||||
```
|
||||
naming: name "slice" lacks action verb. Add verb prefix/suffix (e.g. filter_slice, slice_window). See .claude/rules/ids_naming.md.
|
||||
naming: domain "bizops" not in canonical list (core, infra, finance, ...). Add it to CLAUDE.md and rules first.
|
||||
```
|
||||
Nombres de funciones en snake_case. Tipos en PascalCase para Go.
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
## Invocación de LLM: SIEMPRE `ask_llm`, NUNCA `claude -p`
|
||||
|
||||
**REGLA DURA.** Para ejecutar un modelo LLM desde cualquier código del ecosistema (scripts, heredocs, apps, pipelines, agentes), usa el grupo `claude-direct` — empezando por `ask_llm_py_core`. **NUNCA** uses `claude -p` ni lances el binario `claude` como subproceso para obtener una respuesta del modelo.
|
||||
|
||||
### Por qué
|
||||
|
||||
| | `claude -p` | `ask_llm` / `claude-direct` |
|
||||
|---|---|---|
|
||||
| Mecanismo | Lanza Claude Code entero (proceso `claude`) | Habla directo a `api.anthropic.com/v1/messages` |
|
||||
| Arranque | ~7-15s (carga MCP + `CLAUDE.md` ~100k tokens) | **0 — request HTTP directa** |
|
||||
| Latencia/msg | ~9-15s | **~2.5s** |
|
||||
| Coste | Alto (re-carga contexto cada vez) | Mínimo (solo tu prompt) |
|
||||
| Tools | Las de Claude Code (no controlables) | **Las que tú defines** (`run_claude_tool_loop`) |
|
||||
| Streaming | indirecto | nativo (`stream_anthropic_messages`) |
|
||||
|
||||
`claude -p` es lento, caro y arranca todo Claude Code para una completion. `ask_llm` es la API directa: arranque 0, rápido, con tus propias tools. Usa el token OAuth que Claude Code ya guarda en `~/.claude/.credentials.json`.
|
||||
|
||||
### Cómo (según el caso)
|
||||
|
||||
| Caso | Usa |
|
||||
|---|---|
|
||||
| Pregunta/chat one-shot | `fn run ask_llm "..."` o `from core.ask_llm import ask_llm` |
|
||||
| Streaming de eventos crudos (text/tool_use deltas) | `stream_anthropic_messages_py_core` |
|
||||
| Agente con TUS tools (tool-use loop) | `run_claude_tool_loop_py_core` (defines `tools` + `dispatch`) |
|
||||
| Token OAuth | `load_claude_oauth_token_py_core` (automático dentro de las anteriores) |
|
||||
| Distribuir fuera del registry | `apps/llm_cli/llm.py` (versión standalone autocontenida) |
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from core.ask_llm import ask_llm
|
||||
respuesta = ask_llm("resume esto en 3 lineas: ...", model="claude-haiku-4-5-20251001", echo=False)
|
||||
```
|
||||
|
||||
### Legacy
|
||||
|
||||
`claude_stream_go_core` (lanza `claude -p --output-format stream-json`) es el **camino antiguo**. No usarlo en código nuevo — preferir las funciones `claude-direct`. Queda solo para compatibilidad de consumidores existentes.
|
||||
|
||||
### Excepción acotada
|
||||
|
||||
Si una tarea necesita **genuinamente las capacidades de Claude Code** (sus tools nativas, los MCP del repo, plan mode, el contexto del proyecto) y no basta con el modelo + tus propias tools via `run_claude_tool_loop`, entonces NO es una "invocación LLM" simple: documenta por qué en el código. El **default sin excepción es `ask_llm`**.
|
||||
|
||||
### Telemetría / auditoría
|
||||
|
||||
Un `claude -p` o un `subprocess(["claude", "-p", ...])` en código nuevo es un antipatrón auditable: sustituir por `ask_llm` / `claude-direct`. Buscar usos: `grep -rn 'claude -p' --include='*.py' --include='*.sh' --include='*.go'`.
|
||||
|
||||
### Relación con otras reglas
|
||||
|
||||
- [[registry_calls]] — patrones canónicos de invocación de funciones; esta regla fija el patrón para la sub-tarea "invocar un LLM".
|
||||
- [[registry_first]] — reusar antes que reescribir; `ask_llm` es la función reutilizable para LLM.
|
||||
@@ -1,52 +0,0 @@
|
||||
## Slash commands por project (namespaced)
|
||||
|
||||
Cada `projects/<p>/` puede tener su propio `.claude/commands/*.md`. Para invocarlos desde la raiz de `fn_registry` sin que pisen los comandos globales, se exponen via **symlink namespaced** en `fn_registry/.claude/commands/<project>/`.
|
||||
|
||||
### Patron canonico
|
||||
|
||||
```
|
||||
projects/aurgi/.claude/commands/foo.md # archivo real (viaja con el sub-repo del project)
|
||||
fn_registry/.claude/commands/aurgi -> symlink -> ../../projects/aurgi/.claude/commands
|
||||
```
|
||||
|
||||
Resultado:
|
||||
|
||||
| cwd | Invocacion |
|
||||
|---|---|
|
||||
| `cd projects/aurgi && claude` | `/foo` (sin namespace) |
|
||||
| `cd fn_registry && claude` | `/aurgi:foo` (namespaced, no colisiona con `/foo` global) |
|
||||
|
||||
Subdirs dentro de `.claude/commands/` se exponen como namespace en el slash command. Por eso `aurgi/foo.md` -> `/aurgi:foo`.
|
||||
|
||||
### Como anadir un project nuevo
|
||||
|
||||
1. `mkdir -p projects/<p>/.claude/commands/`.
|
||||
2. Crear `<comando>.md` con frontmatter `description:` + cuerpo.
|
||||
3. Symlink: `ln -sf ../../projects/<p>/.claude/commands /home/egutierrez/fn_registry/.claude/commands/<p>`.
|
||||
4. Versionar el `.claude/commands/` del project en su propio sub-repo (NO en fn_registry — projects estan gitignored).
|
||||
5. Versionar SOLO el symlink en fn_registry (`git add .claude/commands/<p>`).
|
||||
|
||||
### Reglas
|
||||
|
||||
- Cada project mantiene autonomia: sus commands viajan con el sub-repo y funcionan tanto en `cd projects/<p>` como desde la raiz.
|
||||
- El symlink en fn_registry da acceso global con namespace — sin colision con commands del registry.
|
||||
- NO duplicar contenido: archivo real solo en `projects/<p>/.claude/commands/`. fn_registry solo guarda el symlink.
|
||||
- Si el project se mueve/elimina, borrar el symlink en fn_registry.
|
||||
|
||||
### Listado actual
|
||||
|
||||
| Project | Symlink | Commands disponibles desde fn_registry |
|
||||
|---|---|---|
|
||||
| aurgi | `.claude/commands/aurgi` | `/aurgi:aumentar_task`, `/aurgi:contexto_aurgi`, `/aurgi:anadir_contexto_aurgi` |
|
||||
|
||||
Anadir filas aqui al introducir un project nuevo con commands.
|
||||
|
||||
### Catalogo dinamico
|
||||
|
||||
Para listado en tiempo real (sin tener que actualizar esta tabla a mano): `/commands` escanea `.claude/commands/` recursivo y agrupa por namespace. Filtros: `/commands <substring>`, `/commands --ns <ns>`, `/commands --json`.
|
||||
|
||||
### Gotchas
|
||||
|
||||
- Claude Code lista los commands disponibles al inicio de sesion. Si un symlink apunta a un directorio inexistente, los commands no aparecen — verificar con `ls -L .claude/commands/<project>/`.
|
||||
- El namespace usa el nombre del subdirectorio (`aurgi/`), no del project en `projects/`. Mantenerlos iguales para evitar confusion.
|
||||
- Los commands del project se ejecutan con el cwd de la sesion actual. Un `/aurgi:aumentar_task` invocado desde `fn_registry/` corre con cwd `fn_registry/` — paths relativos en el `.md` deben asumir esto (siempre usar paths relativos al repo, ej. `projects/aurgi/vaults/...`).
|
||||
@@ -28,23 +28,6 @@ projects/{nombre}/
|
||||
- `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
|
||||
|
||||
### Cada project es su propio repo Gitea (sub-repo)
|
||||
|
||||
Desde 2026-06-05 cada `projects/<nombre>/` es un **repo Gitea independiente** `dataforge/<nombre>` (branch `master`), igual que las apps y los analyses. El repo del project versiona **solo las docs de nivel-project** (`project.md`, `CONVENTIONS.md` y demás `.md`/`.claude/` propios del project). El contenido de los hijos NO se versiona aquí: cada `apps/<app>/` y cada `analysis/<a>/` es su propio sub-repo Gitea y queda excluido por el `.gitignore` del project:
|
||||
|
||||
```gitignore
|
||||
apps/*/
|
||||
analysis/*/
|
||||
vaults/*
|
||||
!vaults/.gitkeep
|
||||
```
|
||||
|
||||
- **Crear el repo del project**: `ensure_repo_synced_bash_infra projects/<nombre> dataforge <nombre> master "init: project <nombre>"` (necesita `GITEA_URL` + `GITEA_TOKEN`; el token está en `pass gitea/dataforge-git-token`). Crear el `.gitignore` de arriba ANTES, para no trackear el contenido de los sub-repos hijos.
|
||||
- **Push/pull**: `/full-git-push` y `/full-git-pull` ya lo manejan automáticamente — `discover_git_repos_bash_infra` descubre cualquier `.git` bajo `fn_registry`, incluidos los projects.
|
||||
- **`repo_url`** en `project.md` apunta al repo del project; los `repo_url` de cada app viven en su `app.md`. Así el project "referencia" sus sub-repos sin git submodules (KISS).
|
||||
- El repo padre `fn_registry` sigue ignorando `projects/*/` entero (regla `apps_subrepo.md`): nunca trackea contenido de projects.
|
||||
- Estado actual: `dataforge/web_scraping`, `dataforge/fn_monitoring`, `dataforge/message_bus`.
|
||||
- Apps y analyses sueltos (sin proyecto) siguen en `apps/` y `analysis/` en la raiz
|
||||
|
||||
### Raiz vs proyecto
|
||||
|
||||
@@ -140,7 +140,7 @@ Cobertura por capa, no todas activas a la vez:
|
||||
### Que NO se monitoriza
|
||||
|
||||
- Funcion Go/C++ llamada internamente por app ya compilada.
|
||||
- Funcion ejecutada por systemd timer / cron / dag_engine **step `command:`** (no `function:`) sin pasar por `fn run`. Nota: dag_engine steps con `function:` SI quedan trazados — el executor invoca `fn run <id>` y guarda `function_id` en `dag_step_results`.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Append a one-liner [[fn_id]] — purpose to MEMORY.md after fn-constructor
|
||||
# creates a new registry function. Idempotent: skips if id already present.
|
||||
# Used by /fn_claude step 5b (issue 0087, pieza 6).
|
||||
#
|
||||
# Usage: append_fn_to_memory.sh <fn_id> "<one-line purpose>"
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
FN_ID="${1:-}"
|
||||
PURPOSE="${2:-}"
|
||||
|
||||
if [ -z "$FN_ID" ] || [ -z "$PURPOSE" ]; then
|
||||
echo "usage: append_fn_to_memory.sh <fn_id> <purpose>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
MEM_DIR="${CLAUDE_MEMORY_DIR:-/home/lucas/.claude/projects/-home-lucas-fn-registry/memory}"
|
||||
MEM_FILE="$MEM_DIR/MEMORY.md"
|
||||
|
||||
[ -d "$MEM_DIR" ] || { echo "memory dir missing: $MEM_DIR" >&2; exit 1; }
|
||||
[ -f "$MEM_FILE" ] || { echo "MEMORY.md missing: $MEM_FILE" >&2; exit 1; }
|
||||
|
||||
# Per-function reference file slug
|
||||
SLUG="reference_fn_${FN_ID}.md"
|
||||
REF_FILE="$MEM_DIR/$SLUG"
|
||||
|
||||
# Idempotency: if already linked in MEMORY.md, exit 0
|
||||
if grep -qF "[fn-$FN_ID]" "$MEM_FILE" 2>/dev/null; then
|
||||
echo "already in MEMORY.md: $FN_ID"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 1. Create reference memory file
|
||||
cat > "$REF_FILE" <<EOF
|
||||
---
|
||||
name: fn-$FN_ID
|
||||
description: Registry function $FN_ID — $PURPOSE
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
Registry function: \`$FN_ID\`
|
||||
|
||||
$PURPOSE
|
||||
|
||||
Invoke via \`./fn run $FN_ID [args]\` or \`mcp__registry__fn_run id="$FN_ID"\`. Inspect with \`mcp__registry__fn_show id="$FN_ID"\` / \`mcp__registry__fn_code id="$FN_ID"\`.
|
||||
EOF
|
||||
|
||||
# 2. Append index line to MEMORY.md
|
||||
printf -- '- [%s](%s) — %s\n' "fn-$FN_ID" "$SLUG" "$PURPOSE" >> "$MEM_FILE"
|
||||
|
||||
echo "appended: $FN_ID -> $MEM_FILE"
|
||||
@@ -1,63 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(CGO_ENABLED=1 go test *)",
|
||||
"Bash(sqlite3 *)"
|
||||
]
|
||||
},
|
||||
"enabledMcpjsonServers": [
|
||||
"registry",
|
||||
"jupyter"
|
||||
],
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_mcp.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_fn_match.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash|Edit|Write|MultiEdit|mcp__registry__.*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_call_monitor.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit|mcp__registry__fn_create_function",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capability_tag_gate.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capabilities_inject.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_first_reminder.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
#!/bin/bash
|
||||
# integrate-worktrees.sh — Integra branches de worktrees a master con --no-ff
|
||||
#
|
||||
# Uso: ./integrate-worktrees.sh <slug-1> <slug-2> ...
|
||||
# Ejemplo: ./integrate-worktrees.sh 0026-split-runtime 0027-prune-config-schema
|
||||
#
|
||||
# Para cada slug:
|
||||
# 1. git merge --no-ff issue/<slug> a master
|
||||
# 2. Verificar que master compila después del merge
|
||||
# 3. Si hay conflict o fallo de build, PARAR inmediatamente
|
||||
#
|
||||
# Los slugs deben pasarse en el orden correcto (waves ya resueltas).
|
||||
# NO hace push — eso lo decide el usuario.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "ERROR: se necesita al menos un slug"
|
||||
echo "Uso: $0 <slug-1> <slug-2> ..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Asegurar que estamos en master
|
||||
echo "=== Cambiando a master ==="
|
||||
cd "$REPO_ROOT"
|
||||
git checkout master
|
||||
|
||||
MERGED=0
|
||||
FAILED_AT=""
|
||||
|
||||
for slug in "$@"; do
|
||||
branch="issue/${slug}"
|
||||
|
||||
echo ""
|
||||
echo "=== Integrando: ${branch} ==="
|
||||
|
||||
# Verificar que la branch existe
|
||||
if ! git show-ref --verify --quiet "refs/heads/${branch}"; then
|
||||
echo "FAIL: branch ${branch} no existe"
|
||||
FAILED_AT="$slug"
|
||||
break
|
||||
fi
|
||||
|
||||
# Merge --no-ff
|
||||
if ! git merge --no-ff "$branch" -m "merge: ${branch} — implementación paralela"; then
|
||||
echo ""
|
||||
echo "CONFLICT: merge de ${branch} tiene conflictos"
|
||||
echo "Resolver manualmente y luego continuar con los slugs restantes"
|
||||
echo ""
|
||||
echo "Para resolver:"
|
||||
echo " 1. git status (ver archivos en conflicto)"
|
||||
echo " 2. Resolver conflictos en cada archivo"
|
||||
echo " 3. git add <archivos>"
|
||||
echo " 4. git commit"
|
||||
echo ""
|
||||
echo "Slugs pendientes después de ${slug}:"
|
||||
FOUND=0
|
||||
for remaining in "$@"; do
|
||||
if [ "$FOUND" -eq 1 ]; then
|
||||
echo " - ${remaining}"
|
||||
fi
|
||||
if [ "$remaining" = "$slug" ]; then
|
||||
FOUND=1
|
||||
fi
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "MERGED: ${branch}"
|
||||
|
||||
# Verificar que master sigue compilando (si BUILD_CMD esta definido)
|
||||
if [ -n "${BUILD_CMD:-}" ]; then
|
||||
echo "--- Verificando build post-merge ($BUILD_CMD) ---"
|
||||
if ! (cd "$REPO_ROOT" && bash -c "$BUILD_CMD" 2>&1); then
|
||||
echo ""
|
||||
echo "FAIL: master no compila despues de mergear ${branch}"
|
||||
echo "Revertir con: git reset --hard HEAD~1"
|
||||
echo "Investigar el problema antes de continuar."
|
||||
FAILED_AT="$slug"
|
||||
break
|
||||
fi
|
||||
echo "OK: build post-merge exitoso"
|
||||
else
|
||||
echo "--- Build post-merge SKIPPED (BUILD_CMD no definido) ---"
|
||||
fi
|
||||
|
||||
MERGED=$((MERGED + 1))
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Resumen de integración ==="
|
||||
echo "Mergeados: ${MERGED} de $#"
|
||||
|
||||
if [ -n "$FAILED_AT" ]; then
|
||||
echo "Falló en: ${FAILED_AT}"
|
||||
echo ""
|
||||
echo "Worktrees NO limpiados (resolver primero el fallo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Limpieza de worktrees y branches
|
||||
echo ""
|
||||
echo "=== Limpieza ==="
|
||||
for slug in "$@"; do
|
||||
path="${REPO_ROOT}/worktrees/${slug}"
|
||||
branch="issue/${slug}"
|
||||
|
||||
if [ -d "$path" ]; then
|
||||
git worktree remove "$path" 2>/dev/null && echo "REMOVED: worktree ${path}" || echo "WARN: no se pudo eliminar worktree ${path}"
|
||||
fi
|
||||
|
||||
git branch -d "$branch" 2>/dev/null && echo "DELETED: branch ${branch}" || echo "WARN: no se pudo eliminar branch ${branch}"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Integración completa ==="
|
||||
echo "Master tiene ${MERGED} merges nuevos."
|
||||
echo ""
|
||||
echo "Para publicar: git push"
|
||||
@@ -1,74 +0,0 @@
|
||||
#!/bin/bash
|
||||
# setup-worktrees.sh — Crea git worktrees para ejecución paralela de issues
|
||||
#
|
||||
# Uso: ./setup-worktrees.sh <slug-1> <slug-2> ...
|
||||
# Ejemplo: ./setup-worktrees.sh 0026-split-runtime 0027-prune-config-schema
|
||||
#
|
||||
# Cada slug genera:
|
||||
# worktrees/<slug>/ (worktree completo)
|
||||
# branch: issue/<slug>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
WORKTREE_DIR="${REPO_ROOT}/worktrees"
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "ERROR: se necesita al menos un slug de issue"
|
||||
echo "Uso: $0 <slug-1> <slug-2> ..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verificar master (NO pull --rebase: rompe merges locales convirtiendolos
|
||||
# en cherry-picks contra origin/master viejo). Detectado 2026-05-18.
|
||||
echo "=== Verificando master ==="
|
||||
CURRENT_BRANCH="$(git branch --show-current)"
|
||||
if [ "$CURRENT_BRANCH" != "master" ] && [ -n "$CURRENT_BRANCH" ]; then
|
||||
echo "WARN: estas en branch '${CURRENT_BRANCH}', no master. Worktrees nuevos saldran de master ref de todos modos."
|
||||
fi
|
||||
# NO auto-pull. Usuario decide sync con remote.
|
||||
|
||||
mkdir -p "$WORKTREE_DIR"
|
||||
|
||||
CREATED=0
|
||||
SKIPPED=0
|
||||
FAILED=0
|
||||
|
||||
for slug in "$@"; do
|
||||
branch="issue/${slug}"
|
||||
path="${WORKTREE_DIR}/${slug}"
|
||||
|
||||
if [ -d "$path" ]; then
|
||||
echo "SKIP: worktree ya existe: ${path}"
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Verificar que la branch no existe ya
|
||||
if git show-ref --verify --quiet "refs/heads/${branch}" 2>/dev/null; then
|
||||
echo "WARN: branch ${branch} ya existe, creando worktree desde ella"
|
||||
git worktree add "$path" "$branch" 2>/dev/null || {
|
||||
echo "FAIL: no se pudo crear worktree para ${slug}"
|
||||
FAILED=$((FAILED + 1))
|
||||
continue
|
||||
}
|
||||
else
|
||||
echo "CREATE: worktree ${path} (branch ${branch})"
|
||||
git worktree add -b "$branch" "$path" master 2>/dev/null || {
|
||||
echo "FAIL: no se pudo crear worktree para ${slug}"
|
||||
FAILED=$((FAILED + 1))
|
||||
continue
|
||||
}
|
||||
fi
|
||||
|
||||
CREATED=$((CREATED + 1))
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Resumen ==="
|
||||
echo "Creados: ${CREATED}"
|
||||
echo "Existentes: ${SKIPPED}"
|
||||
echo "Fallidos: ${FAILED}"
|
||||
echo ""
|
||||
echo "=== Worktrees activos ==="
|
||||
git worktree list
|
||||
@@ -1,165 +0,0 @@
|
||||
#!/bin/bash
|
||||
# verify-worktree.sh — Verifica build, tests y cierre de issue en un worktree.
|
||||
#
|
||||
# Uso:
|
||||
# ./verify-worktree.sh <worktree-path> [build-cmd] [test-cmd]
|
||||
#
|
||||
# Ejemplos:
|
||||
# ./verify-worktree.sh worktrees/0026-foo
|
||||
# ./verify-worktree.sh worktrees/0026-foo "go build -tags fts5 ./..." "go test -tags fts5 ./..."
|
||||
# BUILD_CMD="cmake --build cpp/build" TEST_CMD="ctest --test-dir cpp/build" ./verify-worktree.sh worktrees/0026-foo
|
||||
#
|
||||
# Resolucion de comandos (en orden de prioridad):
|
||||
# 1. Argumentos posicionales (build-cmd, test-cmd)
|
||||
# 2. Variables de entorno BUILD_CMD / TEST_CMD
|
||||
# 3. Archivo .parallel-fix-issues.yml en la raiz del worktree (claves: build, test)
|
||||
# 4. Auto-deteccion segun ficheros del proyecto:
|
||||
# - go.mod → "go build ./..." + "go test ./..."
|
||||
# - CMakeLists.txt → "cmake -S . -B build && cmake --build build" + "ctest --test-dir build"
|
||||
# - Cargo.toml → "cargo build" + "cargo test"
|
||||
# - package.json → "npm run build" + "npm test"
|
||||
# - pyproject.toml → "" + "pytest"
|
||||
# 5. Si nada se detecta, salta build/test con WARN.
|
||||
#
|
||||
# Auto-deteccion adicional: si hay go.mod, intenta extraer build tag de //go:build.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 = todo OK
|
||||
# 1 = error de argumento
|
||||
# 2 = build fallo
|
||||
# 3 = tests fallaron
|
||||
# 4 = issue no cerrado (solo WARN, no falla)
|
||||
# 5 = sin commits propios
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "ERROR: se necesita el path del worktree"
|
||||
echo "Uso: $0 <worktree-path> [build-cmd] [test-cmd]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WORKTREE="$1"
|
||||
ARG_BUILD_CMD="${2:-}"
|
||||
ARG_TEST_CMD="${3:-}"
|
||||
|
||||
# Resolver path absoluto
|
||||
if [[ "$WORKTREE" != /* ]]; then
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
WORKTREE="${REPO_ROOT}/${WORKTREE}"
|
||||
fi
|
||||
|
||||
if [ ! -d "$WORKTREE" ]; then
|
||||
echo "ERROR: worktree no encontrado: ${WORKTREE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SLUG="$(basename "$WORKTREE")"
|
||||
echo "=== Verificando: ${SLUG} ==="
|
||||
|
||||
# --- Resolver build/test commands ---
|
||||
BUILD_CMD="${ARG_BUILD_CMD:-${BUILD_CMD:-}}"
|
||||
TEST_CMD="${ARG_TEST_CMD:-${TEST_CMD:-}}"
|
||||
|
||||
# Manifest opcional
|
||||
MANIFEST="${WORKTREE}/.parallel-fix-issues.yml"
|
||||
if [ -z "$BUILD_CMD" ] && [ -f "$MANIFEST" ]; then
|
||||
M_BUILD=$(grep -E "^build:" "$MANIFEST" 2>/dev/null | sed -E 's/^build:[[:space:]]*"?([^"]*)"?[[:space:]]*$/\1/' | head -1 || true)
|
||||
if [ -n "$M_BUILD" ]; then BUILD_CMD="$M_BUILD"; echo "INFO: build desde manifest"; fi
|
||||
fi
|
||||
if [ -z "$TEST_CMD" ] && [ -f "$MANIFEST" ]; then
|
||||
M_TEST=$(grep -E "^test:" "$MANIFEST" 2>/dev/null | sed -E 's/^test:[[:space:]]*"?([^"]*)"?[[:space:]]*$/\1/' | head -1 || true)
|
||||
if [ -n "$M_TEST" ]; then TEST_CMD="$M_TEST"; echo "INFO: test desde manifest"; fi
|
||||
fi
|
||||
|
||||
# Auto-deteccion
|
||||
if [ -z "$BUILD_CMD" ] || [ -z "$TEST_CMD" ]; then
|
||||
AUTO_BUILD=""
|
||||
AUTO_TEST=""
|
||||
if [ -f "${WORKTREE}/go.mod" ]; then
|
||||
# Detectar build tag
|
||||
AUTO_TAG=$(grep -rh "^//go:build " --include="*.go" "$WORKTREE" 2>/dev/null \
|
||||
| sed -E 's|^//go:build ([a-zA-Z0-9_]+).*|\1|' \
|
||||
| sort -u | head -1 || true)
|
||||
TAG_FLAG=""
|
||||
[ -n "$AUTO_TAG" ] && TAG_FLAG="-tags $AUTO_TAG"
|
||||
AUTO_BUILD="go build $TAG_FLAG ./..."
|
||||
AUTO_TEST="go test $TAG_FLAG ./..."
|
||||
echo "INFO: stack detectado: Go${TAG_FLAG:+ ($TAG_FLAG)}"
|
||||
elif [ -f "${WORKTREE}/CMakeLists.txt" ] || ls "${WORKTREE}"/cpp/CMakeLists.txt >/dev/null 2>&1; then
|
||||
CMAKE_DIR="."
|
||||
[ -f "${WORKTREE}/cpp/CMakeLists.txt" ] && [ ! -f "${WORKTREE}/CMakeLists.txt" ] && CMAKE_DIR="cpp"
|
||||
AUTO_BUILD="cmake -S ${CMAKE_DIR} -B ${CMAKE_DIR}/build -DCMAKE_BUILD_TYPE=Release && cmake --build ${CMAKE_DIR}/build -j"
|
||||
AUTO_TEST="ctest --test-dir ${CMAKE_DIR}/build --output-on-failure || true"
|
||||
echo "INFO: stack detectado: C++/CMake (dir=${CMAKE_DIR})"
|
||||
elif [ -f "${WORKTREE}/Cargo.toml" ]; then
|
||||
AUTO_BUILD="cargo build"
|
||||
AUTO_TEST="cargo test"
|
||||
echo "INFO: stack detectado: Rust"
|
||||
elif [ -f "${WORKTREE}/package.json" ]; then
|
||||
AUTO_BUILD="npm run build --if-present"
|
||||
AUTO_TEST="npm test --if-present"
|
||||
echo "INFO: stack detectado: Node"
|
||||
elif [ -f "${WORKTREE}/pyproject.toml" ] || [ -f "${WORKTREE}/setup.py" ]; then
|
||||
AUTO_BUILD="" # python normalmente no tiene build step
|
||||
AUTO_TEST="pytest"
|
||||
echo "INFO: stack detectado: Python"
|
||||
else
|
||||
echo "WARN: no se detecto stack; usar BUILD_CMD/TEST_CMD env o manifest .parallel-fix-issues.yml"
|
||||
fi
|
||||
[ -z "$BUILD_CMD" ] && BUILD_CMD="$AUTO_BUILD"
|
||||
[ -z "$TEST_CMD" ] && TEST_CMD="$AUTO_TEST"
|
||||
fi
|
||||
|
||||
# 1. Verificar commits propios
|
||||
echo ""
|
||||
echo "--- Commits propios ---"
|
||||
COMMIT_COUNT=$(cd "$WORKTREE" && git log master..HEAD --oneline 2>/dev/null | wc -l)
|
||||
if [ "$COMMIT_COUNT" -eq 0 ]; then
|
||||
echo "FAIL: sin commits propios en la branch"
|
||||
exit 5
|
||||
fi
|
||||
echo "OK: ${COMMIT_COUNT} commits desde master"
|
||||
cd "$WORKTREE" && git log master..HEAD --oneline
|
||||
|
||||
# 2. Build
|
||||
echo ""
|
||||
if [ -n "$BUILD_CMD" ]; then
|
||||
echo "--- Build ($BUILD_CMD) ---"
|
||||
if (cd "$WORKTREE" && bash -c "$BUILD_CMD" 2>&1); then
|
||||
echo "OK: build exitoso"
|
||||
else
|
||||
echo "FAIL: build fallo"
|
||||
exit 2
|
||||
fi
|
||||
else
|
||||
echo "--- Build SKIPPED (sin comando) ---"
|
||||
fi
|
||||
|
||||
# 3. Tests
|
||||
echo ""
|
||||
if [ -n "$TEST_CMD" ]; then
|
||||
echo "--- Tests ($TEST_CMD) ---"
|
||||
if (cd "$WORKTREE" && bash -c "$TEST_CMD" 2>&1); then
|
||||
echo "OK: tests pasaron"
|
||||
else
|
||||
echo "FAIL: tests fallaron"
|
||||
exit 3
|
||||
fi
|
||||
else
|
||||
echo "--- Tests SKIPPED (sin comando) ---"
|
||||
fi
|
||||
|
||||
# 4. Issue cerrado
|
||||
echo ""
|
||||
echo "--- Cierre de issue ---"
|
||||
COMPLETED_FILES=$(cd "$WORKTREE" && git diff --name-only master -- dev/issues/completed/ 2>/dev/null | wc -l)
|
||||
if [ "$COMPLETED_FILES" -gt 0 ]; then
|
||||
echo "OK: issue movido a completed/"
|
||||
cd "$WORKTREE" && git diff --name-only master -- dev/issues/completed/
|
||||
else
|
||||
echo "WARN: no se detecto issue movido a completed/ (verificar manualmente)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== RESULTADO: ${SLUG} — OK ==="
|
||||
+3
-10
@@ -67,9 +67,8 @@ worktrees/
|
||||
# Temp — workspace efimero para pruebas rapidas (APIs, scripts, analisis)
|
||||
temp/
|
||||
|
||||
# C++ build artifacts (build/, build-tests/, build-windows/, etc.)
|
||||
cpp/build*/
|
||||
/build/
|
||||
# C++ build artifacts
|
||||
cpp/build/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -81,10 +80,4 @@ Thumbs.db
|
||||
broken_paths.txt
|
||||
imgui.ini
|
||||
prompts/
|
||||
|
||||
# Module versioning auto-generated headers (written by `fn index`, issue 0097)
|
||||
**/version_generated.h
|
||||
**/app_modules_generated.h
|
||||
|
||||
# Issue migration backups (0100)
|
||||
dev/issues/.backup_pre_*
|
||||
kotlin/functions/ui/
|
||||
|
||||
@@ -1,29 +1,22 @@
|
||||
[submodule "cpp/vendor/imgui"]
|
||||
path = cpp/vendor/imgui
|
||||
url = https://github.com/ocornut/imgui.git
|
||||
shallow = true
|
||||
branch = docking
|
||||
[submodule "cpp/vendor/implot"]
|
||||
path = cpp/vendor/implot
|
||||
url = https://github.com/epezent/implot.git
|
||||
shallow = true
|
||||
[submodule "cpp/vendor/tracy"]
|
||||
path = cpp/vendor/tracy
|
||||
url = https://github.com/wolfpld/tracy.git
|
||||
shallow = true
|
||||
[submodule "cpp/vendor/glfw"]
|
||||
path = cpp/vendor/glfw
|
||||
url = https://github.com/glfw/glfw.git
|
||||
shallow = true
|
||||
[submodule "cpp/vendor/implot3d"]
|
||||
path = cpp/vendor/implot3d
|
||||
url = https://github.com/brenocq/implot3d.git
|
||||
shallow = true
|
||||
[submodule "cpp/vendor/sdl3"]
|
||||
path = cpp/vendor/sdl3
|
||||
url = https://github.com/libsdl-org/SDL.git
|
||||
shallow = true
|
||||
[submodule "emsdk"]
|
||||
path = emsdk
|
||||
url = https://github.com/emscripten-core/emsdk.git
|
||||
shallow = true
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"0ea5e69b-9607-4f11-b740-005e835faef6": {
|
||||
"version": "2.4.0",
|
||||
"created_at": "2026-06-03T17:52:16.077873+00:00",
|
||||
"document_version": "2.0.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -3,10 +3,6 @@
|
||||
"registry": {
|
||||
"command": "./apps/registry_mcp/registry_mcp",
|
||||
"args": ["--enable-run", "--enable-write"]
|
||||
},
|
||||
"jupyter": {
|
||||
"command": "bash",
|
||||
"args": ["/home/enmanuel/fn_registry/bash/functions/infra/jupyter_mcp_serve.sh"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,68 +8,6 @@ Para contexto detallado del trabajo diario ver `docs/diary/`. Para decisiones ar
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## 2026-05-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Bloque `service:` en frontmatter de `app.md`** (issue 0105) — toda app con `tag: service` declara ahora `port`, `health_endpoint`, `health_timeout_s`, `systemd_unit`, `systemd_scope`, `restart_policy`, `runtime` (`systemd-user|systemd-system|docker-compose|stdio|manual`), `pc_targets[]`, `is_local_only`. 11 apps actualizadas: `sqlite_api`, `dag_engine`, `call_monitor`, `kanban`, `deploy_server`, `registry_mcp`, `registry_api`, `footprint_geo_stack`, `element_matrix_chat`, `agents_and_robots`, `services_api`.
|
||||
- **Migration `014_service_metadata.sql`** — anade 8 columnas (`service_port`, `service_health_endpoint`, `service_health_timeout_s`, `service_systemd_unit`, `service_systemd_scope`, `service_restart_policy`, `service_runtime`, `service_is_local_only`) a `apps` + tabla nueva `service_targets (app_id, pc_id, role)` con indices por `app_id` y `pc_id`.
|
||||
- **`registry.App.Service *ServiceSpec`** + parser `rawService` + escritura/lectura en `InsertApp`/`scanApps`/`Purge` (preserva `service_targets`). API publica `db.GetServicePCTargets(appID) []string`.
|
||||
- **`audit_services_spec_go_infra`** (`functions/infra/audit_services_spec.{go,md}`) — audita apps `tag: service` y reporta drift del bloque `service:` (runtime allowlist, pc_targets >=1, systemd_unit obligatorio si `runtime` empieza con `systemd-`, restart_policy en `always|on-failure|none`).
|
||||
- **`fn doctor services-spec`** — subcomando nuevo en `cmd/fn/doctor.go`. Salida tabwriter + `--json`. Hoy: `11/11 services with complete service: block`.
|
||||
- **App `services_api`** (`apps/services_api/`, issue 0106) — Go HTTP daemon en `127.0.0.1:8485`. Loop paralelo cada 15s (max 8 in-flight, timeout 20s/probe) que reconcilia esperado vs real para cada `(app, pc)` cruzado de `service_targets`. Probes locales (`systemctl is-active` + TCP dial + `http.Client`) o remotos (`ssh_exec_go_infra`). Persiste en `operations.db`: `service_state` (snapshot actual) + `service_transition` (cambios de overall append-only). Endpoints `GET /api/health`, `GET /api/services`, `POST /api/check`, `GET /api/pcs`. systemd unit `~/.config/systemd/user/services_api.service` con `Restart=always`.
|
||||
- **App `services_monitor`** (`apps/services_monitor/`, issue 0106) — frontend C++ ImGui. Polling auto cada 5s configurable + boton "Force check" (POST `/api/check`). Tabla 9-col agrupada por app: overall pill, systemd state, port + listening flag (`TI_PLUG`/`TI_PLUG_CONNECTED`), HTTP status+latency, runtime, last change age, error/note. JSON via `vendor/nlohmann/json.hpp` (copiado de data_factory). HTTP socket TCP via `http_client.{cpp,h}` (copiado de data_factory). Build linux + windows con `add_imgui_app` + ws2_32 en Win. Deploy automatico via `redeploy_cpp_app_windows`.
|
||||
- **Issues 0105 + 0106** (`dev/issues/`) — estandarizacion del bloque `service:` y app `services_monitor`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`sqlite_api.service` murio 20h sin alerta el 2026-05-17** — Raiz: el unit tenia `Restart=on-failure` y el ultimo exit fue por `SIGTERM` (limpio, no failure). systemd NO reinicia exit success. Fix: cambio a `Restart=always` + `RestartSec=5`. Reload + restart inmediato. Detectado mientras se debuggeaba `data_factory` cargando lento (raiz: data_factory llama a `sqlite_api:8484`, timeout 3s, no responde). Aplicado el mismo `Restart=always` al unit nuevo `services_api.service`.
|
||||
- **`sqlite_api/app.md` health_endpoint** — declaraba `/api/status` que devuelve 404. Cambiado a `/api/databases` (200, lista de bases registradas). Detectado por el primer ciclo del propio `services_api` que marcaba sqlite_api como `degraded`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`services_monitor` tags** — sin `service`/`services` en `tags` para evitar falso positivo en el matcher `tags LIKE '%service%'` del audit `services-spec`. La app es desktop client (frontend), no daemon.
|
||||
|
||||
## 2026-05-16
|
||||
|
||||
### Added
|
||||
|
||||
- **Panel "Logs" en `dag_engine` RunDetail** — `apps/dag_engine/frontend/src/pages/RunDetail.tsx` anade `<Paper>` final con `<Code block>` scrollable + `CopyButton` de Mantine. Helper `buildLogText(run, steps)` compone texto plano (metadata del run + por-step status/exit/duration/stdout/stderr indentado) para pegar entero al LLM sin abrir los `Collapse` del `StepTimeline`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dag_engine` steps `function:` fallando con `error: function "<id>" not found (tried as ID and name)`** — tres DAGs nocturnos (`fn_backup` x2, `daily-registry-audit`) fallaron 2026-05-15/16 porque el binario `fn` resolvia una copia stale `apps/dag_engine/registry.db` (May 15, 262 KB) en vez del `registry.db` raiz. Raiz: el systemd unit `dag_engine.service` tiene `WorkingDirectory=apps/dag_engine/` y no exportaba `FN_REGISTRY_ROOT`; `cmd/fn/ops.go::tryOpenRegistryDB` cae al walk-up `go.mod` (devuelve `apps/dag_engine/`). Fix:
|
||||
- Borrado `apps/dag_engine/registry.db` stale (violaba `.claude/rules/db_locations.md`).
|
||||
- `~/.config/systemd/user/dag_engine.service`: anadido `Environment=FN_REGISTRY_ROOT`, `FN_BIN`, `PATH` (con `/usr/local/go/bin` para steps `function:` Go sin tests que invocan `go vet`), `HOME`.
|
||||
- `apps/dag_engine/executor.go`: steps `function:` exportan `FN_REGISTRY_ROOT=<root>` en env y default `dir = fnRegistryRoot` si `step.Dir`/`dag.WorkingDir` vacios. Steps `command:`/`script:` sin cambio.
|
||||
|
||||
### Added
|
||||
|
||||
- **Iconos `.ico` Windows para apps C++** — 11 apps GUI (`chart_demo`, `dag_engine_ui`, `data_factory`, `graph_explorer`, `navegator_dashboard`, `odr_console`, `primitives_gallery`, `registry_dashboard`, `shaders_lab`, `text_editor_smoke`, `altsnap_jitter_test`) ahora tienen icono propio en el `.exe` y en `<exe_dir>` desplegado.
|
||||
- Glyphs: **Phosphor Icons** (`fill` weight), clonado en `sources/phosphor-core/` (1512 SVGs disponibles). Cada app usa un `accent_hex` distinto (Tailwind 500-700) para distinguirse en taskbar/desktop.
|
||||
- Mapping inicial en `dev/gen_app_icons.py` (script reproducible). Cada `.ico` multi-resolucion (16/24/32/48/64/128/256).
|
||||
- Wiring CMake: `cpp/CMakeLists.txt:1-5` declara `LANGUAGES C CXX RC` en WIN32; `add_imgui_app` macro detecta `<app_dir>/appicon.ico` y genera `<target>_appicon.rc` enlazado via `windres` (toolchain `cpp/toolchains/mingw-w64.cmake`).
|
||||
- Nueva funcion del registry: `generate_app_icon_py_infra` (`python/functions/infra/generate_app_icon.{py,md}`). Toma `phosphor_icon_name + accent_hex + out_ico_path` y exporta `.ico` multi-res. Tags: `cpp-windows`, `icon`, `phosphor`.
|
||||
- Convencion documentada en `.claude/rules/cpp_apps.md §11`.
|
||||
|
||||
- **C++ framework — Alt+RMB resize / Alt+LMB move anywhere** (`cpp/framework/app_base.cpp`). WndProc subclass detecta `WM_RBUTTONDOWN`/`WM_LBUTTONDOWN` con `GetAsyncKeyState(VK_MENU) & 0x8000`, `ReleaseCapture` + `PostMessage(WM_SYSCOMMAND, SC_SIZE|dir | SC_MOVE|HTCAPTION)`. Modal nativo, cero jitter automatico via gate sizemove existente. Aplica a main + cada viewport flotante (subclass per-frame).
|
||||
- **C++ framework — multi-HWND subclass** para anti-jitter. `g_subclassed` ahora `unordered_map<HWND, WNDPROC>`, scan per-frame en `pio.Viewports` instala subclass en cada HWND nuevo, `prune_dead_subclassed()` con `IsWindow`, `uninstall_sizemove_subclass_all()` al exit. Fix del temblor en paneles flotantes (no solo el main HWND).
|
||||
- **C++ framework — iconified survival** de paneles flotantes. Antes `glfwWaitEvents+continue` paraba el frame loop entero al minimizar el main → secondary viewports congelados/ocultos. Ahora detecta secondary viewports y fall-through al frame normal si existen; solo duerme cuando no hay flotantes.
|
||||
- **C++ framework — `fn::internal::*` test observability**. `sizemove_enter_count()`, `alt_rmb_resize_count()`, `alt_lmb_move_count()`, `rbuttondown_seen_count()`, `set_force_alt_for_test(bool)`. Counters monotonicos zero-cost, modo test salta `PostMessage SC_SIZE/SC_MOVE` para no atrapar al harness en modal.
|
||||
- **`apps/altsnap_jitter_test/`** — extendido a 6 phases (p1 sync, p2 main HWND modal, p3 secondary HWND modal, p4 iconify+restore preserva floating, p5 Alt+RMB consumed, p6 Alt+LMB consumed). Todas PASS en Windows.
|
||||
- **`redeploy_all_cpp_apps_bash_pipelines`** — pipeline nuevo `bash/functions/pipelines/redeploy_all_cpp_apps.sh` que cross-compila todo el arbol `cpp/` en un solo cmake pass + redeploy de cada `.exe` al Desktop. Filtro opcional por substring de nombre. Tolerante a fallos (build best-effort, summary OK/SKIPPED/FAILED). Tags: `cpp, windows, deploy, redeploy, bulk, cpp-windows`. Composicion: `build_cpp_windows_bash_infra` + loop `taskkill.exe` + `deploy_cpp_exe_to_windows_bash_infra`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`io.ConfigWindowsMoveFromTitleBarOnly = true`** en `fn::run_app`. Floating panels (viewport secundario = OS window borderless con UNA ventana ImGui rellenandolo) ahora respetan "solo header arrastra" como las decoradas. Fix del drag-anywhere-sin-alt en panel flotante. Alt+LMB anywhere sigue funcionando (subclass consume antes que ImGui).
|
||||
- **`resolve_cpp_app_dir_bash_infra` v1.1.0** — ahora busca apps tambien en `apps/<X>/` (canonical issue 0096) ademas de `cpp/apps/<X>/` (legacy) y `projects/*/apps/<X>/`. Fix retroactivo: `./fn run compile_cpp_app <name>` fallaba para apps en el layout canonical (ej. `dag_engine_ui`). Deduccion desde CWD tambien actualizada. Helper interno `_list_cpp_apps`.
|
||||
|
||||
### Notes
|
||||
|
||||
- Apps C++ redesplegadas via `redeploy_all_cpp_apps`: 12 OK / 1 SKIP (`data_factory` sin .exe target) / 0 FAILED. Todas tienen los fixes del framework activos.
|
||||
- ImGui_ImplGlfw subclassea el HWND DESPUES que nuestro framework. ImGui captura nuestro WndProc como `PrevWndProc` y chainea via `CallWindowProc`, asi que el subclass nuestro sigue recibiendo TODOS los mensajes en el orden correcto. NO re-subclassear despues de ImGui init (provoca recursion infinita por cycle: `our_proc -> orig=imgui_proc -> imgui_proc -> prev=our_proc -> ...`).
|
||||
- Pre-existing build break en `cpp/tests/test_llm_anthropic.cpp` + `cpp/tests/test_graph_icons.cpp` por uso de `setenv()` que no existe en mingw-w64. NO bloquea `redeploy_all_cpp_apps` (build best-effort). Candidato a guard `#ifdef _WIN32` con `_putenv_s` o skip cross-compile. No introducido por esta sesion.
|
||||
|
||||
## 2026-05-14
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
[2026-05-22 23:18:14.872] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:24:12.811] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:24:14.628] [INFO] [connect] testing https://agents.organic-machine.com...
|
||||
[2026-05-22 23:24:14.758] [INFO] [connect] OK
|
||||
[2026-05-22 23:24:14.765] [INFO] [db] base_url saved
|
||||
[2026-05-22 23:24:14.765] [INFO] [fetch_agents] starting
|
||||
[2026-05-22 23:24:14.766] [INFO] [fetch_agents] requesting https://agents.organic-machine.com/agents
|
||||
[2026-05-22 23:24:14.903] [INFO] [fetch_agents] response status=200 err= body_len=3146
|
||||
[2026-05-22 23:24:14.904] [INFO] [fetch_agents] parsed 11 rows
|
||||
[2026-05-22 23:24:14.904] [INFO] [fetch_agents] done
|
||||
[2026-05-22 23:24:14.910] [INFO] [agents_panel] render n_rows=11 cells=121 specs=11
|
||||
[2026-05-22 23:27:07.469] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:27:08.242] [INFO] [agents_panel] render n_rows=11 cells=121 specs=11
|
||||
[2026-05-22 23:27:36.670] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:27:37.446] [INFO] [agents_panel] render n_rows=11 cells=121 specs=11
|
||||
[2026-05-22 23:28:07.068] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:30:03.025] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:30:38.605] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:30:48.267] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:40:58.931] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:41:16.455] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:42:35.646] [INFO] app start: Agents Dashboard
|
||||
@@ -1,4 +0,0 @@
|
||||
[2026-05-15 23:51:43.764] [INFO] app start: altsnap_jitter_test
|
||||
[2026-05-15 23:51:44.017] [INFO] app exit
|
||||
[2026-05-15 23:52:47.933] [INFO] app start: altsnap_jitter_test
|
||||
[2026-05-15 23:52:48.135] [INFO] app exit
|
||||
@@ -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.
@@ -1,71 +0,0 @@
|
||||
---
|
||||
name: apply_chromium_cdp_flag
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "apply_chromium_cdp_flag [--port N] [--network] [--fragment-path <path>] [--remove] [--dry-run]"
|
||||
description: "Gestiona de forma idempotente el fragmento /etc/chromium.d/cdp que activa Chrome DevTools Protocol global en todo chromium que el usuario lance en el equipo. Escribe, actualiza o borra el fragmento con backup automático."
|
||||
tags: [navegator, chromium, cdp, devtools, browser, automation, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
params:
|
||||
- name: "--port N"
|
||||
desc: "Puerto TCP donde Chromium escuchará conexiones CDP. Default 9222."
|
||||
- name: "--network"
|
||||
desc: "Si se pasa, añade --remote-debugging-address=0.0.0.0 (accesible desde la red local). Por defecto solo loopback (127.0.0.1). Imprime advertencia de seguridad."
|
||||
- name: "--fragment-path <path>"
|
||||
desc: "Ruta del fragmento a escribir/borrar. Default /etc/chromium.d/cdp."
|
||||
- name: "--remove"
|
||||
desc: "Borra el fragmento (desactiva CDP global). Idempotente: si no existe, no-op."
|
||||
- name: "--dry-run"
|
||||
desc: "Imprime el fragmento que se escribiría sin tocar nada. No requiere sudo."
|
||||
output: "Sale 0 en éxito (aplicado, ya-aplicado, o eliminado). Sale != 0 en error con mensaje a stderr. En caso de actualización imprime ruta del backup creado."
|
||||
file_path: "bash/functions/browser/apply_chromium_cdp_flag.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Activar CDP global en loopback puerto 9222 (proyecto web_scraping, regla 8)
|
||||
source bash/functions/browser/apply_chromium_cdp_flag.sh
|
||||
apply_chromium_cdp_flag
|
||||
|
||||
# Previsualizar el fragmento sin escribir nada (no requiere sudo)
|
||||
apply_chromium_cdp_flag --port 9222 --dry-run
|
||||
|
||||
# Puerto alternativo (para correr en paralelo al navegador del usuario)
|
||||
apply_chromium_cdp_flag --port 9333
|
||||
|
||||
# Activar expuesto a la red local (RIESGO: cualquier host de la LAN puede controlar el navegador)
|
||||
apply_chromium_cdp_flag --port 9222 --network
|
||||
|
||||
# Desactivar CDP global
|
||||
apply_chromium_cdp_flag --remove
|
||||
|
||||
# Ruta personalizada (útil en pruebas o entornos chroot)
|
||||
apply_chromium_cdp_flag --port 9222 --fragment-path /tmp/test_cdp_fragment --dry-run
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Al preparar un PC nuevo para controlar el chromium diario del usuario vía CDP (primer setup del proyecto `web_scraping`, regla 8). Al cambiar el puerto CDP del sistema. Al desactivar esa capacidad antes de prestar o formatear el equipo. Sustituye el paso manual "crear `/etc/chromium.d/cdp` con sudo" documentado en `CHROMIUM_SYSTEM.md`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Requiere sudo** para escribir bajo `/etc/`. En este equipo usar `pass show claude/sudo | sudo -S apply_chromium_cdp_flag` o ejecutar como root.
|
||||
- **`--network` (0.0.0.0) es un riesgo de seguridad serio**: cualquier máquina en la red local puede conectarse al puerto CDP y controlar Chromium completamente (leer cookies, sesiones, inyectar JavaScript). Solo usar en entornos de red aislados o laboratorios.
|
||||
- **El chromium ya abierto antes del cambio no hereda el flag** hasta que se reinicie. El fragmento solo se aplica en el próximo arranque de `/usr/bin/chromium`.
|
||||
- **Dos procesos chromium no pueden compartir el mismo puerto**. Si el usuario ya tiene un chromium con CDP en 9222, la automatización dedicada debe arrancar con `chrome_launch_go_browser` en otro puerto (ej. 9333) o usar `--port 9333` en esta función.
|
||||
- **Idempotente**: si el fragmento ya existe con contenido idéntico, la función sale 0 sin tocar nada ni crear backup.
|
||||
- **Backup automático**: al sobreescribir, crea `<path>.bak.YYYYMMDD`. Si ya existe un backup del mismo día, no lo sobreescribe (el primero del día se preserva).
|
||||
- **Validación post-escritura**: tras escribir, verifica con `grep` que la línea `export CHROMIUM_FLAGS` con el puerto correcto quedó en el archivo. Si falla, restaura el backup y sale con error.
|
||||
- Ver `projects/web_scraping/CHROMIUM_SYSTEM.md` para el contexto completo del sistema CDP de este equipo.
|
||||
@@ -1,205 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# apply_chromium_cdp_flag — gestiona el fragmento /etc/chromium.d/cdp que activa CDP global.
|
||||
#
|
||||
# Uso:
|
||||
# apply_chromium_cdp_flag [--port N] [--network] [--fragment-path <path>] [--remove] [--dry-run]
|
||||
|
||||
apply_chromium_cdp_flag() {
|
||||
local port=9222
|
||||
local network=0
|
||||
local fragment_path="/etc/chromium.d/cdp"
|
||||
local remove=0
|
||||
local dry_run=0
|
||||
|
||||
# Parseo de argumentos
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port)
|
||||
port="$2"
|
||||
shift 2
|
||||
;;
|
||||
--network)
|
||||
network=1
|
||||
shift
|
||||
;;
|
||||
--fragment-path)
|
||||
fragment_path="$2"
|
||||
shift 2
|
||||
;;
|
||||
--remove)
|
||||
remove=1
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "apply_chromium_cdp_flag: argumento desconocido: $1" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validación del puerto
|
||||
if ! [[ "$port" =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then
|
||||
echo "apply_chromium_cdp_flag: puerto inválido: $port (debe ser 1-65535)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Construcción del contenido del fragmento
|
||||
local flags_line
|
||||
if (( network )); then
|
||||
echo "ADVERTENCIA DE SEGURIDAD: --network activa --remote-debugging-address=0.0.0.0." >&2
|
||||
echo "El navegador quedará expuesto a toda la red local. Cualquier host en la red" >&2
|
||||
echo "podrá controlar Chromium remotamente y leer todas las sesiones abiertas." >&2
|
||||
flags_line='export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --remote-debugging-port='"${port}"' --remote-allow-origins=* --remote-debugging-address=0.0.0.0"'
|
||||
else
|
||||
flags_line='export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --remote-debugging-port='"${port}"' --remote-allow-origins=*"'
|
||||
fi
|
||||
|
||||
local mode_label
|
||||
if (( network )); then
|
||||
mode_label="network (0.0.0.0)"
|
||||
else
|
||||
mode_label="loopback (127.0.0.1)"
|
||||
fi
|
||||
|
||||
local fragment_content
|
||||
fragment_content="# CDP global para automatizacion del navegador del usuario (proyecto web_scraping, regla 8).
|
||||
# Bind ${mode_label} por defecto: el puerto solo
|
||||
# es accesible desde 127.0.0.1, no desde la red.
|
||||
${flags_line}"
|
||||
|
||||
# Modo --dry-run: solo mostrar y salir
|
||||
if (( dry_run )); then
|
||||
echo "--- dry-run: fragmento que se escribiría en ${fragment_path} ---"
|
||||
echo "${fragment_content}"
|
||||
echo "--- fin dry-run ---"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Modo --remove
|
||||
if (( remove )); then
|
||||
if [[ ! -e "$fragment_path" ]]; then
|
||||
echo "apply_chromium_cdp_flag: ${fragment_path} no existe, nada que borrar."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local backup_path="${fragment_path}.bak.$(date +%Y%m%d)"
|
||||
if [[ ! -e "$backup_path" ]]; then
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
cp "$fragment_path" "$backup_path"
|
||||
else
|
||||
sudo cp "$fragment_path" "$backup_path" || {
|
||||
echo "apply_chromium_cdp_flag: no se pudo crear backup en ${backup_path}" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
rm "$fragment_path"
|
||||
else
|
||||
sudo rm "$fragment_path" || {
|
||||
echo "apply_chromium_cdp_flag: no se pudo borrar ${fragment_path}" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
echo "apply_chromium_cdp_flag: fragmento eliminado (backup: ${backup_path})"
|
||||
echo "Nota: el chromium ya abierto antes de este cambio no lo hereda hasta reiniciarlo."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Idempotencia: comparar con contenido actual
|
||||
if [[ -f "$fragment_path" ]]; then
|
||||
local current_content
|
||||
current_content=$(cat "$fragment_path" 2>/dev/null)
|
||||
if [[ "$current_content" == "$fragment_content" ]]; then
|
||||
echo "apply_chromium_cdp_flag: ya aplicado, sin cambios (${fragment_path})."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Crear directorio si falta
|
||||
local fragment_dir
|
||||
fragment_dir=$(dirname "$fragment_path")
|
||||
if [[ ! -d "$fragment_dir" ]]; then
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
mkdir -p "$fragment_dir"
|
||||
else
|
||||
sudo mkdir -p "$fragment_dir" || {
|
||||
echo "apply_chromium_cdp_flag: no se pudo crear ${fragment_dir}" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
fi
|
||||
|
||||
# Backup si ya existe y difiere
|
||||
if [[ -e "$fragment_path" ]]; then
|
||||
local backup_path="${fragment_path}.bak.$(date +%Y%m%d)"
|
||||
if [[ ! -e "$backup_path" ]]; then
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
cp "$fragment_path" "$backup_path"
|
||||
else
|
||||
sudo cp "$fragment_path" "$backup_path" || {
|
||||
echo "apply_chromium_cdp_flag: no se pudo crear backup en ${backup_path}" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
echo "apply_chromium_cdp_flag: backup creado en ${backup_path}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Escribir fragmento
|
||||
local tmpfile
|
||||
tmpfile=$(mktemp)
|
||||
printf '%s\n' "$fragment_content" > "$tmpfile"
|
||||
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
cp "$tmpfile" "$fragment_path"
|
||||
chmod 644 "$fragment_path"
|
||||
else
|
||||
sudo cp "$tmpfile" "$fragment_path" || {
|
||||
rm -f "$tmpfile"
|
||||
echo "apply_chromium_cdp_flag: no se pudo escribir ${fragment_path}" >&2
|
||||
return 1
|
||||
}
|
||||
sudo chmod 644 "$fragment_path" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$tmpfile"
|
||||
|
||||
# Validación post-escritura
|
||||
local expected_line="--remote-debugging-port=${port}"
|
||||
if ! grep -qF "$expected_line" "$fragment_path" 2>/dev/null; then
|
||||
echo "apply_chromium_cdp_flag: validación fallida — la línea export no apareció en ${fragment_path}." >&2
|
||||
# Restaurar backup si existe
|
||||
local backup_path="${fragment_path}.bak.$(date +%Y%m%d)"
|
||||
if [[ -f "$backup_path" ]]; then
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
cp "$backup_path" "$fragment_path"
|
||||
else
|
||||
sudo cp "$backup_path" "$fragment_path" 2>/dev/null || true
|
||||
fi
|
||||
echo "apply_chromium_cdp_flag: backup restaurado desde ${backup_path}" >&2
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Resumen final
|
||||
echo "apply_chromium_cdp_flag: CDP global activado correctamente."
|
||||
echo " Fragmento : ${fragment_path}"
|
||||
echo " Puerto : ${port}"
|
||||
echo " Modo : ${mode_label}"
|
||||
echo ""
|
||||
echo "Nota: el chromium ya abierto antes de este cambio no hereda el flag hasta reiniciarlo."
|
||||
echo "Nota: dos procesos chromium no pueden compartir el mismo puerto; usa --port <otro> para"
|
||||
echo " automatización dedicada que corra en paralelo al navegador del usuario."
|
||||
}
|
||||
|
||||
# Auto-ejecución al correr el archivo directo (bash file.sh / fn run). Si se hace `source`,
|
||||
# solo se define la función y no se ejecuta nada.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
apply_chromium_cdp_flag "$@"
|
||||
fi
|
||||
@@ -1,90 +0,0 @@
|
||||
---
|
||||
name: apply_chromium_extension_policy
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "apply_chromium_extension_policy [--keep <ext_id[=update_url]>]... [--block <ext_id>]... [--policy-path <path>] [--update-url <url>] [--dry-run]"
|
||||
description: "Escribe de forma idempotente la política managed de Chromium combinando ExtensionInstallForcelist (force-instala la whitelist --keep) y ExtensionInstallBlocklist (bloquea y desinstala la blocklist --block). No usa el comodín \"*\" blocked, por lo que NO afecta a las extensiones unpacked cargadas con --load-extension. Guarda backup fuera del directorio managed/ (que Chromium lee entero). Requiere sudo para escribir en /etc; en --dry-run no toca el sistema."
|
||||
tags: [chromium, extensions, policy, browser, navegator, managed-policy, idempotent]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "--keep <ext_id[=update_url]>"
|
||||
desc: "ID de extensión a force-instalar (repetible). Va a ExtensionInstallForcelist. Forma simple '<id>' usa el update_url por defecto (Web Store). Forma '<id>=<update_url>' fuerza una extensión self-hosted: por ejemplo '<id>=file:///home/u/.web_proxy/update.xml' instala un .crx local empaquetado bajo managed policy (necesario porque --load-extension queda desactivado cuando hay managed policy). Ejemplo Web Store: ddkjiahejlhfcafbddmgiahcphecmpfh (uBlock Origin Lite)."
|
||||
- name: "--block <ext_id>"
|
||||
desc: "ID de extensión a bloquear y desinstalar en cualquier perfil (repetible). Va a ExtensionInstallBlocklist. Solo afecta a los IDs listados; el resto de extensiones no se toca."
|
||||
- name: "--policy-path <path>"
|
||||
desc: "Ruta del JSON de managed policy. Default: /etc/chromium/policies/managed/extensions.json."
|
||||
- name: "--update-url <url>"
|
||||
desc: "URL del servicio de actualización de extensiones. Default: https://clients2.google.com/service/update2/crx."
|
||||
- name: "--dry-run"
|
||||
desc: "Imprime el JSON que se escribiría sin tocar el sistema (no requiere sudo)."
|
||||
output: "Escribe el JSON de política en policy-path y emite a stdout un resumen: extensiones forzadas, bloqueadas, ruta, backup creado y recordatorio de reinicio de Chromium. Sale 0 si la política se aplicó o ya estaba vigente. Sale != 0 en error. Requiere al menos un --keep o --block."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/browser/apply_chromium_extension_policy.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Dejar el perfil con solo uBlock Origin Lite: forzar uBlock + bloquear las 3 que estorban
|
||||
# al scraping (Dark Reader, NoScript, OneTab). Proyecto web_scraping, regla 9.
|
||||
source bash/functions/browser/apply_chromium_extension_policy.sh
|
||||
apply_chromium_extension_policy \
|
||||
--keep ddkjiahejlhfcafbddmgiahcphecmpfh \
|
||||
--block eimadpbcbfnmbkopoojfekhnkhdbieeh \
|
||||
--block doojmbjmlfjjnbmnoijecmcbfeoakpjm \
|
||||
--block chphlpgkkbolifaimnlloiipkdnihall
|
||||
|
||||
# Previsualizar sin tocar el sistema (sin sudo)
|
||||
apply_chromium_extension_policy --keep ddkjiahejlhfcafbddmgiahcphecmpfh --dry-run
|
||||
|
||||
# Ejecutar como root para el sudo no interactivo de este equipo
|
||||
pass show claude/sudo | sudo -S bash bash/functions/browser/apply_chromium_extension_policy.sh \
|
||||
--keep ddkjiahejlhfcafbddmgiahcphecmpfh --block eimadpbcbfnmbkopoojfekhnkhdbieeh
|
||||
```
|
||||
|
||||
La policy por sí sola evita la reinstalación pero NO desinstala lo ya presente en un perfil concreto:
|
||||
combínala con `clean_chrome_profile_extensions_bash_browser` (con Chromium cerrado) para purgar del
|
||||
disco las extensiones ya instaladas.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Al preparar un PC nuevo o cambiar qué extensiones de Chrome Web Store deben estar (o no estar) en
|
||||
cualquier perfil de Chromium del equipo. Reemplaza el paso manual de editar el JSON de policy con
|
||||
sudo. `--keep` fuerza y fija las imprescindibles; `--block` elimina las molestas sin tocar el resto.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **El backup NUNCA va dentro de `managed/`** (lo gestiona la función, pero es la lección clave): Chromium
|
||||
lee **todos** los archivos del directorio `policies/managed/` sin filtrar por extensión de nombre. Un
|
||||
`extensions.json.bak.YYYYMMDD` dentro de `managed/` se mergea con la policy efectiva y **reinyecta** las
|
||||
extensiones del backup (se ven como `location=7` external_policy_download y vuelven aunque las borres).
|
||||
Por eso la función guarda los backups en `policies/policy-backups/`, fuera de `managed/`. Si encuentras
|
||||
backups antiguos dentro de `managed/`, muévelos fuera.
|
||||
- **No usa el comodín `"*": blocked`**: ese modo desinstala todo lo no-whitelist pero también **bloquea las
|
||||
extensiones unpacked** (`--load-extension`), rompiendo cosas como la extensión de captura de `web_proxy`
|
||||
con el error "Loading of unpacked extensions is disabled by the administrator". Esta función bloquea solo
|
||||
los IDs de `--block`.
|
||||
- **`--load-extension` y managed policy son incompatibles en Chromium 137+**: con CUALQUIER managed policy
|
||||
presente, Chromium desactiva `--load-extension` ("disabled by the administrator"). Para cargar una
|
||||
extensión local junto a una managed policy hay que empaquetarla (.crx + update_url) o usar `--proxy-server`
|
||||
directo en el caso de `web_proxy`.
|
||||
- **Requiere sudo** para escribir en `/etc/chromium/policies/managed/`. En este equipo: `pass show claude/sudo | sudo -S <cmd>`.
|
||||
- **Chrome cachea la política en memoria**: cerrar TODOS los Chromium (`pkill -9 chromium`) y relanzar, o `chrome://policy` → "Reload policies".
|
||||
- **Idempotente**: si el archivo ya tiene el mismo contenido, no-op y sale 0.
|
||||
- Referencia del sistema completo: `projects/web_scraping/CHROMIUM_SYSTEM.md`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.2.0 (2026-06-05) — `--keep` acepta `<id>=<update_url>` para force-instalar extensiones self-hosted (p.ej. un `.crx` local vía `file://` a un `update.xml`), que es la forma de cargar una extensión propia cuando hay managed policy y `--load-extension` está desactivado.
|
||||
- v1.1.0 (2026-06-05) — añade `--block` (ExtensionInstallBlocklist); reemplaza el modo `ExtensionSettings "*": blocked` (rompía extensiones unpacked) por blocklist específica; mueve los backups fuera de `managed/` (Chromium lee todo el directorio y un `.bak` ahí reinyectaba extensiones).
|
||||
- v1.0.0 (2026-06-05) — baseline: ExtensionInstallForcelist con whitelist `--keep`.
|
||||
@@ -1,257 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# apply_chromium_extension_policy — Escribe de forma idempotente la política managed de Chromium
|
||||
# que fuerza la instalación de una whitelist de extensiones y bloquea (desinstala) una blocklist
|
||||
# concreta, sin tocar el resto. Usa ExtensionInstallForcelist + ExtensionInstallBlocklist.
|
||||
|
||||
apply_chromium_extension_policy() {
|
||||
local policy_path="/etc/chromium/policies/managed/extensions.json"
|
||||
local update_url="https://clients2.google.com/service/update2/crx"
|
||||
local dry_run=0
|
||||
local -a keep_ids=()
|
||||
local -a block_ids=()
|
||||
|
||||
# --- Parseo de argumentos ---
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--keep)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo "apply_chromium_extension_policy: --keep requiere un ID de extensión" >&2
|
||||
return 1
|
||||
fi
|
||||
keep_ids+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
--block)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo "apply_chromium_extension_policy: --block requiere un ID de extensión" >&2
|
||||
return 1
|
||||
fi
|
||||
block_ids+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
--policy-path)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo "apply_chromium_extension_policy: --policy-path requiere un valor" >&2
|
||||
return 1
|
||||
fi
|
||||
policy_path="$2"
|
||||
shift 2
|
||||
;;
|
||||
--update-url)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo "apply_chromium_extension_policy: --update-url requiere un valor" >&2
|
||||
return 1
|
||||
fi
|
||||
update_url="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "apply_chromium_extension_policy: argumento desconocido: $1" >&2
|
||||
echo "Uso: apply_chromium_extension_policy [--keep <ext_id>]... [--block <ext_id>]... [--policy-path <path>] [--update-url <url>] [--dry-run]" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Validar que hay al menos una extensión a forzar o bloquear ---
|
||||
if [[ ${#keep_ids[@]} -eq 0 && ${#block_ids[@]} -eq 0 ]]; then
|
||||
echo "apply_chromium_extension_policy: se requiere al menos un --keep o un --block <ext_id>" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- Construir el JSON ---
|
||||
# Dos claves complementarias, ninguna bloquea las extensiones unpacked (--load-extension),
|
||||
# de modo que extensiones locales como la de captura de web_proxy siguen cargando:
|
||||
# 1. ExtensionInstallForcelist: fuerza la instalación de la whitelist (--keep), que además
|
||||
# no se puede desinstalar desde la UI.
|
||||
# 2. ExtensionInstallBlocklist: bloquea Y desinstala las extensiones de la blocklist
|
||||
# (--block) en cualquier perfil. Solo afecta a los IDs listados; el resto no se toca.
|
||||
local forcelist_json="[]" blocklist_json="[]"
|
||||
if [[ ${#keep_ids[@]} -gt 0 ]]; then
|
||||
local entries="" first=1
|
||||
for kid in "${keep_ids[@]}"; do
|
||||
# Cada --keep puede ser "<id>" (usa el update_url por defecto, Web Store) o
|
||||
# "<id>=<update_url>" para una extensión self-hosted (p.ej. file:// a un update.xml local).
|
||||
local id="${kid%%=*}" url="$update_url"
|
||||
[[ "$kid" == *=* ]] && url="${kid#*=}"
|
||||
[[ $first -eq 0 ]] && entries+=","$'\n'
|
||||
entries+=" \"${id};${url}\""
|
||||
first=0
|
||||
done
|
||||
forcelist_json=$(printf '[\n%s\n ]' "$entries")
|
||||
fi
|
||||
if [[ ${#block_ids[@]} -gt 0 ]]; then
|
||||
local entries="" first=1
|
||||
for id in "${block_ids[@]}"; do
|
||||
[[ $first -eq 0 ]] && entries+=","$'\n'
|
||||
entries+=" \"${id}\""
|
||||
first=0
|
||||
done
|
||||
blocklist_json=$(printf '[\n%s\n ]' "$entries")
|
||||
fi
|
||||
|
||||
local new_json
|
||||
new_json=$(cat <<JSONEOF
|
||||
{
|
||||
"ExtensionInstallForcelist": ${forcelist_json},
|
||||
"ExtensionInstallBlocklist": ${blocklist_json}
|
||||
}
|
||||
JSONEOF
|
||||
)
|
||||
|
||||
# --- Modo dry-run ---
|
||||
if [[ $dry_run -eq 1 ]]; then
|
||||
echo "[dry-run] JSON que se escribiría en: ${policy_path}"
|
||||
echo "---"
|
||||
echo "$new_json"
|
||||
echo "---"
|
||||
echo "[dry-run] No se ha modificado el sistema."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# --- Idempotencia: comparar con contenido actual ---
|
||||
if [[ -f "$policy_path" ]]; then
|
||||
local current_content
|
||||
current_content=$(cat "$policy_path" 2>/dev/null || true)
|
||||
if [[ "$current_content" == "$new_json" ]]; then
|
||||
echo "apply_chromium_extension_policy: política ya aplicada (sin cambios). Nada que hacer."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Backup del archivo existente ---
|
||||
# CRÍTICO: el backup NUNCA puede vivir dentro del directorio de la policy. Chromium lee TODOS
|
||||
# los archivos del directorio managed/ (sin filtrar por extensión de nombre), así que un
|
||||
# "extensions.json.bak.YYYYMMDD" dentro de managed/ se mergea con la policy efectiva y reinyecta
|
||||
# las extensiones del backup. Por eso el backup se guarda en un directorio hermano (policy-backups)
|
||||
# que chromium no lee.
|
||||
local backup_path=""
|
||||
if [[ -f "$policy_path" ]]; then
|
||||
local date_suffix policy_dir backup_dir
|
||||
date_suffix=$(date +%Y%m%d)
|
||||
policy_dir="$(dirname "$policy_path")"
|
||||
case "$(basename "$policy_dir")" in
|
||||
managed|recommended) backup_dir="$(dirname "$policy_dir")/policy-backups" ;;
|
||||
*) backup_dir="$policy_dir" ;;
|
||||
esac
|
||||
backup_path="${backup_dir}/$(basename "$policy_path").bak.${date_suffix}"
|
||||
if [[ ! -d "$backup_dir" ]]; then
|
||||
if [[ $EUID -ne 0 ]]; then sudo mkdir -p "$backup_dir" 2>/dev/null; else mkdir -p "$backup_dir"; fi
|
||||
fi
|
||||
if [[ ! -f "$backup_path" ]]; then
|
||||
echo "apply_chromium_extension_policy: creando backup → ${backup_path}"
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
sudo cp "$policy_path" "$backup_path" || {
|
||||
echo "apply_chromium_extension_policy: no se pudo crear el backup en ${backup_path}" >&2
|
||||
return 1
|
||||
}
|
||||
else
|
||||
cp "$policy_path" "$backup_path" || {
|
||||
echo "apply_chromium_extension_policy: no se pudo crear el backup en ${backup_path}" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
else
|
||||
echo "apply_chromium_extension_policy: backup del día ya existe (${backup_path}), se omite."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Crear directorio padre si no existe ---
|
||||
local policy_dir
|
||||
policy_dir=$(dirname "$policy_path")
|
||||
if [[ ! -d "$policy_dir" ]]; then
|
||||
echo "apply_chromium_extension_policy: creando directorio ${policy_dir}"
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
sudo mkdir -p "$policy_dir" || {
|
||||
echo "apply_chromium_extension_policy: no se pudo crear el directorio ${policy_dir}" >&2
|
||||
return 1
|
||||
}
|
||||
else
|
||||
mkdir -p "$policy_dir" || {
|
||||
echo "apply_chromium_extension_policy: no se pudo crear el directorio ${policy_dir}" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Escribir el JSON vía tmpfile + sudo cp ---
|
||||
local tmpfile
|
||||
tmpfile=$(mktemp /tmp/chromium_policy_XXXXXX.json)
|
||||
echo "$new_json" > "$tmpfile"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
sudo cp "$tmpfile" "$policy_path" || {
|
||||
echo "apply_chromium_extension_policy: no se pudo escribir ${policy_path}" >&2
|
||||
rm -f "$tmpfile"
|
||||
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
|
||||
echo "apply_chromium_extension_policy: restaurando backup tras error..."
|
||||
sudo cp "$backup_path" "$policy_path" 2>/dev/null || true
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
else
|
||||
cp "$tmpfile" "$policy_path" || {
|
||||
echo "apply_chromium_extension_policy: no se pudo escribir ${policy_path}" >&2
|
||||
rm -f "$tmpfile"
|
||||
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
|
||||
echo "apply_chromium_extension_policy: restaurando backup tras error..."
|
||||
cp "$backup_path" "$policy_path" 2>/dev/null || true
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
rm -f "$tmpfile"
|
||||
|
||||
# --- Validar el JSON escrito ---
|
||||
local validation_ok=0
|
||||
if command -v python3 &>/dev/null; then
|
||||
python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$policy_path" 2>/dev/null && validation_ok=1
|
||||
elif command -v jq &>/dev/null; then
|
||||
jq . "$policy_path" &>/dev/null && validation_ok=1
|
||||
else
|
||||
validation_ok=1
|
||||
fi
|
||||
|
||||
if [[ $validation_ok -eq 0 ]]; then
|
||||
echo "apply_chromium_extension_policy: el JSON escrito no es válido — restaurando backup" >&2
|
||||
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
sudo cp "$backup_path" "$policy_path" 2>/dev/null || true
|
||||
else
|
||||
cp "$backup_path" "$policy_path" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- Resumen final ---
|
||||
echo "apply_chromium_extension_policy: política aplicada correctamente."
|
||||
echo " Ruta : ${policy_path}"
|
||||
if [[ ${#keep_ids[@]} -gt 0 ]]; then
|
||||
echo " Forzadas (${#keep_ids[@]}):"
|
||||
for id in "${keep_ids[@]}"; do echo " - ${id}"; done
|
||||
fi
|
||||
if [[ ${#block_ids[@]} -gt 0 ]]; then
|
||||
echo " Bloqueadas/desinstaladas (${#block_ids[@]}):"
|
||||
for id in "${block_ids[@]}"; do echo " - ${id}"; done
|
||||
fi
|
||||
echo " Extensiones unpacked (--load-extension, p.ej. web_proxy): NO afectadas."
|
||||
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
|
||||
echo " Backup : ${backup_path}"
|
||||
fi
|
||||
echo ""
|
||||
echo " AVISO: Chromium cachea la politica en memoria. Para que surta efecto:"
|
||||
echo " pkill -9 chromium (cierra TODOS los procesos)"
|
||||
echo " # Luego relanza Chromium. O desde un Chromium abierto:"
|
||||
echo " # chrome://policy → 'Reload policies'"
|
||||
}
|
||||
|
||||
# Auto-ejecución al correr el archivo directo (bash file.sh / fn run). Si se hace `source`,
|
||||
# solo se define la función y no se ejecuta nada.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
apply_chromium_extension_policy "$@"
|
||||
fi
|
||||
@@ -1,79 +0,0 @@
|
||||
---
|
||||
name: backup_chrome_bookmarks
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "backup_chrome_bookmarks --user-data-dir <dir> [--profile <name>]... [--backup-dir <dir>] [--dry-run]"
|
||||
description: "Copia byte a byte los archivos Bookmarks de perfiles Chrome/Chromium a un directorio de backup con timestamp ISO. Descubre automáticamente todos los perfiles con Bookmarks si no se especifica ninguno. Preserva el checksum interno del archivo sin parsear ni reserializar el JSON. No requiere que Chromium esté cerrado."
|
||||
tags: [navegator, chromium, bookmarks, backup, browser, scraping]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/browser/backup_chrome_bookmarks.sh"
|
||||
params:
|
||||
- name: --user-data-dir
|
||||
desc: "(obligatorio) Ruta raíz del user-data-dir de Chrome/Chromium. Ej: ~/.config/chromium-cdp"
|
||||
- name: --profile
|
||||
desc: "Nombre de carpeta de perfil a respaldar (repetible). Si no se pasa ninguno se descubren todos los perfiles que contengan un archivo Bookmarks, excluyendo System Profile."
|
||||
- name: --backup-dir
|
||||
desc: "Directorio raíz donde se crearán los backups. Default: ~/.local/share/web_scraping/bookmarks-backups"
|
||||
- name: --dry-run
|
||||
desc: "Muestra a stderr qué archivos se copiarían y sus tamaños sin escribir nada en disco. El JSON de salida se emite igualmente."
|
||||
output: "JSON en stdout: {backup_dir: \"<dir>\", ts: \"<YYYYMMDDTHHmmss>\", profiles: [{profile: \"<name>\", src: \"<path>\", dst: \"<path>\", bytes: N}, ...]}. Perfiles sin Bookmarks se omiten silenciosamente. Exit 0 en éxito o dry-run. Errores a stderr con exit != 0."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Backup de todos los perfiles del chromium-cdp (descubrimiento automático)
|
||||
source $HOME/fn_registry/bash/functions/browser/backup_chrome_bookmarks.sh
|
||||
backup_chrome_bookmarks --user-data-dir "$HOME/.config/chromium-cdp"
|
||||
|
||||
# Previsualizar sin tocar nada
|
||||
backup_chrome_bookmarks \
|
||||
--user-data-dir "$HOME/.config/chromium-cdp" \
|
||||
--dry-run
|
||||
|
||||
# Backup de perfiles específicos
|
||||
backup_chrome_bookmarks \
|
||||
--user-data-dir "$HOME/.config/chromium-cdp" \
|
||||
--profile Default \
|
||||
--profile Personal \
|
||||
--profile "Profile 1"
|
||||
|
||||
# Backup a directorio personalizado
|
||||
backup_chrome_bookmarks \
|
||||
--user-data-dir "$HOME/.config/chromium-cdp" \
|
||||
--backup-dir "$HOME/vaults/backups/bookmarks"
|
||||
|
||||
# Salida esperada (ejemplo):
|
||||
# {"backup_dir":"/home/enmanuel/.local/share/web_scraping/bookmarks-backups","ts":"20260605T143022","profiles":[{"profile":"Default","src":"/home/enmanuel/.config/chromium-cdp/Default/Bookmarks","dst":"/home/enmanuel/.local/share/web_scraping/bookmarks-backups/20260605T143022/Default/Bookmarks","bytes":4218}]}
|
||||
```
|
||||
|
||||
También ejecutable directamente con `fn run`:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
./fn run backup_chrome_bookmarks_bash_browser -- \
|
||||
--user-data-dir "$HOME/.config/chromium-cdp" --dry-run
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala antes de cualquier sesión de scraping o automatización que modifique bookmarks de Chromium, para tener un snapshot recuperable. También útil como paso previo en pipelines que reorganizan o importan marcadores masivamente. Combínala con `rotate_backups_bash_infra` para aplicar política de retención sobre el directorio de backups.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Copia verbatim para preservar checksum**: el archivo `Bookmarks` de Chromium incluye un campo `checksum` calculado sobre el contenido. Esta función usa `cp -p` sin tocar el contenido — si parseases y reserializases el JSON (con `jq`, `python3 json.dump`, etc.) el checksum quedaría inválido y Chromium podría resetear o ignorar los marcadores al arrancar.
|
||||
- **No requiere Chromium cerrado**: a diferencia de `clean_chrome_profile_extensions`, esta función solo lee el archivo `Bookmarks`. Chromium no mantiene un lock exclusivo sobre él — la copia es segura con el navegador abierto. El archivo refleja el estado en disco en ese instante; cambios en vuelo en memoria no estarán en el backup hasta que Chromium los persista.
|
||||
- **Perfiles sin Bookmarks se omiten silenciosamente**: si un perfil existe pero no tiene el archivo `Bookmarks` (perfil recién creado sin haber abierto el navegador), se salta sin error. Solo aparece en el JSON de salida si fue respaldado.
|
||||
- **System Profile excluido siempre**: el perfil `System Profile` es un perfil interno de Chromium sin datos de usuario y se excluye del descubrimiento automático.
|
||||
- **Sin jq ni python3**: la emisión del JSON de salida se construye con printf de bash puro, sin dependencias externas.
|
||||
@@ -1,139 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# backup_chrome_bookmarks — copia byte a byte los archivos Bookmarks de perfiles
|
||||
# Chrome/Chromium a un directorio de backup con timestamp. Preserva el checksum
|
||||
# interno del archivo sin parsear ni reserializar el JSON.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
backup_chrome_bookmarks() {
|
||||
# ── defaults ──────────────────────────────────────────────────────────────
|
||||
local _user_data_dir=""
|
||||
local _profiles=()
|
||||
local _backup_dir="${HOME}/.local/share/web_scraping/bookmarks-backups"
|
||||
local _dry_run=0
|
||||
|
||||
# ── parse args ─────────────────────────────────────────────────────────────
|
||||
_usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage: backup_chrome_bookmarks --user-data-dir <dir> [--profile <name>]...
|
||||
[--backup-dir <dir>] [--dry-run]
|
||||
|
||||
--user-data-dir (obligatorio) Raíz de perfiles de Chrome/Chromium.
|
||||
Ej: ~/.config/chromium-cdp
|
||||
--profile <name> Nombre de carpeta de perfil a respaldar (repetible).
|
||||
Si no se pasa ninguno → respalda TODOS los perfiles con
|
||||
un archivo Bookmarks (excluye System Profile).
|
||||
--backup-dir <dir> Directorio raíz para backups.
|
||||
Default: ~/.local/share/web_scraping/bookmarks-backups
|
||||
--dry-run Muestra qué copiaría sin tocar nada.
|
||||
|
||||
Exit codes:
|
||||
0 éxito (o dry-run completado)
|
||||
1 error de argumento o validación
|
||||
EOF
|
||||
return 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--user-data-dir) _user_data_dir="$2"; shift 2 ;;
|
||||
--profile) _profiles+=("$2"); shift 2 ;;
|
||||
--backup-dir) _backup_dir="$2"; shift 2 ;;
|
||||
--dry-run) _dry_run=1; shift ;;
|
||||
-h|--help) _usage; return 0 ;;
|
||||
*) echo "backup_chrome_bookmarks: argumento desconocido: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── validaciones ──────────────────────────────────────────────────────────
|
||||
if [[ -z "$_user_data_dir" ]]; then
|
||||
echo "backup_chrome_bookmarks: --user-data-dir es obligatorio" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$_user_data_dir" ]]; then
|
||||
echo "backup_chrome_bookmarks: directorio no encontrado: ${_user_data_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── descubrir perfiles si no se pasó ninguno ───────────────────────────────
|
||||
if [[ ${#_profiles[@]} -eq 0 ]]; then
|
||||
local _candidate
|
||||
while IFS= read -r -d '' _candidate; do
|
||||
local _pname
|
||||
_pname="$(basename "$_candidate")"
|
||||
# Excluir System Profile (perfil interno de Chromium sin datos de usuario)
|
||||
if [[ "$_pname" == "System Profile" ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ -f "${_candidate}/Bookmarks" ]]; then
|
||||
_profiles+=("$_pname")
|
||||
fi
|
||||
done < <(find "$_user_data_dir" -mindepth 1 -maxdepth 1 -type d -print0 | sort -z)
|
||||
fi
|
||||
|
||||
if [[ ${#_profiles[@]} -eq 0 ]]; then
|
||||
echo "backup_chrome_bookmarks: no se encontraron perfiles con archivo Bookmarks en: ${_user_data_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── timestamp único para este backup ──────────────────────────────────────
|
||||
local _ts
|
||||
_ts="$(date +%Y%m%dT%H%M%S)"
|
||||
|
||||
# ── procesar perfiles ─────────────────────────────────────────────────────
|
||||
# Construir el array de resultados JSON manualmente (sin jq ni python3)
|
||||
local _results="["
|
||||
local _first=1
|
||||
local _profile
|
||||
|
||||
for _profile in "${_profiles[@]}"; do
|
||||
local _src="${_user_data_dir}/${_profile}/Bookmarks"
|
||||
|
||||
# Si el perfil no tiene Bookmarks, se omite sin error
|
||||
if [[ ! -f "$_src" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
local _dst="${_backup_dir}/${_ts}/${_profile}/Bookmarks"
|
||||
local _bytes
|
||||
_bytes="$(wc -c < "$_src")"
|
||||
|
||||
if [[ $_dry_run -eq 1 ]]; then
|
||||
echo "backup_chrome_bookmarks: [dry-run] cp -p \"${_src}\" -> \"${_dst}\" (${_bytes} bytes)" >&2
|
||||
else
|
||||
local _dst_dir
|
||||
_dst_dir="$(dirname "$_dst")"
|
||||
mkdir -p "$_dst_dir"
|
||||
cp -p "$_src" "$_dst"
|
||||
fi
|
||||
|
||||
# Escapar comillas dobles en el path por si acaso
|
||||
local _src_esc="${_src//\"/\\\"}"
|
||||
local _dst_esc="${_dst//\"/\\\"}"
|
||||
local _profile_esc="${_profile//\"/\\\"}"
|
||||
|
||||
local _entry
|
||||
_entry="$(printf '{"profile":"%s","src":"%s","dst":"%s","bytes":%s}' \
|
||||
"$_profile_esc" "$_src_esc" "$_dst_esc" "$_bytes")"
|
||||
|
||||
if [[ $_first -eq 1 ]]; then
|
||||
_results+="$_entry"
|
||||
_first=0
|
||||
else
|
||||
_results+=",${_entry}"
|
||||
fi
|
||||
done
|
||||
|
||||
_results+="]"
|
||||
|
||||
# ── emitir resultado JSON ──────────────────────────────────────────────────
|
||||
local _backup_dir_esc="${_backup_dir//\"/\\\"}"
|
||||
printf '{"backup_dir":"%s","ts":"%s","profiles":%s}\n' \
|
||||
"$_backup_dir_esc" "$_ts" "$_results"
|
||||
}
|
||||
|
||||
# ── auto-ejecución ────────────────────────────────────────────────────────────
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
backup_chrome_bookmarks "$@"
|
||||
fi
|
||||
@@ -1,80 +0,0 @@
|
||||
---
|
||||
name: chrome_load_extensions
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "chrome_load_extensions [--port N] [--profile DIR] --ext PATH [--ext PATH ...] [--proxy URL] [--url URL]"
|
||||
description: "Lanza Chrome con extensiones unpacked via --load-extension (WSL2→Windows chrome.exe, paths traducidos, join sin echo, setsid anti-exit-144). OJO: --load-extension SOLO funciona en Chrome for Testing/Chromium/Dev. En Chrome STABLE 138+ esta DESACTIVADO (feature DisableLoadExtensionCommandLineSwitch + bloqueo duro en 148) y carga 0 extensiones aunque el cmdline sea correcto. Para Chrome stable usar install via Web Store (1-clic, persiste en perfil) o enterprise policy ExtensionInstallForcelist (requiere HKLM/HKCU Policies escribible — denegado en maquinas gestionadas)."
|
||||
tags: [chrome, cdp, browser, extensions, wsl2, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
params:
|
||||
- name: "--port N"
|
||||
desc: "Puerto de remote debugging CDP. Default: 9222."
|
||||
- name: "--profile DIR"
|
||||
desc: "Chrome user-data-dir. Acepta ruta Windows (C:\\...) o ruta WSL/Linux (se traduce via wslpath -w). Default: C:\\Users\\<USERNAME>\\AppData\\Local\\fn-chrome-cdp-profile (WSL2) o /tmp/fn-chrome-cdp-profile (Linux nativo)."
|
||||
- name: "--ext PATH"
|
||||
desc: "Ruta a un directorio de extensión unpacked. Repetible. Acepta ruta Windows (se pasa intacta) o ruta WSL/Linux (se traduce via wslpath -w). Obligatorio al menos uno."
|
||||
- name: "--proxy URL"
|
||||
desc: "Proxy opcional, ej. http://127.0.0.1:8889. Agrega --proxy-server=URL a Chrome."
|
||||
- name: "--url URL"
|
||||
desc: "URL inicial opcional para abrir con --new-window."
|
||||
output: "PID del proceso Chrome lanzado (stdout). Mensajes de estado en stderr. CDP listo en 127.0.0.1:<port>."
|
||||
file_path: "bash/functions/browser/chrome_load_extensions.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/browser/chrome_load_extensions.sh
|
||||
|
||||
chrome_load_extensions \
|
||||
--port 9222 \
|
||||
--profile 'C:\Users\lucas\AppData\Local\fn-chrome-cdp-profile' \
|
||||
--ext 'C:\Users\lucas\hls-dl-ext' \
|
||||
--ext 'C:\Users\lucas\ubol' \
|
||||
--proxy http://127.0.0.1:8889 \
|
||||
--url https://www.gnularetro.cc/
|
||||
```
|
||||
|
||||
Sin proxy ni URL, sólo extensiones:
|
||||
|
||||
```bash
|
||||
source bash/functions/browser/chrome_load_extensions.sh
|
||||
|
||||
pid=$(chrome_load_extensions \
|
||||
--ext '/home/lucas/dev/hls-dl-ext' \
|
||||
--ext '/home/lucas/dev/ubol')
|
||||
# Paths WSL traducidos automáticamente a Windows.
|
||||
# CDP listo en 127.0.0.1:9222.
|
||||
echo "Chrome PID: $pid"
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites Chrome CDP con extensiones unpacked cargadas (HLS downloader, uBlock Origin, extensiones en desarrollo) y `chrome_launch_go_browser` no sirve porque hardcodea `--disable-extensions`. WSL2→Windows. Ideal para sesiones de navegator con proxy + extensión activa.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **MUERTO en Chrome STABLE 138+ (validado 2026-05-30, Chrome 148)**: `--load-extension` NO carga nada en el canal stable, ni con `--disable-extensions-except` ni con `--disable-features=DisableLoadExtensionCommandLineSwitch`. `chrome://version` muestra el flag correcto pero `chrome://extensions` sale vacío. Google lo bloqueó duro en stable. La función SOLO sirve en **Chrome for Testing / Chromium / Dev/Canary**, donde el switch sigue activo. Para stable: ver opciones abajo.
|
||||
- **Instalar en Chrome STABLE (las que SÍ funcionan)**:
|
||||
1. **Web Store 1-clic** — abre la página del store en el perfil CDP, el humano da "Añadir a Chrome". Persiste en el perfil para siempre (futuros lanzamientos ya con la extensión, sin flags). El popup de confirmación es UI del navegador (no DOM) → NO es CDP-clickable, requiere gesto humano. Único método no-admin que persiste por-perfil.
|
||||
2. **Enterprise policy** `ExtensionInstallForcelist` (HKCU/HKLM `\Software\Policies\Google\Chrome`) — force-install sin clic desde el store, browser-wide. El key `Policies\Google\Chrome` puede dar "Access denied" al escribir (visto 2026-05-30 incluso en máquina personal vía reg.exe/PowerShell desde WSL — Chrome/Windows protege el subárbol Policies). Si funciona, requiere relanzar Chrome para que descargue del store. Método global (afecta todos los perfiles).
|
||||
3. Extensiones **unpacked custom** (no en store, ej. un HLS downloader propio) en stable: no hay vía no-admin. Empaquetar a CRX + self-host `update_url` + policy, o usar Chrome for Testing. A menudo innecesario si la lógica vive fuera (ej. `grab_stream.py` descarga sin extensión).
|
||||
- **Combo flags (solo Chrome for Testing/dev)**: requiere AMBOS `--load-extension=p1,p2` Y `--disable-extensions-except=p1,p2` juntos + `--disable-features=DisableLoadExtensionCommandLineSwitch`. **NUNCA `--disable-extensions`** (desactiva todo).
|
||||
- **join sin `echo`**: rutas Windows `C:\Users\...` tienen `\U`; el `echo` de zsh (o sh con xpg_echo) lo interpreta como escape unicode y trunca la ruta a `C:`. La función usa acumulador `+=`, no `echo`. Verificable en `chrome://version` (debe verse el path completo, no `--load-extension=C:`).
|
||||
- **exit 144 en Bash tool**: si el proceso Chrome retiene el pipe stdout, la herramienta devuelve exit 144. Esta función lanza con `setsid ... </dev/null >log 2>&1 &` + `disown` para desacoplar completamente. El log queda en `/tmp/chrome_ext_<port>.log`.
|
||||
- **WSL2: traducir paths con `wslpath -w`**: los paths de `--ext` y `--profile` que sean rutas Linux se traducen automáticamente. Las rutas Windows (`C:\...`) se pasan intactas. `wslpath` debe estar disponible (estándar en WSL2 desde Windows 10 1903+).
|
||||
- **Perfil ya abierto**: si Chrome ya tiene ese perfil abierto, relanzar añade una ventana extra a la misma instancia. La función detecta si CDP ya responde en el puerto y avisa por stderr, pero procede igualmente.
|
||||
- **Web Store vs unpacked**: instalar extensiones desde la Web Store (un clic) persiste en el perfil sin necesidad de flags y sobrevive reinicios. Esta función es para extensiones unpacked en desarrollo o que no están en la Web Store. Si usas ambas, los flags no interfieren con las instaladas del store.
|
||||
- **zsh globbing**: `--remote-allow-origins=*` está dentro de comillas en la función, no se expande. Si lo pasas desde la línea de comandos, entrecomillarlo.
|
||||
- **Proxy + extensión**: si usas proxy para captura de tráfico (Burp, mitmproxy, gost), el proxy se aplica a toda la sesión Chrome, incluyendo el tráfico de las extensiones.
|
||||
@@ -1,161 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# chrome_load_extensions — lanza Chrome (WSL2→Windows chrome.exe) con extensiones unpacked cargadas en un perfil CDP.
|
||||
# Chrome 148+: requiere --load-extension=<paths> Y --disable-extensions-except=<same paths> juntos.
|
||||
# NUNCA pasar --disable-extensions (desactiva todo, incluyendo las que quieres cargar).
|
||||
|
||||
chrome_load_extensions() {
|
||||
local port=9222
|
||||
local profile=""
|
||||
local proxy=""
|
||||
local url=""
|
||||
local -a ext_paths=()
|
||||
|
||||
# --- Parse args ---
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port)
|
||||
port="$2"; shift 2 ;;
|
||||
--profile)
|
||||
profile="$2"; shift 2 ;;
|
||||
--ext)
|
||||
ext_paths+=("$2"); shift 2 ;;
|
||||
--proxy)
|
||||
proxy="$2"; shift 2 ;;
|
||||
--url)
|
||||
url="$2"; shift 2 ;;
|
||||
--*)
|
||||
echo "chrome_load_extensions: flag desconocido: $1" >&2; return 1 ;;
|
||||
*)
|
||||
# Positional = extra ext path
|
||||
ext_paths+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ${#ext_paths[@]} -eq 0 ]]; then
|
||||
echo "chrome_load_extensions: se requiere al menos un --ext PATH de extension unpacked" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- Detectar chrome.exe ---
|
||||
local chrome_bin=""
|
||||
if command -v chrome.exe &>/dev/null; then
|
||||
chrome_bin="chrome.exe"
|
||||
elif [[ -f "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe" ]]; then
|
||||
chrome_bin="/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"
|
||||
elif [[ -f "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe" ]]; then
|
||||
chrome_bin="/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe"
|
||||
else
|
||||
echo "chrome_load_extensions: chrome.exe no encontrado en PATH ni en rutas conocidas" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- Detectar WSL2 ---
|
||||
local wsl2=0
|
||||
if grep -qi 'microsoft\|wsl' /proc/version 2>/dev/null; then
|
||||
wsl2=1
|
||||
fi
|
||||
|
||||
# --- Traducir paths de extensiones a Windows si hace falta ---
|
||||
local -a win_ext_paths=()
|
||||
for p in "${ext_paths[@]}"; do
|
||||
if [[ $wsl2 -eq 1 ]] && [[ "$p" != [A-Za-z]:\\* ]]; then
|
||||
# Path Linux → traducir a Windows
|
||||
local win_p
|
||||
win_p=$(wslpath -w "$p" 2>/dev/null) || {
|
||||
echo "chrome_load_extensions: wslpath -w '$p' falló" >&2
|
||||
return 1
|
||||
}
|
||||
win_ext_paths+=("$win_p")
|
||||
else
|
||||
win_ext_paths+=("$p")
|
||||
fi
|
||||
done
|
||||
|
||||
# --- Resolver perfil ---
|
||||
if [[ -z "$profile" ]]; then
|
||||
# Default: perfil canónico fn-chrome-cdp-profile en Windows
|
||||
local win_user="${USERNAME:-${USER:-lucas}}"
|
||||
if [[ $wsl2 -eq 1 ]]; then
|
||||
profile="C:\\Users\\${win_user}\\AppData\\Local\\fn-chrome-cdp-profile"
|
||||
else
|
||||
profile="/tmp/fn-chrome-cdp-profile"
|
||||
fi
|
||||
elif [[ $wsl2 -eq 1 ]] && [[ "$profile" != [A-Za-z]:\\* ]]; then
|
||||
# Path Linux del perfil → traducir a Windows
|
||||
profile=$(wslpath -w "$profile" 2>/dev/null) || {
|
||||
echo "chrome_load_extensions: wslpath -w '$profile' falló" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
# --- Construir lista de paths separada por coma (para Chrome) ---
|
||||
# Chrome usa coma como separador en --load-extension y --disable-extensions-except.
|
||||
# NO usar `echo` para el join: rutas Windows como C:\Users tienen \U, y el echo de
|
||||
# zsh (o sh con xpg_echo) interpreta \U como escape unicode y trunca la ruta a "C:".
|
||||
# Acumulador con += y printf-safe, sin interpretacion de backslashes.
|
||||
local ext_list=""
|
||||
local p
|
||||
for p in "${win_ext_paths[@]}"; do
|
||||
ext_list+="${ext_list:+,}${p}"
|
||||
done
|
||||
|
||||
# --- Construir args de Chrome ---
|
||||
local -a args=(
|
||||
"--remote-debugging-port=${port}"
|
||||
"--user-data-dir=${profile}"
|
||||
"--no-first-run"
|
||||
"--no-default-browser-check"
|
||||
"--remote-allow-origins=*"
|
||||
"--load-extension=${ext_list}"
|
||||
"--disable-extensions-except=${ext_list}"
|
||||
# Chrome 137+ activa por defecto el feature DisableLoadExtensionCommandLineSwitch,
|
||||
# que IGNORA silenciosamente --load-extension. Hay que desactivarlo o las
|
||||
# extensiones unpacked no cargan (chrome://extensions sale vacio).
|
||||
"--disable-features=DisableLoadExtensionCommandLineSwitch"
|
||||
)
|
||||
|
||||
# WSL2: bind en 0.0.0.0 para que sea accesible desde la red WSL
|
||||
if [[ $wsl2 -eq 1 ]]; then
|
||||
args+=("--remote-debugging-address=0.0.0.0")
|
||||
fi
|
||||
|
||||
if [[ -n "$proxy" ]]; then
|
||||
args+=("--proxy-server=${proxy}")
|
||||
fi
|
||||
|
||||
if [[ -n "$url" ]]; then
|
||||
args+=("--new-window" "$url")
|
||||
fi
|
||||
|
||||
# --- Revisar si CDP ya responde en el puerto ---
|
||||
if curl -sf --max-time 1 "http://127.0.0.1:${port}/json/version" &>/dev/null; then
|
||||
echo "chrome_load_extensions: CDP ya activo en puerto ${port}; lanzando ventana extra" >&2
|
||||
fi
|
||||
|
||||
# --- Lanzar Chrome desacoplado del proceso padre ---
|
||||
# setsid + redirección evita el exit 144 en el Bash tool (el pipe no queda retenido).
|
||||
setsid "$chrome_bin" "${args[@]}" </dev/null >"/tmp/chrome_ext_${port}.log" 2>&1 &
|
||||
local chrome_pid=$!
|
||||
disown "$chrome_pid"
|
||||
|
||||
echo "chrome_load_extensions: Chrome lanzado PID=${chrome_pid} puerto=${port}" >&2
|
||||
|
||||
# --- Esperar a que CDP esté listo (hasta 15 segundos) ---
|
||||
local deadline=$(( $(date +%s) + 15 ))
|
||||
local ready=0
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
if curl -sf --max-time 1 "http://127.0.0.1:${port}/json/version" &>/dev/null; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
if [[ $ready -eq 1 ]]; then
|
||||
echo "chrome_load_extensions: CDP listo en 127.0.0.1:${port}"
|
||||
else
|
||||
echo "chrome_load_extensions: advertencia — CDP no respondió en 15s en puerto ${port}; Chrome puede estar iniciando lentamente" >&2
|
||||
fi
|
||||
|
||||
echo "$chrome_pid"
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
name: clean_chrome_profile_extensions
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "clean_chrome_profile_extensions [--user-data-dir <dir>] [--profile-directory <name>] [--keep <ext_id>]... [--dry-run]"
|
||||
description: "Purga in-place las extensiones de un perfil Chrome/Chromium existente que no estén en la whitelist --keep: borra sus carpetas de disco y elimina sus referencias de Preferences y Secure Preferences para que Chromium no las reinstale. Complementaria a apply_chromium_extension_policy_bash_browser que evita reinstalación pero no desinstala lo ya instalado en Chromium 148."
|
||||
tags: [navegator, chromium, extensions, profile, cleanup, browser, scraping]
|
||||
uses_functions: [apply_chromium_extension_policy_bash_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/browser/clean_chrome_profile_extensions.sh"
|
||||
params:
|
||||
- name: --user-data-dir
|
||||
desc: "Ruta raíz del user-data-dir de Chrome/Chromium. Default: ~/.config/chromium"
|
||||
- name: --profile-directory
|
||||
desc: "Nombre del subperfil dentro de user-data-dir. Default: Default"
|
||||
- name: --keep
|
||||
desc: "ID de extensión Chrome a conservar (repetible, 32 chars minúsculas). Si no se pasa ninguno el default es ddkjiahejlhfcafbddmgiahcphecmpfh (uBlock Origin Lite)"
|
||||
- name: --dry-run
|
||||
desc: "Muestra qué IDs se conservarían y cuáles se borrarían sin tocar disco ni archivos de preferencias"
|
||||
output: "JSON en stdout: {profile: \"<path>\", kept: [id...], removed: [id...]}. Exit 0 en éxito o dry-run. Errores a stderr con exit != 0."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Cerrar Chromium primero (OBLIGATORIO en modo real)
|
||||
pkill -TERM chromium
|
||||
|
||||
# Purgar perfil Default dejando solo uBlock Origin Lite
|
||||
source $HOME/fn_registry/bash/functions/browser/clean_chrome_profile_extensions.sh
|
||||
clean_chrome_profile_extensions --keep ddkjiahejlhfcafbddmgiahcphecmpfh
|
||||
|
||||
# Previsualizar antes de tocar nada
|
||||
clean_chrome_profile_extensions --keep ddkjiahejlhfcafbddmgiahcphecmpfh --dry-run
|
||||
|
||||
# Perfil no-default con whitelist de dos extensiones
|
||||
clean_chrome_profile_extensions \
|
||||
--user-data-dir "$HOME/.config/chromium" \
|
||||
--profile-directory "Profile 1" \
|
||||
--keep ddkjiahejlhfcafbddmgiahcphecmpfh \
|
||||
--keep cjpalhdlnbpafiamejdnhcphjbkeiagm
|
||||
|
||||
# Salida esperada (ejemplo):
|
||||
# {"profile":"/home/enmanuel/.config/chromium/Default","kept":["ddkjiahejlhfcafbddmgiahcphecmpfh"],"removed":["dark-reader-id","another-ext-id"]}
|
||||
```
|
||||
|
||||
También ejecutable directamente con `fn run`:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
./fn run clean_chrome_profile_extensions_bash_browser -- --dry-run
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala después de reducir la whitelist de extensiones con `apply_chromium_extension_policy_bash_browser` (modo `blocked`), para quitar del disco las que ya estaban instaladas en el perfil: la policy evita que Chromium reinstale extensiones nuevas, pero en Chromium 148 no desinstala las que ya estaban force-instaladas. Esta función hace la purga determinista del estado existente. También útil antes de una sesión de scraping para dejar el perfil con solo las extensiones necesarias.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Chromium DEBE estar cerrado** antes de ejecutar en modo real. Chromium reescribe `Preferences` desde memoria al cerrar y desharía toda la purga. La función lo comprueba con `pgrep -x chromium` y aborta con exit 2 si hay procesos vivos. En `--dry-run` no se hace este check.
|
||||
- **Combínala con `apply_chromium_extension_policy_bash_browser` (blocked)** para que las extensiones no vuelvan a instalarse la próxima vez que arranques Chromium. Esta función purga el estado actual; la policy evita la reinstalación futura.
|
||||
- **Backup automático de prefs**: antes de editar `Preferences` y `Secure Preferences` la función crea `<archivo>.bak.YYYYMMDD`. Si ya existe un backup del día no lo sobreescribe. En caso de problemas: `cp Preferences.bak.YYYYMMDD Preferences`.
|
||||
- **Opera por perfil**: actúa sobre `--user-data-dir`/`--profile-directory`/Extensions. Si tienes varios perfiles (`Default`, `Profile 1`, etc.) debes invocarla una vez por cada uno.
|
||||
- **python3 > jq > warn**: para editar el JSON de Preferences usa python3 si está disponible, jq como fallback, y emite un warning a stderr (sin abortar) si ninguno está. En ese caso las carpetas sí se borran pero las referencias en Preferences quedan — Chromium podría intentar reinstalar desde Web Store.
|
||||
- **Secure Preferences HMAC**: la tabla `protection.macs.extensions.settings` también se limpia para evitar que Chromium detecte inconsistencia entre el HMAC y la entrada eliminada y resetee configuraciones. Si la HMAC falla de todas formas, Chromium lo trata como perfil potencialmente corrupto y puede resetear algunas prefs — comportamiento esperado de Chromium, no un bug de esta función.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Código | Significado |
|
||||
|--------|------------|
|
||||
| 0 | Éxito o dry-run completado |
|
||||
| 1 | Argumento inválido o perfil no encontrado |
|
||||
| 2 | Chromium está corriendo (solo en modo real) |
|
||||
| 3 | Directorio Extensions no encontrado |
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# clean_chrome_profile_extensions — purga in-place extensiones fuera de la whitelist
|
||||
# de un perfil Chrome/Chromium existente. Borra las carpetas de disco y limpia las
|
||||
# referencias en Preferences y Secure Preferences para que Chromium no las reinstale.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
clean_chrome_profile_extensions() {
|
||||
# ── defaults ──────────────────────────────────────────────────────────────
|
||||
local _user_data_dir="${HOME}/.config/chromium"
|
||||
local _profile_dir="Default"
|
||||
local _keep=()
|
||||
local _default_ext="ddkjiahejlhfcafbddmgiahcphecmpfh"
|
||||
local _dry_run=0
|
||||
|
||||
# ── parse args ─────────────────────────────────────────────────────────────
|
||||
_usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage: clean_chrome_profile_extensions [--user-data-dir <dir>] [--profile-directory <name>]
|
||||
[--keep <ext_id>]... [--dry-run]
|
||||
|
||||
--user-data-dir Raíz del perfil. Default: ~/.config/chromium
|
||||
--profile-directory Subperfil. Default: Default
|
||||
--keep <ext_id> ID de extensión a conservar (repetible).
|
||||
Default si no se pasa ninguno: ddkjiahejlhfcafbddmgiahcphecmpfh (uBlock Origin Lite)
|
||||
--dry-run Lista qué se borraría sin tocar nada.
|
||||
|
||||
Exit codes:
|
||||
0 éxito (o dry-run completado)
|
||||
1 error de argumento o validación
|
||||
2 chromium está corriendo (solo en modo real)
|
||||
3 directorio de extensiones no encontrado
|
||||
EOF
|
||||
return 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--user-data-dir) _user_data_dir="$2"; shift 2 ;;
|
||||
--profile-directory) _profile_dir="$2"; shift 2 ;;
|
||||
--keep) _keep+=("$2"); shift 2 ;;
|
||||
--dry-run) _dry_run=1; shift ;;
|
||||
-h|--help) _usage; return 0 ;;
|
||||
*) echo "clean_chrome_profile_extensions: argumento desconocido: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── whitelist por defecto ──────────────────────────────────────────────────
|
||||
if [[ ${#_keep[@]} -eq 0 ]]; then
|
||||
_keep=("$_default_ext")
|
||||
fi
|
||||
|
||||
# ── construir paths base ───────────────────────────────────────────────────
|
||||
local _profile_path
|
||||
_profile_path="${_user_data_dir}/${_profile_dir}"
|
||||
local _ext_dir="${_profile_path}/Extensions"
|
||||
|
||||
# ── validaciones ──────────────────────────────────────────────────────────
|
||||
if [[ ! -d "$_profile_path" ]]; then
|
||||
echo "clean_chrome_profile_extensions: perfil no encontrado: ${_profile_path}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$_ext_dir" ]]; then
|
||||
echo "clean_chrome_profile_extensions: directorio de extensiones no encontrado: ${_ext_dir}" >&2
|
||||
return 3
|
||||
fi
|
||||
|
||||
# ── guard: chromium NO debe estar corriendo (excepto en dry-run) ──────────
|
||||
if [[ $_dry_run -eq 0 ]]; then
|
||||
if pgrep -x chromium >/dev/null 2>&1; then
|
||||
echo "clean_chrome_profile_extensions: chromium está corriendo — ciérralo antes de limpiar:" >&2
|
||||
echo " pkill -TERM chromium" >&2
|
||||
echo "(Chromium reescribe Preferences desde memoria al cerrar y desharía la purga)" >&2
|
||||
return 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── enumerar extensiones instaladas ───────────────────────────────────────
|
||||
local _to_remove=()
|
||||
local _to_keep=()
|
||||
|
||||
while IFS= read -r -d '' _ext_path; do
|
||||
local _ext_id
|
||||
_ext_id="$(basename "$_ext_path")"
|
||||
|
||||
# Siempre ignorar la carpeta Temp (usada durante installs en curso)
|
||||
if [[ "$_ext_id" == "Temp" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Comprobar si está en la whitelist
|
||||
local _in_keep=0
|
||||
local _k
|
||||
for _k in "${_keep[@]}"; do
|
||||
if [[ "$_ext_id" == "$_k" ]]; then
|
||||
_in_keep=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $_in_keep -eq 1 ]]; then
|
||||
_to_keep+=("$_ext_id")
|
||||
else
|
||||
_to_remove+=("$_ext_id")
|
||||
fi
|
||||
done < <(find "$_ext_dir" -mindepth 1 -maxdepth 1 -type d -print0 | sort -z)
|
||||
|
||||
# ── modo dry-run: solo informar ────────────────────────────────────────────
|
||||
if [[ $_dry_run -eq 1 ]]; then
|
||||
echo "=== clean_chrome_profile_extensions DRY-RUN ===" >&2
|
||||
echo " Perfil : ${_profile_path}" >&2
|
||||
echo " Conservar (${#_to_keep[@]}): ${_to_keep[*]+"${_to_keep[*]}"}" >&2
|
||||
echo " Borrar (${#_to_remove[@]}): ${_to_remove[*]+"${_to_remove[*]}"}" >&2
|
||||
_emit_json "$_profile_path" _to_keep _to_remove
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── borrar extensiones fuera de la whitelist ───────────────────────────────
|
||||
if [[ ${#_to_remove[@]} -gt 0 ]]; then
|
||||
local _id
|
||||
for _id in "${_to_remove[@]}"; do
|
||||
rm -rf "${_ext_dir}/${_id}"
|
||||
done
|
||||
|
||||
# ── purgar referencias en Preferences y Secure Preferences ────────────
|
||||
# Construir lista Python de IDs eliminados
|
||||
local _py_ids_list=""
|
||||
for _id in "${_to_remove[@]}"; do
|
||||
_py_ids_list+="\"${_id}\","
|
||||
done
|
||||
_py_ids_list="[${_py_ids_list%,}]"
|
||||
|
||||
local _today
|
||||
_today="$(date +%Y%m%d)"
|
||||
|
||||
local _prefs_file
|
||||
for _prefs_file in "${_profile_path}/Preferences" "${_profile_path}/Secure Preferences"; do
|
||||
if [[ ! -f "$_prefs_file" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Backup (no sobreescribir backup del mismo día)
|
||||
local _backup="${_prefs_file}.bak.${_today}"
|
||||
if [[ ! -f "$_backup" ]]; then
|
||||
cp "$_prefs_file" "$_backup"
|
||||
fi
|
||||
|
||||
# Editar con python3 si está disponible
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$_prefs_file" "$_py_ids_list" <<'PY' || \
|
||||
echo "clean_chrome_profile_extensions: advertencia — no se pudo purgar ${_prefs_file} con python3" >&2
|
||||
import sys, json
|
||||
|
||||
prefs_path = sys.argv[1]
|
||||
removed_ids = json.loads(sys.argv[2])
|
||||
|
||||
with open(prefs_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# 1. extensions.settings.<id>
|
||||
ext_settings = data.get("extensions", {}).get("settings", {})
|
||||
for ext_id in removed_ids:
|
||||
ext_settings.pop(ext_id, None)
|
||||
|
||||
# 2. extensions.pinned_extensions (lista de IDs)
|
||||
pinned = data.get("extensions", {}).get("pinned_extensions", None)
|
||||
if isinstance(pinned, list):
|
||||
data["extensions"]["pinned_extensions"] = [
|
||||
pid for pid in pinned if pid not in removed_ids
|
||||
]
|
||||
|
||||
# 3. protection.macs.extensions.settings.<id> (Secure Preferences HMAC table)
|
||||
try:
|
||||
mac_ext = data["protection"]["macs"]["extensions"]["settings"]
|
||||
for ext_id in removed_ids:
|
||||
mac_ext.pop(ext_id, None)
|
||||
except (KeyError, TypeError):
|
||||
pass
|
||||
|
||||
with open(prefs_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, separators=(",", ":"))
|
||||
PY
|
||||
|
||||
# Fallback con jq si python3 no está disponible
|
||||
elif command -v jq >/dev/null 2>&1; then
|
||||
local _tmp_prefs
|
||||
_tmp_prefs="$(mktemp)"
|
||||
local _jq_del=""
|
||||
for _id in "${_to_remove[@]}"; do
|
||||
_jq_del+=" | del(.extensions.settings[\"${_id}\"])"
|
||||
_jq_del+=" | del(.protection.macs.extensions.settings[\"${_id}\"])"
|
||||
done
|
||||
# pinned_extensions como lista
|
||||
_jq_del+=" | if .extensions.pinned_extensions then .extensions.pinned_extensions -= [$(printf '"%s",' "${_to_remove[@]}" | sed 's/,$//')] else . end"
|
||||
jq "${_jq_del:1}" "$_prefs_file" > "$_tmp_prefs" && mv "$_tmp_prefs" "$_prefs_file" || {
|
||||
echo "clean_chrome_profile_extensions: advertencia — jq falló procesando ${_prefs_file}" >&2
|
||||
rm -f "$_tmp_prefs"
|
||||
}
|
||||
else
|
||||
echo "clean_chrome_profile_extensions: advertencia — ni python3 ni jq disponibles; se borraron las carpetas pero no las referencias en $(basename "$_prefs_file")" >&2
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── emitir resultado JSON ──────────────────────────────────────────────────
|
||||
_emit_json "$_profile_path" _to_keep _to_remove
|
||||
}
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
# _json_array_from_nameref <nameref>
|
||||
# Convierte un array bash (pasado por nombre de variable) en JSON array de strings.
|
||||
_json_array_from_nameref() {
|
||||
local -n _arr_ref="$1"
|
||||
local _out="["
|
||||
local _first=1
|
||||
local _item
|
||||
for _item in "${_arr_ref[@]+"${_arr_ref[@]}"}"; do
|
||||
if [[ $_first -eq 1 ]]; then
|
||||
_out+="\"${_item}\""
|
||||
_first=0
|
||||
else
|
||||
_out+=",\"${_item}\""
|
||||
fi
|
||||
done
|
||||
_out+="]"
|
||||
echo "$_out"
|
||||
}
|
||||
|
||||
# _emit_json <profile_path> <kept_nameref> <removed_nameref>
|
||||
_emit_json() {
|
||||
local _p="$1"
|
||||
local _kept_json
|
||||
_kept_json="$(_json_array_from_nameref "$2")"
|
||||
local _removed_json
|
||||
_removed_json="$(_json_array_from_nameref "$3")"
|
||||
printf '{"profile":"%s","kept":%s,"removed":%s}\n' \
|
||||
"$_p" "$_kept_json" "$_removed_json"
|
||||
}
|
||||
|
||||
# ── auto-ejecución ────────────────────────────────────────────────────────────
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
clean_chrome_profile_extensions "$@"
|
||||
fi
|
||||
@@ -1,93 +0,0 @@
|
||||
---
|
||||
name: create_chrome_profile
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "create_chrome_profile --user-data-dir <dir> --profile <dir-name> --name <legible> [--port N] [--chrome-path <path>] [--no-launch] [--timeout-sec N] [--dry-run]"
|
||||
description: "Crea un perfil Chrome/Chromium nuevo en un user-data-dir: opcionalmente lanza chromium headless vía systemd-run para que la managed policy instale las extensiones forzadas (uBlock, web_proxy) y luego edita Local State para asignar el nombre legible al perfil. Con --no-launch crea solo la estructura de carpetas y la entrada en Local State sin arrancar Chrome."
|
||||
tags: [navegator, chromium, profile, browser, cdp, headless, scraping]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/browser/create_chrome_profile.sh"
|
||||
params:
|
||||
- name: --user-data-dir
|
||||
desc: "Raíz del user-data-dir de Chrome/Chromium. Puede no existir; la función lo crea. Obligatorio."
|
||||
- name: --profile
|
||||
desc: "Nombre de la carpeta del perfil dentro de user-data-dir, por ejemplo: Default, \"Profile 1\", Automation. Obligatorio."
|
||||
- name: --name
|
||||
desc: "Nombre legible visible en el selector de perfil de Chrome, por ejemplo: Work, Aurgi, Bot. Obligatorio."
|
||||
- name: --port
|
||||
desc: "Puerto CDP para el lanzamiento headless. Default: 9250. Usar un valor distinto al 9222 global para no colisionar."
|
||||
- name: --chrome-path
|
||||
desc: "Ruta absoluta al binario chromium/chrome. Si se omite, auto-detecta: chromium, chromium-browser, google-chrome, brave-browser."
|
||||
- name: --no-launch
|
||||
desc: "No lanza chromium. Solo crea la carpeta del perfil y edita Local State con el nombre legible. El perfil no tendrá extensiones instaladas. Útil para tests y CRUD offline."
|
||||
- name: --timeout-sec
|
||||
desc: "Segundos máximos esperando a que Preferences aparezca tras el lanzamiento headless. Default: 25."
|
||||
- name: --dry-run
|
||||
desc: "Describe las acciones que se ejecutarían sin lanzar ni escribir nada. Emite el JSON de resultado con dry_run:true."
|
||||
output: "JSON en stdout: {\"profile\":\"<dir-name>\",\"name\":\"<legible>\",\"launched\":true|false,\"preferences_created\":true|false}. En dry-run añade \"dry_run\":true. Exit 0 en éxito."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source $HOME/fn_registry/bash/functions/browser/create_chrome_profile.sh
|
||||
|
||||
# Modo offline (no lanza Chrome, solo CRUD de Local State — seguro para tests)
|
||||
create_chrome_profile \
|
||||
--user-data-dir /tmp/test_udd \
|
||||
--profile "Automation" \
|
||||
--name "Aurgi Bot" \
|
||||
--no-launch
|
||||
# Salida: {"profile":"Automation","name":"Aurgi Bot","launched":false,"preferences_created":false}
|
||||
|
||||
# Modo normal: lanza headless para que la policy instale uBlock y web_proxy,
|
||||
# luego asigna nombre en Local State
|
||||
create_chrome_profile \
|
||||
--user-data-dir "$HOME/.local/share/web_scraping/profiles" \
|
||||
--profile "Profile 1" \
|
||||
--name "Work" \
|
||||
--port 9250
|
||||
# Salida: {"profile":"Profile 1","name":"Work","launched":true,"preferences_created":true}
|
||||
|
||||
# Dry-run: describe acciones sin ejecutar nada
|
||||
create_chrome_profile \
|
||||
--user-data-dir "$HOME/.local/share/web_scraping/profiles" \
|
||||
--profile "Default" \
|
||||
--name "Scraping" \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala para aprovisionar perfiles nuevos en un user-data-dir de automatización antes de lanzar sesiones CDP con `script-navegador` o funciones del grupo `navegator`. En modo normal (sin `--no-launch`) la managed policy instala automáticamente uBlock y la extensión web_proxy en el perfil nuevo; en `--no-launch` sirve para tests unitarios o para crear la entrada de Local State sin depender de Chrome.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Lanzar chromium desde Bash tool de Claude da exit-144**: la función usa `systemd-run --user --collect` para aislar el proceso en su propio cgroup, evitando que el harness del agente lo mate. Esto es obligatorio; lanzar con `&` / `setsid` daría exit-144 en el contexto del agente.
|
||||
- **La managed policy instala las extensiones al arrancar el perfil**: NO pasar `--disable-extensions` — rompería la forcelist. Las extensiones force-listed (`ExtensionInstallForcelist` en `/etc/chromium/policies/managed/extensions.json`) se instalan en el perfil durante el primer arranque; en el headless inicial puede no completar la descarga si no hay red o si el timeout es corto.
|
||||
- **Dos chromium NO pueden compartir el mismo user-data-dir**: si ya hay un chromium corriendo sobre `--user-data-dir`, la función detecta `SingletonLock` y sale con exit 2 antes de lanzar. Para perfiles de automatización paralela, usa un `--user-data-dir` dedicado por perfil.
|
||||
- **Local State debe editarse con Chrome muerto**: la función para el unit de systemd y espera la desaparición de `SingletonLock` antes de editar `Local State`. Si se edita mientras Chrome está vivo, Chrome sobreescribe el archivo desde memoria al salir y los cambios de nombre se pierden.
|
||||
- **`--remote-allow-origins=*` necesita comillas en zsh**: el glob `*` se expande si no va entre comillas. La función pasa el flag correctamente internamente, pero si lo pasas tú en otros scripts acuérdate de las comillas.
|
||||
- **Perfil diario en `~/.config/chromium-cdp`**: en este equipo el fragmento `/etc/chromium.d/cdp` redirige el user-data-dir global a `~/.config/chromium-cdp`. Para automatización usar siempre un `--user-data-dir` dedicado fuera de `~/.config/`.
|
||||
- **Timeout corto puede dar `preferences_created: false`**: el perfil headless tarda entre 2-8 segundos en crear `Preferences` según la carga del sistema. Si se aumenta `--timeout-sec` a 45-60 en máquinas lentas se evitan falsos timeouts.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Código | Significado |
|
||||
|--------|------------|
|
||||
| 0 | Éxito |
|
||||
| 1 | Argumento obligatorio faltante o binario no encontrado |
|
||||
| 2 | Lock: ya hay un chromium usando el mismo user-data-dir |
|
||||
| 3 | Timeout esperando a que Preferences se cree |
|
||||
| 4 | Error editando Local State (JSON inválido tras escritura) |
|
||||
@@ -1,309 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# create_chrome_profile — crea un perfil Chrome/Chromium nuevo en un user-data-dir,
|
||||
# opcionalmente lanzando chromium headless para que la managed policy instale las
|
||||
# extensiones forzadas (uBlock, web_proxy). Edita Local State para asignar el nombre
|
||||
# legible al perfil.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
create_chrome_profile() {
|
||||
# ── defaults ──────────────────────────────────────────────────────────────
|
||||
local _udd=""
|
||||
local _profile_dir=""
|
||||
local _name=""
|
||||
local _port=9250
|
||||
local _chrome_path=""
|
||||
local _no_launch=0
|
||||
local _timeout_sec=25
|
||||
local _dry_run=0
|
||||
|
||||
# ── parse args ─────────────────────────────────────────────────────────────
|
||||
_usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage: create_chrome_profile --user-data-dir <dir> --profile <dir-name> --name <legible>
|
||||
[--port N] [--chrome-path <path>] [--no-launch] [--timeout-sec N] [--dry-run]
|
||||
|
||||
--user-data-dir Raíz del user-data-dir de Chrome/Chromium (obligatorio).
|
||||
--profile Nombre de la carpeta del perfil dentro de user-data-dir, ej: Default,
|
||||
"Profile 1", Automation (obligatorio).
|
||||
--name Nombre legible que aparece en el selector de perfil, ej: Work, Aurgi
|
||||
(obligatorio).
|
||||
--port Puerto CDP para el lanzamiento headless. Default: 9250.
|
||||
Usar un puerto distinto al 9222 global para no chocar.
|
||||
--chrome-path Ruta explícita al binario chromium/chrome. Auto-detecta si se omite.
|
||||
--no-launch No lanza chromium. Crea la carpeta y edita Local State offline.
|
||||
El perfil no tendrá extensiones instaladas; útil para tests/CRUD.
|
||||
--timeout-sec Segundos esperando a que Preferences aparezca tras el lanzamiento.
|
||||
Default: 25.
|
||||
--dry-run Describe las acciones sin lanzar ni escribir nada.
|
||||
|
||||
Exit codes:
|
||||
0 éxito
|
||||
1 error de argumento o validación
|
||||
2 lock: ya hay un chromium usando este user-data-dir
|
||||
3 timeout esperando a que Preferences se cree
|
||||
4 error editando Local State (JSON inválido tras escritura)
|
||||
EOF
|
||||
return 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--user-data-dir) _udd="$2"; shift 2 ;;
|
||||
--profile) _profile_dir="$2"; shift 2 ;;
|
||||
--name) _name="$2"; shift 2 ;;
|
||||
--port) _port="$2"; shift 2 ;;
|
||||
--chrome-path) _chrome_path="$2"; shift 2 ;;
|
||||
--no-launch) _no_launch=1; shift ;;
|
||||
--timeout-sec) _timeout_sec="$2"; shift 2 ;;
|
||||
--dry-run) _dry_run=1; shift ;;
|
||||
-h|--help) _usage; return 0 ;;
|
||||
*) echo "create_chrome_profile: argumento desconocido: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── validaciones obligatorias ──────────────────────────────────────────────
|
||||
if [[ -z "$_udd" ]]; then
|
||||
echo "create_chrome_profile: --user-data-dir es obligatorio" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$_profile_dir" ]]; then
|
||||
echo "create_chrome_profile: --profile es obligatorio" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$_name" ]]; then
|
||||
echo "create_chrome_profile: --name es obligatorio" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local _profile_path="${_udd}/${_profile_dir}"
|
||||
local _local_state="${_udd}/Local State"
|
||||
local _prefs_file="${_profile_path}/Preferences"
|
||||
|
||||
# ── guard: lock por user-data-dir ─────────────────────────────────────────
|
||||
# Dos procesos chromium no pueden compartir el mismo user-data-dir.
|
||||
if [[ $_dry_run -eq 0 && $_no_launch -eq 0 ]]; then
|
||||
local _singleton="${_udd}/SingletonLock"
|
||||
if [[ -e "$_singleton" ]]; then
|
||||
echo "create_chrome_profile: ya hay un chromium corriendo con --user-data-dir=${_udd}" >&2
|
||||
echo " (encontrado: ${_singleton})" >&2
|
||||
echo " Ciérralo o usa un user-data-dir distinto." >&2
|
||||
return 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── detección del binario chromium ────────────────────────────────────────
|
||||
local _bin=""
|
||||
if [[ -n "$_chrome_path" ]]; then
|
||||
if [[ ! -x "$_chrome_path" ]]; then
|
||||
echo "create_chrome_profile: binario no encontrado o no ejecutable: ${_chrome_path}" >&2
|
||||
return 1
|
||||
fi
|
||||
_bin="$_chrome_path"
|
||||
elif [[ $_no_launch -eq 0 ]]; then
|
||||
for _candidate in chromium chromium-browser google-chrome brave-browser; do
|
||||
if command -v "$_candidate" &>/dev/null; then
|
||||
_bin="$_candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -z "$_bin" ]]; then
|
||||
echo "create_chrome_profile: no se encontró binario chromium en PATH" >&2
|
||||
echo " Probados: chromium, chromium-browser, google-chrome, brave-browser" >&2
|
||||
echo " Usa --chrome-path o --no-launch." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── modo dry-run ──────────────────────────────────────────────────────────
|
||||
if [[ $_dry_run -eq 1 ]]; then
|
||||
echo "=== create_chrome_profile DRY-RUN ===" >&2
|
||||
echo " user-data-dir : ${_udd}" >&2
|
||||
echo " profile : ${_profile_dir}" >&2
|
||||
echo " name : ${_name}" >&2
|
||||
if [[ $_no_launch -eq 1 ]]; then
|
||||
echo " modo : --no-launch (sin chromium)" >&2
|
||||
echo " acciones : mkdir -p ${_profile_path}" >&2
|
||||
echo " editar ${_local_state} → info_cache + profiles_order" >&2
|
||||
else
|
||||
echo " binario : ${_bin}" >&2
|
||||
echo " puerto CDP : ${_port}" >&2
|
||||
echo " timeout : ${_timeout_sec}s" >&2
|
||||
echo " acciones : systemd-run unit=create-prof-<rand> chromium headless" >&2
|
||||
echo " poll Preferences hasta ${_timeout_sec}s" >&2
|
||||
echo " systemctl --user stop unit" >&2
|
||||
echo " editar ${_local_state} → info_cache + profiles_order" >&2
|
||||
fi
|
||||
printf '{"profile":"%s","name":"%s","launched":false,"preferences_created":false,"dry_run":true}\n' \
|
||||
"$_profile_dir" "$_name"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── crear directorio del perfil ───────────────────────────────────────────
|
||||
mkdir -p "$_profile_path"
|
||||
|
||||
# ── también asegurar que user-data-dir existe ──────────────────────────────
|
||||
mkdir -p "$_udd"
|
||||
|
||||
# ── modo --no-launch: solo estructura + Local State ────────────────────────
|
||||
local _launched=false
|
||||
local _prefs_created=false
|
||||
|
||||
if [[ $_no_launch -eq 1 ]]; then
|
||||
_update_local_state "$_udd" "$_local_state" "$_profile_dir" "$_name"
|
||||
if [[ -f "$_prefs_file" ]]; then
|
||||
_prefs_created=true
|
||||
fi
|
||||
printf '{"profile":"%s","name":"%s","launched":false,"preferences_created":%s}\n' \
|
||||
"$_profile_dir" "$_name" "$_prefs_created"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── lanzar chromium headless vía systemd-run ──────────────────────────────
|
||||
# systemd-run --user aísla el proceso del cgroup del agente (evita exit-144).
|
||||
# NO se pasa --disable-extensions para que la managed policy instale las
|
||||
# extensiones force-listed (uBlock, web_proxy).
|
||||
local _rand
|
||||
_rand="$(tr -dc 'a-z0-9' </dev/urandom | head -c 8 2>/dev/null || echo "$$")"
|
||||
local _unit="create-prof-${_rand}"
|
||||
|
||||
systemd-run \
|
||||
--user \
|
||||
--collect \
|
||||
--unit="$_unit" \
|
||||
--setenv=DISPLAY=:0 \
|
||||
--setenv=XAUTHORITY="${HOME}/.Xauthority" \
|
||||
"$_bin" \
|
||||
"--user-data-dir=${_udd}" \
|
||||
"--profile-directory=${_profile_dir}" \
|
||||
"--headless=new" \
|
||||
"--no-first-run" \
|
||||
"--remote-debugging-port=${_port}" \
|
||||
"--remote-allow-origins=*" \
|
||||
"about:blank" 2>/dev/null || true
|
||||
|
||||
_launched=true
|
||||
|
||||
# ── poll: esperar a que Preferences exista ────────────────────────────────
|
||||
local _elapsed=0
|
||||
while [[ $_elapsed -lt $_timeout_sec ]]; do
|
||||
if [[ -f "$_prefs_file" ]]; then
|
||||
_prefs_created=true
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
(( _elapsed++ )) || true
|
||||
done
|
||||
|
||||
# ── detener el unit Y matar TODO el árbol de chromium de este udd ───────────
|
||||
# Necesario para poder editar Local State sin que Chrome lo sobreescriba. Ni el
|
||||
# `systemctl stop` ni un `pkill -f --user-data-dir=` bastan: los procesos hijos
|
||||
# (zygote/gpu/renderer) no repiten el flag --user-data-dir pero sí referencian la
|
||||
# ruta del user-data-dir en otros argumentos. Los matamos por PID seleccionando
|
||||
# los procesos chromium cuyo cmdline contiene la ruta del udd (seguro: no mata
|
||||
# este propio script porque filtramos por '[c]hromium').
|
||||
systemctl --user kill -s SIGKILL "$_unit" 2>/dev/null || true
|
||||
systemctl --user stop "$_unit" 2>/dev/null || true
|
||||
# Matar por PID los procesos cuyo comm es exactamente "chromium" (pgrep -x) y cuyo cmdline
|
||||
# contiene la ruta del udd. Usamos pgrep -x para NO auto-matchear grep/pgrep: el path del udd
|
||||
# contiene la cadena "chromium" (~/.config/chromium-cdp).
|
||||
local _wait=0 _p _pids
|
||||
while :; do
|
||||
_pids=""
|
||||
for _p in $(pgrep -x chromium 2>/dev/null); do
|
||||
tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_udd" && _pids="$_pids $_p"
|
||||
done
|
||||
[[ -z "${_pids// }" ]] && break
|
||||
# shellcheck disable=SC2086
|
||||
kill -TERM $_pids 2>/dev/null || true
|
||||
sleep 0.5
|
||||
(( _wait++ )) || true
|
||||
if [[ $_wait -ge 20 ]]; then
|
||||
# shellcheck disable=SC2086
|
||||
kill -9 $_pids 2>/dev/null || true
|
||||
break
|
||||
fi
|
||||
done
|
||||
rm -f "${_udd}/SingletonLock" 2>/dev/null || true
|
||||
|
||||
if [[ "$_prefs_created" == false ]]; then
|
||||
echo "create_chrome_profile: timeout (${_timeout_sec}s) esperando a que se cree: ${_prefs_file}" >&2
|
||||
echo " El directorio del perfil puede existir pero está vacío." >&2
|
||||
printf '{"profile":"%s","name":"%s","launched":true,"preferences_created":false,"error":"timeout"}\n' \
|
||||
"$_profile_dir" "$_name"
|
||||
return 3
|
||||
fi
|
||||
|
||||
# ── editar Local State para asignar nombre legible ────────────────────────
|
||||
_update_local_state "$_udd" "$_local_state" "$_profile_dir" "$_name"
|
||||
|
||||
printf '{"profile":"%s","name":"%s","launched":true,"preferences_created":true}\n' \
|
||||
"$_profile_dir" "$_name"
|
||||
}
|
||||
|
||||
# ── helper: editar Local State con python3 ────────────────────────────────────
|
||||
# Crea/actualiza info_cache.<profile_dir> con name + is_using_default_name=false
|
||||
# y añade profile_dir a profiles_order si no está.
|
||||
_update_local_state() {
|
||||
local _udd="$1"
|
||||
local _local_state="$2"
|
||||
local _profile_dir="$3"
|
||||
local _name="$4"
|
||||
local _today
|
||||
_today="$(date +%Y%m%d)"
|
||||
|
||||
# Si Local State no existe, crear una estructura mínima
|
||||
if [[ ! -f "$_local_state" ]]; then
|
||||
printf '{"profile":{"info_cache":{},"profiles_order":[]}}\n' > "$_local_state"
|
||||
fi
|
||||
|
||||
# Backup antes de modificar (no sobreescribir el del mismo día)
|
||||
local _backup="${_local_state}.bak.${_today}"
|
||||
if [[ ! -f "$_backup" ]]; then
|
||||
cp "$_local_state" "$_backup"
|
||||
fi
|
||||
|
||||
# Editar con python3
|
||||
if ! python3 - "$_local_state" "$_profile_dir" "$_name" <<'PY'; then
|
||||
import sys, json
|
||||
|
||||
ls_path = sys.argv[1]
|
||||
prof_dir = sys.argv[2]
|
||||
prof_name = sys.argv[3]
|
||||
|
||||
with open(ls_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Asegurar estructura profile
|
||||
profile_section = data.setdefault("profile", {})
|
||||
info_cache = profile_section.setdefault("info_cache", {})
|
||||
|
||||
# Crear o actualizar la entrada del perfil en info_cache
|
||||
entry = info_cache.setdefault(prof_dir, {})
|
||||
entry["name"] = prof_name
|
||||
entry["is_using_default_name"] = False
|
||||
|
||||
# Añadir a profiles_order si no está
|
||||
order = profile_section.setdefault("profiles_order", [])
|
||||
if prof_dir not in order:
|
||||
order.append(prof_dir)
|
||||
|
||||
with open(ls_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, separators=(",", ":"))
|
||||
PY
|
||||
echo "create_chrome_profile: error editando Local State con python3" >&2
|
||||
return 4
|
||||
fi
|
||||
|
||||
# Validar JSON tras escritura
|
||||
if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$_local_state" 2>/dev/null; then
|
||||
echo "create_chrome_profile: JSON inválido tras escribir Local State; restaurando backup" >&2
|
||||
cp "$_backup" "$_local_state"
|
||||
return 4
|
||||
fi
|
||||
}
|
||||
|
||||
# ── auto-ejecución ────────────────────────────────────────────────────────────
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
create_chrome_profile "$@"
|
||||
fi
|
||||
@@ -1,93 +0,0 @@
|
||||
---
|
||||
name: delete_chrome_profile
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "delete_chrome_profile --user-data-dir <dir> --profile <name> [--profile <name>]... [--dry-run]"
|
||||
description: "Borra por completo uno o varios perfiles Chrome/Chromium: elimina la carpeta del perfil del disco y limpia todas sus referencias en Local State (info_cache, profiles_order, last_active_profiles, last_used, variations_google_groups). Requiere que Chromium esté cerrado. Hace backup automático de Local State antes de editar y valida el JSON resultante restaurando el backup si es inválido."
|
||||
tags: [navegator, chromium, profile, cleanup, browser, scraping]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/browser/delete_chrome_profile.sh"
|
||||
params:
|
||||
- name: --user-data-dir
|
||||
desc: "Ruta raíz del user-data-dir de Chrome/Chromium (obligatorio). Ej: ~/.config/chromium"
|
||||
- name: --profile
|
||||
desc: "Nombre de la carpeta del perfil a borrar (repetible, mínimo uno obligatorio). Ej: 'Default', 'Profile 1'"
|
||||
- name: --dry-run
|
||||
desc: "Muestra qué carpetas borraría y qué claves de Local State quitaría sin tocar nada. No activa el guard de chromium cerrado."
|
||||
output: "JSON en stdout. Modo real: {deleted:[{profile, dir_removed, local_state_cleaned}...], last_used:'<nuevo>', backup:'Local State.bak.YYYYMMDD'}. Modo dry-run: {dry_run:true, would_delete:[{profile, dir_exists, would_remove, local_state_would_clean}...]}. Errores a stderr con exit != 0."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Cerrar Chromium primero (OBLIGATORIO en modo real)
|
||||
pkill -TERM chromium
|
||||
|
||||
# Borrar un perfil
|
||||
source $HOME/fn_registry/bash/functions/browser/delete_chrome_profile.sh
|
||||
delete_chrome_profile \
|
||||
--user-data-dir "$HOME/.config/chromium" \
|
||||
--profile "Profile 1"
|
||||
# Salida: {"deleted":[{"profile":"Profile 1","dir_removed":true,"local_state_cleaned":true}],"last_used":"Default","backup":"Local State.bak.20260606"}
|
||||
|
||||
# Borrar varios perfiles a la vez
|
||||
delete_chrome_profile \
|
||||
--user-data-dir "$HOME/.config/chromium" \
|
||||
--profile "Profile 1" \
|
||||
--profile "Profile 2"
|
||||
|
||||
# Previsualizar sin tocar nada (no requiere Chromium cerrado)
|
||||
delete_chrome_profile \
|
||||
--user-data-dir "$HOME/.config/chromium" \
|
||||
--profile "Profile 1" \
|
||||
--dry-run
|
||||
# Salida: {"dry_run":true,"would_delete":[{"profile":"Profile 1","dir_exists":true,"would_remove":true,"local_state_would_clean":true}]}
|
||||
|
||||
# Con un user-data-dir sintético para pruebas
|
||||
mkdir -p /tmp/test_udd/Default /tmp/test_udd/"Profile 1"
|
||||
echo '{"profile":{"info_cache":{"Default":{},"Profile 1":{}},"profiles_order":["Default","Profile 1"],"last_active_profiles":["Profile 1"],"last_used":"Profile 1"},"variations_google_groups":{}}' \
|
||||
> "/tmp/test_udd/Local State"
|
||||
delete_chrome_profile --user-data-dir /tmp/test_udd --profile "Profile 1" --dry-run
|
||||
```
|
||||
|
||||
También ejecutable directamente con `fn run`:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
./fn run delete_chrome_profile_bash_browser -- \
|
||||
--user-data-dir "$HOME/.config/chromium" --profile "Profile 1" --dry-run
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala cuando necesites limpiar completamente un perfil de Chromium: antes de crear un perfil de scraping fresco, para depurar problemas de perfiles corruptos, o para liberar espacio eliminando perfiles de sesión temporales. A diferencia de borrar solo la carpeta, esta función también retira las referencias de `Local State` para que Chromium no muestre el perfil fantasma ni intente acceder a él al arrancar.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Chromium DEBE estar cerrado antes de ejecutar en modo real**. Chromium reescribe `Local State` desde memoria al cerrar y desharía todos los cambios. La función comprueba `pgrep -x chromium` y aborta con exit 2 si detecta procesos vivos. En `--dry-run` este check no se activa.
|
||||
- **Operación destructiva e irreversible**: todos los datos del perfil (cookies, logins guardados, historial, caché, contraseñas) se pierden permanentemente al borrar la carpeta. No hay papelera.
|
||||
- **Backup automático de Local State**: antes de editar, la función crea `<udd>/Local State.bak.YYYYMMDD`. Si ya existe un backup del día no lo sobreescribe. Restaurar manualmente: `cp "Local State.bak.YYYYMMDD" "Local State"`.
|
||||
- **Validación JSON tras edición**: si el JSON de Local State queda inválido (raro pero posible con perfiles con nombres muy especiales), la función restaura el backup automáticamente y sale con exit != 0.
|
||||
- **Nombres de perfil con espacios**: los nombres como `"Profile 1"` se pasan entre comillas al script Python. El parsing usa `json.loads` por lo que los espacios no dan problemas, pero deben pasarse correctamente en el shell: `--profile "Profile 1"`.
|
||||
- **python3 > jq > warning**: usa python3 para editar Local State, jq como fallback. Si ninguno está disponible, las carpetas se borran pero Local State queda sin modificar (Chromium podría mostrar perfiles fantasma al arrancar).
|
||||
- **last_used reasignado automáticamente**: si el perfil borrado era el `last_used`, la función asigna el primer perfil restante en `info_cache`. Si no queda ningún perfil, `last_used` queda como cadena vacía.
|
||||
- **No afecta a `--profile Default` si es el único perfil**: lo borrará igualmente — Chromium puede quedar sin ningún perfil configurado y recreará Default al arrancar.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Código | Significado |
|
||||
|--------|-------------|
|
||||
| 0 | Éxito o dry-run completado |
|
||||
| 1 | Argumento inválido, directorio o Local State no encontrado, JSON inválido tras edición |
|
||||
| 2 | Chromium está corriendo (solo en modo real) |
|
||||
@@ -1,264 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# delete_chrome_profile — borra por completo uno o varios perfiles Chrome/Chromium:
|
||||
# elimina la carpeta del perfil y limpia todas las referencias en Local State
|
||||
# (info_cache, profiles_order, last_active_profiles, last_used, variations_google_groups).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
delete_chrome_profile() {
|
||||
# ── defaults ──────────────────────────────────────────────────────────────
|
||||
local _user_data_dir=""
|
||||
local _profiles=()
|
||||
local _dry_run=0
|
||||
|
||||
# ── parse args ─────────────────────────────────────────────────────────────
|
||||
_usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage: delete_chrome_profile --user-data-dir <dir> --profile <name> [--profile <name>]... [--dry-run]
|
||||
|
||||
--user-data-dir <dir> Ruta raíz del user-data-dir de Chrome/Chromium (obligatorio).
|
||||
--profile <name> Nombre de la carpeta del perfil, ej. "Default" o "Profile 1"
|
||||
(repetible, al menos uno obligatorio).
|
||||
--dry-run Muestra qué borraría y qué claves de Local State quitaría
|
||||
sin tocar nada.
|
||||
|
||||
Exit codes:
|
||||
0 éxito (o dry-run completado)
|
||||
1 error de argumento o validación
|
||||
2 chromium está corriendo (solo en modo real)
|
||||
EOF
|
||||
return 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--user-data-dir) _user_data_dir="$2"; shift 2 ;;
|
||||
--profile) _profiles+=("$2"); shift 2 ;;
|
||||
--dry-run) _dry_run=1; shift ;;
|
||||
-h|--help) _usage; return 0 ;;
|
||||
*) echo "delete_chrome_profile: argumento desconocido: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── validaciones de argumentos ────────────────────────────────────────────
|
||||
if [[ -z "$_user_data_dir" ]]; then
|
||||
echo "delete_chrome_profile: --user-data-dir es obligatorio" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ${#_profiles[@]} -eq 0 ]]; then
|
||||
echo "delete_chrome_profile: se requiere al menos un --profile" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$_user_data_dir" ]]; then
|
||||
echo "delete_chrome_profile: user-data-dir no encontrado: ${_user_data_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local _local_state="${_user_data_dir}/Local State"
|
||||
if [[ ! -f "$_local_state" ]]; then
|
||||
echo "delete_chrome_profile: Local State no encontrado: ${_local_state}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── guard: ningún chromium debe tener ESTE user-data-dir abierto (excepto en dry-run) ──
|
||||
# Por-udd, no global. Comprobamos por PID con comm=chromium (pgrep -x) y leemos su cmdline,
|
||||
# para NO auto-matchear el propio `grep`/`pgrep` del pipe: como el path del udd contiene la
|
||||
# cadena "chromium" (p.ej. ~/.config/chromium-cdp), un `pgrep -af '[c]hromium' | grep <udd>`
|
||||
# se detecta a sí mismo. pgrep -x chromium solo lista procesos cuyo nombre es exactamente
|
||||
# "chromium" (el navegador), nunca grep/pgrep/bash.
|
||||
if [[ $_dry_run -eq 0 ]]; then
|
||||
local _p _busy=0
|
||||
for _p in $(pgrep -x chromium 2>/dev/null); do
|
||||
if tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_user_data_dir"; then
|
||||
_busy=1; break
|
||||
fi
|
||||
done
|
||||
if [[ $_busy -eq 1 ]]; then
|
||||
echo "delete_chrome_profile: hay un chromium con este user-data-dir abierto — ciérralo antes de borrar perfiles:" >&2
|
||||
echo " pkill -TERM chromium" >&2
|
||||
echo "(Chromium reescribe Local State desde memoria al cerrar y desharía el borrado)" >&2
|
||||
return 2
|
||||
fi
|
||||
fi
|
||||
|
||||
local _today
|
||||
_today="$(date +%Y%m%d)"
|
||||
|
||||
# ── modo dry-run ──────────────────────────────────────────────────────────
|
||||
if [[ $_dry_run -eq 1 ]]; then
|
||||
echo "=== delete_chrome_profile DRY-RUN ===" >&2
|
||||
local _p
|
||||
for _p in "${_profiles[@]}"; do
|
||||
local _pdir="${_user_data_dir}/${_p}"
|
||||
if [[ -d "$_pdir" ]]; then
|
||||
echo " [borraría] rm -rf ${_pdir}" >&2
|
||||
else
|
||||
echo " [no existe] ${_pdir}" >&2
|
||||
fi
|
||||
echo " [Local State] quitaría claves para perfil: '${_p}'" >&2
|
||||
echo " profile.info_cache.${_p}" >&2
|
||||
echo " profile.profiles_order (entrada '${_p}')" >&2
|
||||
echo " profile.last_active_profiles (entrada '${_p}')" >&2
|
||||
echo " profile.last_used (si == '${_p}', reasignar)" >&2
|
||||
echo " variations_google_groups.${_p} (si existe)" >&2
|
||||
done
|
||||
|
||||
# Construir JSON de dry-run inline
|
||||
local _dry_items="" _dry_first=1
|
||||
for _p in "${_profiles[@]}"; do
|
||||
local _pdir="${_user_data_dir}/${_p}"
|
||||
local _sep="" _exists="false"
|
||||
[[ $_dry_first -eq 0 ]] && _sep=","
|
||||
_dry_first=0
|
||||
[[ -d "$_pdir" ]] && _exists="true"
|
||||
_dry_items+="${_sep}{\"profile\":\"${_p}\",\"dir_exists\":${_exists},\"would_remove\":${_exists},\"local_state_would_clean\":true}"
|
||||
done
|
||||
printf '{"dry_run":true,"would_delete":[%s]}\n' "$_dry_items"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── backup de Local State (no sobreescribir el del día) ───────────────────
|
||||
local _backup="${_local_state}.bak.${_today}"
|
||||
if [[ ! -f "$_backup" ]]; then
|
||||
cp "$_local_state" "$_backup"
|
||||
fi
|
||||
|
||||
# ── borrar carpetas de perfil ──────────────────────────────────────────────
|
||||
local _deleted_results=() # "profile|dir_removed|ls_cleaned"
|
||||
local _p
|
||||
for _p in "${_profiles[@]}"; do
|
||||
local _pdir="${_user_data_dir}/${_p}"
|
||||
local _dir_removed=false
|
||||
if [[ -d "$_pdir" ]]; then
|
||||
rm -rf "$_pdir"
|
||||
_dir_removed=true
|
||||
fi
|
||||
_deleted_results+=("${_p}|${_dir_removed}|false")
|
||||
done
|
||||
|
||||
# ── construir lista Python de perfiles a eliminar ─────────────────────────
|
||||
local _py_profiles_list=""
|
||||
for _p in "${_profiles[@]}"; do
|
||||
_py_profiles_list+="\"${_p}\","
|
||||
done
|
||||
_py_profiles_list="[${_py_profiles_list%,}]"
|
||||
|
||||
# ── editar Local State con python3 ────────────────────────────────────────
|
||||
local _ls_cleaned=false
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$_local_state" "$_py_profiles_list" <<'PY'
|
||||
import sys, json
|
||||
|
||||
ls_path = sys.argv[1]
|
||||
profiles_to_delete = json.loads(sys.argv[2])
|
||||
|
||||
with open(ls_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
profile_section = data.get("profile", {})
|
||||
|
||||
# 1. profile.info_cache — eliminar cada perfil
|
||||
info_cache = profile_section.get("info_cache", {})
|
||||
for p in profiles_to_delete:
|
||||
info_cache.pop(p, None)
|
||||
|
||||
# 2. profile.profiles_order — quitar entradas del perfil
|
||||
if "profiles_order" in profile_section and isinstance(profile_section["profiles_order"], list):
|
||||
profile_section["profiles_order"] = [
|
||||
x for x in profile_section["profiles_order"] if x not in profiles_to_delete
|
||||
]
|
||||
|
||||
# 3. profile.last_active_profiles — quitar entradas del perfil
|
||||
if "last_active_profiles" in profile_section and isinstance(profile_section["last_active_profiles"], list):
|
||||
profile_section["last_active_profiles"] = [
|
||||
x for x in profile_section["last_active_profiles"] if x not in profiles_to_delete
|
||||
]
|
||||
|
||||
# 4. profile.last_used — reasignar si apunta a un perfil borrado
|
||||
last_used = profile_section.get("last_used", "")
|
||||
if last_used in profiles_to_delete:
|
||||
remaining = [k for k in info_cache.keys() if k not in profiles_to_delete]
|
||||
profile_section["last_used"] = remaining[0] if remaining else ""
|
||||
|
||||
# 5. variations_google_groups — limpiar entradas del perfil (si existe)
|
||||
vgg = data.get("variations_google_groups", {})
|
||||
for p in profiles_to_delete:
|
||||
vgg.pop(p, None)
|
||||
|
||||
with open(ls_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, separators=(",", ":"))
|
||||
PY
|
||||
_ls_cleaned=true
|
||||
|
||||
# ── fallback con jq ───────────────────────────────────────────────────────
|
||||
elif command -v jq >/dev/null 2>&1; then
|
||||
local _tmp_ls
|
||||
_tmp_ls="$(mktemp)"
|
||||
local _jq_expr="."
|
||||
for _p in "${_profiles[@]}"; do
|
||||
_jq_expr+=" | del(.profile.info_cache[\"${_p}\"])"
|
||||
_jq_expr+=" | del(.variations_google_groups[\"${_p}\"])"
|
||||
_jq_expr+=" | if .profile.profiles_order then .profile.profiles_order -= [\"${_p}\"] else . end"
|
||||
_jq_expr+=" | if .profile.last_active_profiles then .profile.last_active_profiles -= [\"${_p}\"] else . end"
|
||||
done
|
||||
if jq "${_jq_expr}" "$_local_state" > "$_tmp_ls" 2>/dev/null; then
|
||||
mv "$_tmp_ls" "$_local_state"
|
||||
_ls_cleaned=true
|
||||
else
|
||||
echo "delete_chrome_profile: advertencia — jq falló editando Local State" >&2
|
||||
rm -f "$_tmp_ls"
|
||||
fi
|
||||
|
||||
else
|
||||
echo "delete_chrome_profile: advertencia — ni python3 ni jq disponibles; carpetas borradas pero Local State no modificado" >&2
|
||||
fi
|
||||
|
||||
# ── validar que el JSON resultante sigue siendo parseable ─────────────────
|
||||
if [[ "$_ls_cleaned" == "true" ]]; then
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
if ! python3 -c "import sys, json; json.load(open(sys.argv[1]))" "$_local_state" 2>/dev/null; then
|
||||
echo "delete_chrome_profile: JSON de Local State inválido tras edición — restaurando backup" >&2
|
||||
cp "$_backup" "$_local_state"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── actualizar _deleted_results con ls_cleaned ────────────────────────────
|
||||
local _updated_results=()
|
||||
for _entry in "${_deleted_results[@]}"; do
|
||||
local _ep _edr _els
|
||||
IFS='|' read -r _ep _edr _els <<< "$_entry"
|
||||
_updated_results+=("${_ep}|${_edr}|${_ls_cleaned}")
|
||||
done
|
||||
|
||||
# ── leer last_used resultante ──────────────────────────────────────────────
|
||||
local _new_last_used=""
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
_new_last_used="$(python3 -c "
|
||||
import sys, json
|
||||
data = json.load(open(sys.argv[1]))
|
||||
print(data.get('profile', {}).get('last_used', ''))
|
||||
" "$_local_state" 2>/dev/null || echo "")"
|
||||
fi
|
||||
|
||||
# ── construir JSON de resultado inline ────────────────────────────────────
|
||||
local _result_items="" _res_first=1
|
||||
for _entry in "${_updated_results[@]+"${_updated_results[@]}"}"; do
|
||||
local _pn _dr _lc
|
||||
IFS='|' read -r _pn _dr _lc <<< "$_entry"
|
||||
local _rsep=""
|
||||
[[ $_res_first -eq 0 ]] && _rsep=","
|
||||
_res_first=0
|
||||
_result_items+="${_rsep}{\"profile\":\"${_pn}\",\"dir_removed\":${_dr},\"local_state_cleaned\":${_lc}}"
|
||||
done
|
||||
printf '{"deleted":[%s],"last_used":"%s","backup":"Local State.bak.%s"}\n' \
|
||||
"$_result_items" "$_new_last_used" "$_today"
|
||||
}
|
||||
|
||||
# ── auto-ejecución ────────────────────────────────────────────────────────────
|
||||
if [[ "${BASH_SOURCE[0]:-}" == "${0}" ]]; then
|
||||
delete_chrome_profile "$@"
|
||||
fi
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
name: install_chromium_proxy_extension
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: 1.0.0
|
||||
purity: impure
|
||||
signature: install_chromium_proxy_extension --ext-dir DIR [--name NAME] [--stable-dir DIR] [--uninstall]
|
||||
description: "Instala una extension desempaquetada de Chromium en todos los perfiles del usuario de forma persistente, escribiendo un fragmento en /etc/chromium.d/ que el wrapper de Chromium carga en cada arranque. Pensado para distribuir la extension de toggle de proxy de web_proxy sin Web Store, pero sirve para cualquier extension desempaquetada."
|
||||
tags: [web-proxy, chromium, extension, browser, proxy, install]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
params:
|
||||
- name: --ext-dir
|
||||
desc: "Directorio de la extension desempaquetada de origen (debe contener manifest.json). Obligatorio salvo en --uninstall."
|
||||
- name: --name
|
||||
desc: "Nombre del fragmento en /etc/chromium.d/ (default web_proxy_ext). Identifica esta instalacion para poder desinstalarla."
|
||||
- name: --stable-dir
|
||||
desc: "Ruta estable donde se copia la extension, independiente del repo (default ~/.web_proxy/extension). --load-extension apunta aqui."
|
||||
- name: --uninstall
|
||||
desc: "Elimina el fragmento de /etc/chromium.d/ y la copia estable. No requiere --ext-dir."
|
||||
output: "JSON en stdout: {installed|uninstalled, name, stable_dir, chromiumd, ext_id}. Requiere sudo para escribir en /etc/chromium.d/."
|
||||
file_path: bash/functions/browser/install_chromium_proxy_extension.sh
|
||||
---
|
||||
|
||||
# install_chromium_proxy_extension
|
||||
|
||||
Instala una extension desempaquetada de Chromium en **todos los perfiles** del
|
||||
usuario, de forma persistente, sin pasar por la Chrome Web Store.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Instalar la extension de toggle de proxy de web_proxy en todos los perfiles
|
||||
install_chromium_proxy_extension --ext-dir /home/enmanuel/fn_registry/apps/web_proxy/extension
|
||||
|
||||
# Desinstalarla
|
||||
install_chromium_proxy_extension --uninstall
|
||||
|
||||
# Otra extension, con nombre y ruta estable propios
|
||||
install_chromium_proxy_extension --ext-dir ~/mis-extensiones/foo --name foo_ext --stable-dir ~/.local/share/foo_ext
|
||||
```
|
||||
|
||||
Tras instalar, cierra y vuelve a abrir Chromium: la extension aparece en todos
|
||||
los perfiles, incluidos los que se creen despues.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas que una extension desempaquetada este presente en todos los
|
||||
perfiles de Chromium de una maquina (por ejemplo, un toggle de proxy de captura
|
||||
preconfigurado) y no quieres publicarla en la Web Store ni cargarla a mano en
|
||||
cada perfil. Es la pieza que hace que `web_proxy` quede "a un clic" en cualquier
|
||||
ventana de Chromium.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Requiere sudo** para escribir en `/etc/chromium.d/`. Ten las credenciales
|
||||
cacheadas (`sudo -v`) antes de invocarla de forma no interactiva.
|
||||
- **Solo para el wrapper de Chromium de Debian/Ubuntu** (paquete `chromium`,
|
||||
no snap ni Google Chrome). El wrapper hace `source /etc/chromium.d/*` en cada
|
||||
arranque. Comprueba con `head -1 $(command -v chromium)` que es un script.
|
||||
- **`--enable-remote-extensions` es imprescindible** en estos builds: sin el,
|
||||
el wrapper anade `--disable-extensions-except` y `--disable-background-networking`,
|
||||
que deshabilitan toda extension que no venga por `--load-extension`. El
|
||||
fragmento generado lo incluye; por eso las demas extensiones del usuario
|
||||
siguen funcionando.
|
||||
- La extension se carga **desempaquetada** (`--load-extension`), no como `.crx`
|
||||
firmado. Chromium puede mostrar un aviso de "extensiones en modo desarrollador".
|
||||
El force-install via managed policy con `.crx` local + `update_url file://`
|
||||
no funciona con este wrapper (lo bloquea `--disable-extensions-except`).
|
||||
- El ID de la extension depende de `--stable-dir` (se deriva del path). Si
|
||||
cambias la ruta estable, el ID cambia.
|
||||
- No reinicia Chromium: los cambios aplican en el siguiente arranque del
|
||||
navegador.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.0.0 (2026-06-02) — version inicial. Instala/desinstala extension global via /etc/chromium.d con --enable-remote-extensions + --load-extension.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user