Compare commits
605 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c7ff8d761 | |||
| 138f4b2713 | |||
| 25425a5fd6 | |||
| 89441539fa | |||
| 1d3d2f43b3 | |||
| 2effb688b0 | |||
| eb30074792 | |||
| f8efb7d177 | |||
| f428f2c82a | |||
| f36d091704 | |||
| 938853d268 | |||
| b31ea70771 | |||
| 2e5c630d38 | |||
| c52846d475 | |||
| b5affae68c | |||
| 5b4452b9fe | |||
| e0f8f3a068 | |||
| b21e7587ad | |||
| d4924f5cab | |||
| 853b3c0363 | |||
| f164ef230f | |||
| ff255c9a3c | |||
| 6c7f60fb6c | |||
| 75ac96a2d1 | |||
| da56085e74 | |||
| ecd864f2d3 | |||
| a91ef5aace | |||
| c2bdc586a4 | |||
| 61a46e4b21 | |||
| 3633d128aa | |||
| 892ff4f789 | |||
| 4388b54356 | |||
| b21adb40c9 | |||
| 6fd2e9d071 | |||
| d9ef4e54f4 | |||
| 2ea9206934 | |||
| 355bcac6c7 | |||
| 4eb4c1cf98 | |||
| 40aacac590 | |||
| e9bcbecd24 | |||
| 7eb7b3d0c8 | |||
| 61ec4c8a76 | |||
| a843f84a18 | |||
| 6f3c129a14 | |||
| bc270db723 | |||
| a3a263702b | |||
| 78c4f593a4 | |||
| 0f72cc8ad3 | |||
| 030e44b027 | |||
| ca2e5588cc | |||
| 5fb2269c00 | |||
| 5e6a974a5d | |||
| 5d2a14e50a | |||
| 212875ed0d | |||
| d6175964e4 | |||
| 5974484bd4 | |||
| 67fff0d677 | |||
| 890f641692 | |||
| ae5d27a5ec | |||
| 0ed949d235 | |||
| c438dc6916 | |||
| 4027aeaaf5 | |||
| 9ff0b3900c | |||
| ce9fa3b451 | |||
| e0cce972ea | |||
| 380a7a8f35 | |||
| 7ecbee1175 | |||
| fe39de8b22 | |||
| 951a77ec7f | |||
| 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
-4
@@ -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). `apps/*` y `analysis/*` estan en el `.gitignore` del repo padre — el codigo de cada app vive en `apps/<name>/.git/`. 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). Ver `.claude/rules/apps_subrepo.md`.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -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,14 +30,14 @@ 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:
|
||||
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/lucas/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/lucas/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
|
||||
git -C /home/lucas/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 +49,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 +116,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 +187,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 +346,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
|
||||
```
|
||||
|
||||
+260
-132
@@ -1,68 +1,238 @@
|
||||
---
|
||||
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.
|
||||
description: Modo full-auto self-Q&A. Toma issue o flow con DoD definido, valida readiness, ejecuta hasta cerrarlo sin interaccion humana. Ante cada decision se autoformula la pregunta, se autoresponde con razonamiento explicito, y avanza. Spawnea subagentes. Para.
|
||||
---
|
||||
|
||||
# /autopilot — Comando autonomo unificado
|
||||
# /autopilot — Modo autonomo end-to-end con self-Q&A
|
||||
|
||||
Comando UNICO para ejecutar issue o flow autonomo end-to-end. Sustituye a `/autonomous-task` (deprecado). Hace dos cosas:
|
||||
Ejecuta un issue o flow **hasta cierre** sin intervencion humana. Ante cada decision, Claude **se formula la pregunta a si mismo y se la responde** (self-Q&A) con razonamiento trazable, en vez de abortar. Auto-prefiere la opcion **Recomendada** cuando exista; cuando no, decide en base a evidencia del codigo, registry, y reglas. Cada Q&A queda persistido en `task_runs.events_json[]` para auditoria. Spawnea subagentes en paralelo y persiste estado en `task_runs`.
|
||||
|
||||
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.
|
||||
Diferencia con comandos relacionados:
|
||||
|
||||
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`.
|
||||
| Comando | Que hace |
|
||||
|---|---|
|
||||
| `/autonomous-task <issue>` | Wrapper directo de `fn-orquestador` (registry-bound, bucle 5 fases) |
|
||||
| `/fix-issue <issue>` | Flujo guiado humano: rama + tasks + tests + version + close (pregunta cuando duda) |
|
||||
| `/flow run <NNNN>` | Runner manual de flows (fase 2 — no implementado) |
|
||||
| `/autopilot <target>` | **Meta-dispatcher**. Detecta issue vs flow, valida DoD, dispatch correcto, auto-defaults SIEMPRE |
|
||||
|
||||
## 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 issue:<NNNN> # issue NNNN explicito
|
||||
/autopilot i:<NNNN> # alias issue
|
||||
/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
|
||||
/autopilot f:<NNNN> # alias flow
|
||||
/autopilot check <target> # solo audita DoD readiness, no ejecuta
|
||||
/autopilot <target> --max-iterations N --max-minutes M
|
||||
/autopilot <target> --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.
|
||||
- `^\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.
|
||||
## Pre-flight: DoD readiness check (OBLIGATORIO)
|
||||
|
||||
Sin DoD claro, autopilot no arranca. La verificacion es STOP-gate, no se rellena por inferencia.
|
||||
|
||||
### Issue (`dev/issues/<NNNN>-*.md`)
|
||||
|
||||
Lee el .md. Debe cumplir **todos** estos:
|
||||
|
||||
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.
|
||||
2. Frontmatter valido (`status`, `priority`).
|
||||
3. **Al menos UNA** de:
|
||||
- Seccion `## DoD` o `## Definition of Done` con >=1 bullet/checkbox concreto.
|
||||
- Seccion `## Acceptance` con checkboxes `[ ]`.
|
||||
- Seccion `## Tests` + `## Tareas` ambas no vacias.
|
||||
4. Tipo soportado por `/autonomous-task` si va a delegar a orquestador (`feature_app_simple`, `bugfix_with_repro`, `refactor_safe`, `add_e2e_check`). Si no entra en estos, autopilot intenta ruta `/fix-issue` simplificada (registry-only changes sin rama).
|
||||
5. **NO** contiene criterios no-verificables: "queda bonito", "es intuitivo", "UX mejor". Heuristica grep simple — si match -> warning + ABORT.
|
||||
|
||||
Si falla -> ABORT con tabla:
|
||||
|
||||
```
|
||||
=== autopilot check 0107c ===
|
||||
status: NOT READY
|
||||
gaps:
|
||||
- Sin seccion DoD/Acceptance/Tests
|
||||
- Frontmatter sin priority
|
||||
fix:
|
||||
- Anadir `## DoD` con 3-5 bullets verificables programaticamente
|
||||
- Anadir `priority: medium` al frontmatter
|
||||
```
|
||||
|
||||
### Flow (`dev/flows/<NNNN>-*.md`)
|
||||
|
||||
1. Archivo existe en `dev/flows/`.
|
||||
1. Archivo existe en `dev/flows/` (no en `completed/`).
|
||||
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`).
|
||||
3. Seccion `## Acceptance` con >=1 checkbox `[ ]` (o ya `[x]` — significa parcialmente progresado).
|
||||
4. Seccion `## Flow` no vacia.
|
||||
5. Pre-requisitos declarados (incluso si vacio explicito).
|
||||
6. Tabla de funciones recomendadas presente — sin `FALTA: crear <id>` no resuelto (si hay `FALTA`, ABORT con lista de funciones a crear primero).
|
||||
|
||||
Si falla:
|
||||
Si `FALTA` esta presente: opcionalmente autopilot ofrece spawnear `fn-constructor` para cada FALTA — pero ESO solo si `--allow-construct-missing`. Por defecto ABORT y reportar.
|
||||
|
||||
---
|
||||
|
||||
## Modo autonomo (reglas duras de comportamiento)
|
||||
|
||||
Durante toda la ejecucion de `/autopilot`:
|
||||
|
||||
1. **NO invocar `AskUserQuestion` al humano**. En su lugar, **self-Q&A loop**: cuando surja una decision, Claude la formaliza como `Question -> Options -> Reasoning -> Choice` y persiste el bloque en `task_runs.events_json[]`. Ver seccion "Self-Q&A loop" mas abajo. Solo ABORTA con `status=needs_human` si la decision toca: (a) destructivo sin rollback (`--force`, `git reset --hard`, `DROP TABLE`), (b) credenciales/secrets, (c) paths protegidos, (d) contradice DoD explicito del issue. En esos casos, NUNCA self-answer — escala al humano.
|
||||
2. **Auto-pick "Recommended"** en cualquier flag con opciones (mocks vs prod, default branch, etc.) — usar primer item etiquetado como recomendado en el archivo / convencion del proyecto. Si no hay marcado, self-Q&A con justificacion.
|
||||
3. **Acciones destructivas prohibidas sin flag explicito**: `git reset --hard`, `git push --force`, `rm -rf` fuera de `/tmp/`, `DROP TABLE`, `--no-verify`, `--force`. Si una accion las requiere -> ABORT.
|
||||
4. **Hooks NO se saltan**. Si pre-commit falla, fix raiz; si excede scope, ABORT (NO `--no-verify`).
|
||||
5. **Paths protegidos** de `dev/autonomous_protected_paths.json` se respetan exactamente.
|
||||
6. **Rama dedicada + worktree aislado SIEMPRE — sin excepciones**. Autopilot opera en `worktrees/auto-<NNNN>-<slug>/` (rama `auto/<NNNN>-<slug>`) para NO bloquear el working tree principal del humano. Aplica a issues, flows, registry-only y apps. Master NUNCA recibe commits directos en modo autopilot. Pre-flight obligatorio desde el working tree principal:
|
||||
```bash
|
||||
git fetch origin master
|
||||
git -C <main_repo> rev-parse --is-clean # tolerante: solo confirmar master rebased
|
||||
WT=worktrees/auto-<NNNN>-<slug>
|
||||
git worktree add -b auto/<NNNN>-<slug> "$WT" master
|
||||
cd "$WT" # todo el trabajo posterior aqui
|
||||
```
|
||||
Path del worktree:
|
||||
- **Dentro del repo**: `worktrees/auto-<NNNN>-<slug>/` (gitignored). Permite que herramientas con `FN_REGISTRY_ROOT` apunten al worktree y no a master.
|
||||
- Alternativa `/tmp/fn_autopilot_<NNNN>_<ts>/` si la app necesita aislamiento total del filesystem del repo (raro).
|
||||
Reanudacion idempotente: si el worktree ya existe -> `cd` a el + `git rebase master`. Si la rama `auto/<NNNN>-<slug>` existe pero el worktree no -> `git worktree add "$WT" auto/<NNNN>-<slug>`. Conflicto en rebase -> ABORT.
|
||||
Cierre exitoso: merge `--no-ff` a master desde el repo principal (`git -C <main> merge --no-ff auto/<NNNN>-<slug>`) solo tras tests verde + DoD 100%. Luego `git worktree remove <WT>` + `git branch -d auto/<NNNN>-<slug>`.
|
||||
Cierre fallido: worktree y rama quedan vivos para inspeccion humana. Master intacto.
|
||||
**Garantia**: el humano puede seguir editando en el repo principal mientras autopilot avanza — sin colisiones de checkout, sin index lockings, sin "uncommitted changes blocks branch switch".
|
||||
7. **Watchdog**: si la metrica de progreso (`acceptance_done / acceptance_total` para flows; `tests_pass / tests_total` o `checks_pass / checks_total` para issues) NO sube en 3 iteraciones consecutivas -> ABORT con `status=stalled`.
|
||||
8. **Timeout** default 60 min. Override con `--max-minutes`.
|
||||
9. **Idempotencia**: re-lanzar `/autopilot <target>` sobre el mismo target reanuda desde el ultimo `task_run` exitoso (lookup por `issue_id` o `flow_id` en `task_runs`).
|
||||
10. **No self-modification**: NUNCA tocar `.claude/agents/`, `.claude/commands/`, `.claude/rules/`, `.claude/scripts/`, `.claude/CLAUDE.md`.
|
||||
11. **Trazabilidad**: cada decision se persiste en `task_runs.events_json[]` con `{ts, agent, action, evidence, diff_summary, auto_choice, self_qa?}`.
|
||||
|
||||
---
|
||||
|
||||
## Self-Q&A loop (corazon del modo)
|
||||
|
||||
Cuando aparece una decision sin Recomendado explicito, **NO abortar y NO preguntar al humano**. Claude:
|
||||
|
||||
1. **Formula la pregunta** en una frase. Una sola pregunta por bloque, especifica, contestable.
|
||||
2. **Lista opciones** (2-4). Misma forma que `AskUserQuestion` interno: `label + description`. Si solo hay una opcion viable, indicalo (`Options: [A] only viable`).
|
||||
3. **Razona en 1-3 lineas** apoyandote en: registry (`mcp__registry__fn_search`), reglas (`.claude/rules/`), tests previos, archivos del repo, convenciones del proyecto.
|
||||
4. **Elige** y marca `confidence: high|med|low`. Si `low` y la accion no es trivialmente reversible -> ABORT con `status=needs_human` y adjunta el bloque Q&A.
|
||||
5. **Persiste** en `events_json[]` con shape:
|
||||
```json
|
||||
{
|
||||
"ts": "...",
|
||||
"agent": "autopilot",
|
||||
"action": "self_qa",
|
||||
"self_qa": {
|
||||
"question": "Crear el flag enabled=false o ya enabled=true?",
|
||||
"options": [
|
||||
{"label": "enabled=false", "rationale": "TBD doctrina (feature_flags.md): merge codigo terminado pero NO expuesto"},
|
||||
{"label": "enabled=true", "rationale": "feature ya tiene tests verde y DoD 100%"}
|
||||
],
|
||||
"choice": "enabled=false",
|
||||
"confidence": "high",
|
||||
"reasoning": "feature_flags.md regla: 'cuando se activa: cambiar enabled:true y rellenar enabled_at'. Activar va en commit posterior."
|
||||
}
|
||||
}
|
||||
```
|
||||
6. **Avanza** sin esperar.
|
||||
|
||||
**Tope de self-Q&A**: `--max-self-answers` (default 20). Si se excede -> ABORT `status=overdeliberating` con dump de todas las Q&A. Una iteracion del bucle que necesita >5 Q&A es señal de DoD vago — abortar.
|
||||
|
||||
**Cuando NO usar self-Q&A (ABORT en vez de auto-responder)**:
|
||||
|
||||
| Caso | Razon |
|
||||
|---|---|
|
||||
| Destructivo sin rollback (`git reset --hard`, `rm -rf` fuera `/tmp/`, `DROP TABLE`, `--no-verify`, `--force`) | Coste de error infinito |
|
||||
| Credenciales/tokens/secrets | Riesgo de exfiltracion |
|
||||
| Paths protegidos (`dev/autonomous_protected_paths.json`) | Regla dura del orquestador |
|
||||
| Contradiccion explicita con DoD del issue | DoD es contrato |
|
||||
| Decision arquitectonica multi-app (renombrar tabla compartida, romper API publica) | Blast radius > 1 artefacto |
|
||||
| `confidence: low` + accion no reversible | Self-Q&A no garantiza acierto sin oraculo |
|
||||
|
||||
---
|
||||
|
||||
## Dispatch logic
|
||||
|
||||
### Path A: issue compatible con `fn-orquestador`
|
||||
|
||||
Si el issue declara o se infiere tipo en `(feature_app_simple, bugfix_with_repro, refactor_safe, add_e2e_check)` Y toca apps/modules/framework:
|
||||
|
||||
- Delega a `fn-orquestador` via `Agent(subagent_type="fn-orquestador", ...)` pasando:
|
||||
- `issue_id`, `--auto-apply-proposals safe`, `--max-iterations`, `--max-minutes`, paths protegidos.
|
||||
- Espera resultado, reenvia `task_run_id` + PR draft URL al humano.
|
||||
|
||||
### Path B: issue registry-only (functions/types/docs/rules)
|
||||
|
||||
- **Rama + worktree `worktrees/auto-<NNNN>-<slug>/`** desde master actualizado (regla dura 6). Humano sigue trabajando en el repo principal en paralelo.
|
||||
- Politica `apps_tbd.md` permite push directo a master para registry-only en modo humano, pero `/autopilot` NO lo usa: el aislamiento por rama+worktree es la garantia de rollback + paralelismo en modo autonomo.
|
||||
- Plan inline con TaskCreate:
|
||||
1. Pre-flight worktree (`git fetch` + `git worktree add -b auto/<NNNN>-<slug> worktrees/auto-<NNNN>-<slug> master` + `cd` a el).
|
||||
2. Leer issue + extraer DoD.
|
||||
3. Search registry para piezas existentes (registry-first).
|
||||
4. Si falta funcion -> spawn `fn-constructor` paralelo.
|
||||
5. `fn index`.
|
||||
6. Tests (`go test`, `pytest`, `bash -n`, segun stack).
|
||||
7. Si toco modulos/framework -> `/version` correspondiente.
|
||||
8. Mover `dev/issues/<NNNN>-*.md` a `dev/issues/completed/`.
|
||||
9. Actualizar `dev/issues/README.md` (si existe).
|
||||
10. Commit atomico por bloque logico en la rama.
|
||||
11. Solo si TODOS los tests pasan + DoD 100%: merge `--no-ff` a master + push + delete rama. Si algo falla -> rama queda viva, master intacto.
|
||||
- Verificacion final: DoD checkboxes -> todos marcados.
|
||||
|
||||
### Path C: flow
|
||||
|
||||
Runner inline (fase 2 manual, mientras `/flow run` no exista):
|
||||
|
||||
1. **Rama + worktree `worktrees/auto-flow-<NNNN>-<slug>/`** desde master (regla dura 6) — incluso para flows que solo ejecutan funciones sin escribir codigo, asi los side-effects en `dev/flows/<NNNN>-*.md` (checkboxes) y `dev/flows/runs/*.jsonl` se commitean en rama, no en master. Humano puede seguir editando el repo principal en paralelo.
|
||||
2. Parsea `## Flow` del .md.
|
||||
3. Cada paso tipo `function: <id>` -> `./fn run <id> [args]`.
|
||||
4. Cada paso tipo `cmd: <bash>` -> Bash tool (con guardas destructivas).
|
||||
5. Paso "MANUAL: ..." -> si tiene equivalente automatico (ej. "abrir Chrome y loguearse" vs `cdp_extract_recipe`) usa el automatico; si no tiene equivalente -> ABORT con `status=needs_human` y razon.
|
||||
6. Tras cada paso, evalua `## Acceptance` checkboxes via heuristicas:
|
||||
- "X runs en data_factory" -> `sqlite3` count.
|
||||
- "DAG corre 2 veces consecutivas" -> consultar `dag_engine` logs.
|
||||
- Otros -> dejar como `[ ]` y reportar.
|
||||
7. Persiste run en `dev/flows/runs/<NNNN>-<ts>.jsonl`.
|
||||
8. Si todos `[ ]` -> `[x]` -> commit en rama + merge `--no-ff` a master + `/flow done <NNNN>` + delete rama.
|
||||
|
||||
---
|
||||
|
||||
## Output canonico
|
||||
|
||||
```
|
||||
=== /autopilot 0107c ===
|
||||
target: issue 0107c (refactor data_table)
|
||||
path: B (registry-only)
|
||||
status: done
|
||||
iterations: 3 / 10
|
||||
duration: 18 min / 60
|
||||
dod_checks: 5/5 pass
|
||||
proposals: 2 creadas, 1 auto-aplicada
|
||||
self_qa: 7 (6 high / 1 med / 0 low)
|
||||
agents_spawned: fn-constructor x2, fn-recopilador x1
|
||||
commits: 4 (3 feat + 1 refactor)
|
||||
branch: master (registry-only, push directo)
|
||||
|
||||
Trace:
|
||||
1. construir → ok (2 funciones nuevas, 1 split)
|
||||
2. tests → ok (43/43)
|
||||
3. version → /version modules/data_table major "..."
|
||||
4. close → mv to completed/, push
|
||||
|
||||
Siguiente: ningun paso humano requerido. Verificar con: fn doctor modules
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sub-comando: `/autopilot check <target>`
|
||||
|
||||
Solo audita readiness — **no** ejecuta nada.
|
||||
|
||||
```
|
||||
=== /autopilot check 0125 ===
|
||||
@@ -70,10 +240,12 @@ status: NOT READY
|
||||
target: issue 0125 (skill-tree-dashboard-panel)
|
||||
gaps:
|
||||
- Sin seccion DoD/Acceptance
|
||||
- "UX intuitiva" linea 47 — no verificable
|
||||
- Frontmatter sin priority
|
||||
non_verifiable_criteria:
|
||||
- "UX intuitiva" (linea 47)
|
||||
fix:
|
||||
- Anadir ## DoD con 3-5 bullets programaticamente verificables
|
||||
- Reemplazar criterios subjetivos por mediciones concretas
|
||||
- Reemplazar "UX intuitiva" por criterio medible
|
||||
```
|
||||
|
||||
Si OK:
|
||||
@@ -83,130 +255,86 @@ Si OK:
|
||||
status: READY
|
||||
target: issue 0107c (refactor data_table)
|
||||
dod_items: 5 checkboxes
|
||||
task_type: refactor_safe
|
||||
path_inferred: B (registry-only — modules/)
|
||||
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 |
|
||||
| `--max-iterations N` | 10 | Tope de iteraciones del bucle |
|
||||
| `--max-minutes M` | 60 | Timeout total |
|
||||
| `--dry-run` | off | Plan + dispatch simulado, no aplica cambios |
|
||||
| `--allow-construct-missing` | off | Si flow tiene `FALTA: crear <id>`, spawn fn-constructor antes |
|
||||
| `--auto-apply-proposals` | `safe` | Pasado a fn-orquestador en Path A |
|
||||
| `--max-self-answers N` | 20 | Tope de bloques Self-Q&A por run. Excedido -> ABORT `overdeliberating` |
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
| `needs_human` | Decision sin Recomendado | Humano resuelve y relanza |
|
||||
| `stalled` | 3 iteraciones sin progreso | Humano revisa `events_json` |
|
||||
| `timeout` | Excedido `--max-minutes` | Aumentar timeout o partir issue |
|
||||
| `aborted_protected_path` | Cambio en path protegido | Humano revisa intent |
|
||||
| `iterations_exhausted` | Excedido `--max-iterations` | Humano evalua si vale subir tope |
|
||||
| `sandbox_breach` | Diff fuera del worktree | ABORT critico, audit |
|
||||
| `overdeliberating` | Excedido `--max-self-answers` | DoD probablemente vago — humano refina criterios |
|
||||
| `low_confidence_abort` | Self-Q&A devolvio `confidence: low` en accion no reversible | Humano valida la decision concreta |
|
||||
|
||||
---
|
||||
|
||||
## 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) |
|
||||
| `/autopilot` sin pre-check DoD | Trabajar sin criterio de exito = bucle infinito |
|
||||
| Auto-relleno de DoD inventada | Criterios falsos -> falso "done" |
|
||||
| Merge a master sin tests verde | Master no deployable |
|
||||
| `AskUserQuestion` al humano | Rompe el contrato autonomo — usa self-Q&A loop |
|
||||
| Self-Q&A sin razonamiento explicito | Decision opaca, no auditable |
|
||||
| Self-Q&A con `confidence: high` en accion destructiva sin oraculo | Confianza injustificada — escalar |
|
||||
| Salto de hooks (`--no-verify`) | Encubre bugs reales |
|
||||
| Tocar mas issues que el target | Scope creep silencioso |
|
||||
| Borrar archivos sin backup en events_json | Pierde auditoria |
|
||||
|
||||
---
|
||||
|
||||
## Relacion con otras reglas
|
||||
|
||||
- [[autonomous_loop]] — politica del bucle (sandbox, paths protegidos, watchdog).
|
||||
- [[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 (MCP / fn run / heredoc).
|
||||
- [[e2e_validation]] — `e2e_checks` consumidos por fn-analizador como gate de Path A.
|
||||
- [[delegation]] — spawn fn-constructor antes que escribir inline.
|
||||
|
||||
---
|
||||
|
||||
## Ejemplos
|
||||
|
||||
```bash
|
||||
# Issue con DoD claro
|
||||
# Issue registry-only con DoD claro
|
||||
/autopilot 0107c
|
||||
/autopilot i:0107c # equivalente con prefijo explicito
|
||||
|
||||
# Issue app que requiere orquestador
|
||||
/autopilot issue:0070 --max-iterations 15 --max-minutes 90
|
||||
|
||||
# Flow con piezas faltantes — autoriza creacion antes
|
||||
/autopilot flow:0008 --allow-construct-missing
|
||||
|
||||
# Solo audit
|
||||
# Solo audit, no ejecutar
|
||||
/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.
|
||||
|
||||
@@ -23,8 +23,8 @@ Si `$ARGUMENTS` no empieza por `modify`, es create. Si trae `<name>`, lo usas co
|
||||
### Paso 0 — verificar que no existe
|
||||
|
||||
```bash
|
||||
test -d "$HOME/fn_registry/apps/<name>" \
|
||||
|| ls $HOME/fn_registry/projects/*/apps/<name> 2>/dev/null
|
||||
test -d "/home/lucas/fn_registry/apps/<name>" \
|
||||
|| ls /home/lucas/fn_registry/projects/*/apps/<name> 2>/dev/null
|
||||
```
|
||||
|
||||
Si existe en cualquier ubicacion: **abortar** y sugerir `/cpp-app modify <name>`. NO sobreescribir.
|
||||
@@ -42,7 +42,7 @@ Regla dura `cpp_apps.md`: description + icon.phosphor + icon.accent SIEMPRE junt
|
||||
|
||||
5. **icon.phosphor** glyph name. Antes de preguntar, ofrece busqueda:
|
||||
```bash
|
||||
ls $HOME/fn_registry/sources/phosphor-core/assets/fill/ | grep -i "<keyword>"
|
||||
ls /home/lucas/fn_registry/sources/phosphor-core/assets/fill/ | grep -i "<keyword>"
|
||||
```
|
||||
Sugiere 3-5 candidatos basados en `description`. Default segun domain: `gfx`->`palette`, `tui`->`terminal`, `tools`->`wrench`, `infra`->`gear`, `finance`->`chart-line-up`, `datascience`->`graph`, `cybersecurity`->`shield`.
|
||||
6. **icon.accent** hex `#rrggbb` (palette select):
|
||||
@@ -122,7 +122,7 @@ Mostrar bloque YAML completo del `app.md` que se va a generar + flags del scaffo
|
||||
Una vez confirmado:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
cd /home/lucas/fn_registry
|
||||
|
||||
# 1. Scaffolder
|
||||
./fn run init_cpp_app <name> \
|
||||
@@ -178,7 +178,7 @@ cd $HOME/fn_registry
|
||||
|
||||
```bash
|
||||
# Buscar apps/<name>/ o projects/*/apps/<name>/
|
||||
sqlite3 $HOME/fn_registry/registry.db \
|
||||
sqlite3 /home/lucas/fn_registry/registry.db \
|
||||
"SELECT id, dir_path FROM apps WHERE name='<name>' AND lang='cpp';"
|
||||
```
|
||||
|
||||
@@ -211,7 +211,7 @@ Para cada cambio: usa `Edit` sobre los archivos correspondientes. NUNCA `Write`
|
||||
|
||||
```bash
|
||||
# Siempre
|
||||
cd $HOME/fn_registry && ./fn index
|
||||
cd /home/lucas/fn_registry && ./fn index
|
||||
|
||||
# Si toco icon.* -> regenerar appicon
|
||||
./fn run generate_app_icon "<phosphor>" "<accent>" "<dir>/appicon.ico"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -21,7 +21,7 @@ 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 |
|
||||
| 17b | [apps_subrepo.md](apps_subrepo.md) | Apps son sub-repos Gitea (apps/* gitignored). `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) |
|
||||
@@ -37,6 +37,3 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
||||
| 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. |
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
```bash
|
||||
# 1. Agente trabaja en worktree del repo padre
|
||||
cd $HOME/fn_registry/worktrees/<slug>
|
||||
cd /home/lucas/fn_registry/worktrees/<slug>
|
||||
|
||||
# 2. Scaffold la app via pipeline canonico
|
||||
./fn run init_cpp_app <name> # apps C++
|
||||
@@ -45,36 +45,6 @@ Cuando el humano corre `/full-git-push` despues del merge, el script `ensure_rep
|
||||
|
||||
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.
|
||||
|
||||
@@ -131,7 +131,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`.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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,6 +80,7 @@ Thumbs.db
|
||||
broken_paths.txt
|
||||
imgui.ini
|
||||
prompts/
|
||||
kotlin/functions/ui/
|
||||
|
||||
# Module versioning auto-generated headers (written by `fn index`, issue 0097)
|
||||
**/version_generated.h
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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,360 @@
|
||||
# dag_engine — Guia de uso
|
||||
|
||||
Motor de DAGs propio del fn_registry. **Scheduler oficial** del ecosistema (issue 0007a-e + flow 0001). Backend Go + frontend web (Vite/React) + frontend C++ ImGui (`cpp/apps/dag_engine_ui`).
|
||||
|
||||
Doc canonica para **anadir DAGs**, **formato YAML**, **comandos CLI**, y **diagnostico de fallos**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Donde viven los DAGs
|
||||
|
||||
| Path | Que |
|
||||
|---|---|
|
||||
| `apps/dag_engine/dags_migrated/` | DAGs activos servidos por `dag_engine.service` (systemd user unit). |
|
||||
| `apps/dag_engine/dags_migrated/archive/` | DAGs deshabilitados (no se cargan por el scheduler). |
|
||||
|
||||
Por defecto el systemd unit apunta a `apps/dag_engine/dags_migrated/`. Para usar otro dir, edita `~/.config/systemd/user/dag_engine.service`:
|
||||
|
||||
```ini
|
||||
ExecStart=/home/lucas/fn_registry/apps/dag_engine/dag_engine server \
|
||||
--port 8090 \
|
||||
--dags-dir /home/lucas/fn_registry/apps/dag_engine/dags_migrated \
|
||||
--db /home/lucas/fn_registry/apps/dag_engine/dag_engine.db \
|
||||
--scheduler
|
||||
```
|
||||
|
||||
Y reload + restart:
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user restart dag_engine.service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Anadir un DAG nuevo (workflow)
|
||||
|
||||
### Paso a paso
|
||||
|
||||
1. **Crear YAML** en `apps/dag_engine/dags_migrated/<nombre>.yaml` (ver formato en seccion 3).
|
||||
2. **Validar** sin ejecutar:
|
||||
```bash
|
||||
./apps/dag_engine/dag_engine validate apps/dag_engine/dags_migrated/<nombre>.yaml
|
||||
```
|
||||
Salida esperada: `Validation: PASS`. Si falla, ver seccion 5 (diagnostico).
|
||||
3. **Probar ejecucion manual** una vez:
|
||||
```bash
|
||||
./apps/dag_engine/dag_engine run apps/dag_engine/dags_migrated/<nombre>.yaml
|
||||
```
|
||||
4. **Recargar scheduler** (toma el YAML automaticamente al iterar el dir):
|
||||
```bash
|
||||
systemctl --user restart dag_engine.service
|
||||
journalctl --user-unit dag_engine.service -n 30 --no-pager
|
||||
```
|
||||
Busca la linea `[scheduler] ticker started for <nombre> (<cron>)` en los logs.
|
||||
5. **Verificar en frontend**:
|
||||
- C++ ImGui: panel `DAGs` muestra el nuevo DAG. Pulsa `Refresh` si no aparece.
|
||||
- Web: `http://localhost:8090`.
|
||||
|
||||
### Disparo manual desde curl o frontend
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8090/api/dags/<nombre>/run
|
||||
```
|
||||
|
||||
Devuelve `{"dag":"<nombre>","run_id":"...","status":"accepted"}` y dispara el WS broadcast — los frontends ven la run en `<1s`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Formato YAML
|
||||
|
||||
Formato YAML propio de dag_engine. Schema: `name`, `description`, `schedule`, `env`, `tags`, `working_dir`, `steps[]`, `handlers` (alias `handler_on`).
|
||||
|
||||
### Ejemplo completo
|
||||
|
||||
```yaml
|
||||
name: my_pipeline
|
||||
description: "Pipeline diario que importa CSV y actualiza Metabase."
|
||||
group: finanzas # opcional, agrupa DAGs en listados
|
||||
type: graph # opcional: graph (default) | chain
|
||||
tags: [daily, csv, metabase] # opcional, filtros en la UI
|
||||
|
||||
# Variables de entorno (heredadas por todos los steps).
|
||||
env:
|
||||
- DATA_DIR: /home/lucas/data
|
||||
- SLACK_HOOK: ${SLACK_HOOK_PROD} # interpolacion de ENV del host
|
||||
|
||||
# Cron schedule. Puede ser string o lista.
|
||||
schedule:
|
||||
- "0 9 * * *" # 09:00 todos los dias
|
||||
- "0 21 * * 5" # 21:00 viernes (segundo trigger)
|
||||
|
||||
# Working dir + shell por defecto para todos los steps.
|
||||
working_dir: /home/lucas/fn_registry
|
||||
shell: /bin/bash
|
||||
timeout_sec: 1800 # 30 min para todo el DAG
|
||||
|
||||
steps:
|
||||
- name: ingest
|
||||
description: "Descarga CSV."
|
||||
command: ./bash/functions/pipelines/ingest_csv.sh
|
||||
timeout_sec: 300 # 5 min para este step
|
||||
env:
|
||||
- SOURCE_URL: https://example.com/data.csv
|
||||
|
||||
- name: transform
|
||||
description: "Limpieza y agregacion."
|
||||
script: |
|
||||
#!/usr/bin/env python3
|
||||
import pandas as pd
|
||||
df = pd.read_csv("$DATA_DIR/raw.csv")
|
||||
df.to_parquet("$DATA_DIR/clean.parquet")
|
||||
depends: [ingest] # debe terminar OK antes
|
||||
retry_policy:
|
||||
limit: 2 # reintentos en caso de fallo
|
||||
interval_sec: 60
|
||||
|
||||
- name: load_metabase
|
||||
command: ./bash/functions/metabase/refresh_dashboard.sh
|
||||
depends: [transform]
|
||||
continue_on:
|
||||
failure: true # no aborta el DAG aunque falle
|
||||
|
||||
- name: notify
|
||||
command: ./bash/functions/io/slack_send.sh "pipeline OK"
|
||||
depends: [load_metabase]
|
||||
|
||||
# Hooks de ciclo de vida.
|
||||
handler_on:
|
||||
success: ./bash/functions/io/notify_success.sh
|
||||
failure: ./bash/functions/io/notify_failure.sh
|
||||
exit: ./bash/functions/io/cleanup.sh
|
||||
```
|
||||
|
||||
### Campos del DAG (top-level)
|
||||
|
||||
| Campo | Tipo | Default | Que |
|
||||
|---|---|---|---|
|
||||
| `name` | string | (obligatorio) | Identificador unico. Debe matchear el filename sin extension. |
|
||||
| `description` | string | "" | Texto libre, aparece en la UI. |
|
||||
| `group` | string | "" | Agrupa DAGs en listados. |
|
||||
| `type` | string | `""` (graph) | `graph` o `chain`. graph = grafo dirigido por `depends`. chain = ejecucion secuencial implicita. |
|
||||
| `working_dir` | string | cwd del server | Path absoluto desde donde lanzar los steps. |
|
||||
| `shell` | string | `/bin/sh` | Shell para `command:`. |
|
||||
| `env` | list/map | [] | Variables de entorno DAG-wide. |
|
||||
| `schedule` | string/list | "" | Cron expressions (5 campos: min hour dom mon dow). Vacio = solo manual. |
|
||||
| `steps` | list | (obligatorio) | Pasos del DAG (>=1). |
|
||||
| `handler_on` | map | null | Hooks `init/success/failure/exit`. Alias: `handlers`. |
|
||||
| `tags` | list[string] | [] | Filtros en la UI. |
|
||||
| `timeout_sec` | int | 0 (sin timeout) | Timeout global del DAG en segundos. |
|
||||
|
||||
### Campos de cada step
|
||||
|
||||
| Campo | Tipo | Default | Que |
|
||||
|---|---|---|---|
|
||||
| `name` | string | (obligatorio) | Identificador del step dentro del DAG. |
|
||||
| `id` | string | "" | Override del id auto-generado. |
|
||||
| `description` | string | "" | Texto libre. |
|
||||
| `command` | string | "" | Comando shell (mutuamente excluyente con `script`/`function`). |
|
||||
| `script` | string | "" | Bloque heredoc. Util para Python/Lua inline. |
|
||||
| `function` | string | "" | ID de funcion del registry (ej `audit_capability_groups_go_infra`). Si set, executor invoca `${FN_REGISTRY_ROOT}/fn run <id> <args...>` y captura `function_id` en `dag_step_results`. Mutuamente exclusivo con `command`/`script`; si convive, gana `function`. |
|
||||
| `args` | list[string] | [] | Args extra para `command` o para la `function`. |
|
||||
| `shell` | string | hereda | Override del shell. |
|
||||
| `dir` / `working_dir` | string | hereda | Working dir para este step. |
|
||||
| `depends` | list[string] | [] | Steps que deben terminar OK antes. Si vacio + `type:graph`, arranca en paralelo. |
|
||||
| `env` | list/map | hereda | Env del step (sobrescribe el del DAG). |
|
||||
| `continue_on.failure` | bool | false | Si true, el DAG sigue aunque este step falle. |
|
||||
| `continue_on.skipped` | bool | false | Si true, dependientes corren aunque este step quede skipped. |
|
||||
| `retry_policy.limit` | int | 0 | Reintentos. |
|
||||
| `retry_policy.interval_sec` | int | 0 | Segundos entre reintentos. |
|
||||
| `timeout_sec` | int | 0 (sin timeout) | Timeout del step. |
|
||||
| `output` | string | "" | Nombre de variable donde guardar stdout (consumible por dependientes). |
|
||||
| `tags` | list[string] | [] | Tags por step (UI). |
|
||||
|
||||
### Function steps (coherencia con el registry)
|
||||
|
||||
Un DAG idiomatico llama funciones del registry, no scripts ad-hoc. Cada step `function:` queda trazado en `call_monitor.calls` por el hook PostToolUse del agente y en `dag_step_results.function_id` del propio dag_engine — el bucle reactivo (issue 0085) tiene visibilidad end-to-end.
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- name: audit_capabilities
|
||||
function: audit_capability_groups_go_infra
|
||||
args: ["--json"]
|
||||
description: "Audita drift entre tags de capability group y paginas madre"
|
||||
```
|
||||
|
||||
Ventajas vs `command: ./fn run ...`:
|
||||
|
||||
- `function_id` se persiste como columna dedicada en `dag_step_results` (filtrable, agrupable).
|
||||
- El frontend `dag_engine_ui` muestra badge + panel lateral con `uses_functions` (subfunciones que el step va a usar transitivamente).
|
||||
- API: `GET /api/functions/{id}` devuelve `{id, name, description, signature, purity, domain, lang, uses_functions[], uses_types[]}` leyendo `registry.db` read-only. La UI consume este endpoint al expandir un step.
|
||||
- Validator regex en `dag_validate`: `^[a-z0-9_]+_[a-z]+_[a-z]+$`. ID invalido = error.
|
||||
- Variables de entorno: `FN_REGISTRY_ROOT` (default `/home/lucas/fn_registry`) localiza el binario `fn`. Override con `FN_BIN=/path/al/fn`.
|
||||
- **`FN_REGISTRY_ROOT` obligatorio cuando el servicio corre via systemd** con `WorkingDirectory` fuera del root del registry. El binario `fn` resuelve `registry.db` por (1) env var, (2) walk-up buscando `go.mod`, (3) exe dir. Si (1) no esta y (2) encuentra el `go.mod` del propio servicio (ej. `apps/dag_engine/go.mod`), devuelve un dir donde `registry.db` no existe o esta stale, fallando con `error: function "<id>" not found`. Bug historico: `apps/dag_engine/registry.db` stale (May 15) tumbo 3 noches `fn_backup` + `daily-registry-audit`. Defensa en profundidad: el executor exporta `FN_REGISTRY_ROOT` y hace `cd $FN_REGISTRY_ROOT` antes del spawn de steps `function:` (executor.go), pero el `Environment=FN_REGISTRY_ROOT=...` del systemd unit sigue siendo la fuente de verdad.
|
||||
- **`PATH` en el systemd unit**: si steps `function:` invocan funciones Go sin tests (`go vet`) o Python (`python3`), el `PATH` del entorno systemd debe incluir esos binarios — declarar `Environment=PATH=/usr/local/go/bin:/home/lucas/go/bin:/home/lucas/.local/bin:/usr/local/bin:/usr/bin:/bin`.
|
||||
|
||||
Ejemplo completo: `apps/dag_engine/dags_migrated/daily-registry-audit.yaml`.
|
||||
|
||||
### Cron schedule
|
||||
|
||||
5 campos clasicos: `min hour dom mon dow`. Ejemplos:
|
||||
|
||||
| Expresion | Significado |
|
||||
|---|---|
|
||||
| `0 9 * * *` | Todos los dias a las 09:00 |
|
||||
| `*/15 * * * *` | Cada 15 minutos |
|
||||
| `0 */6 * * *` | Cada 6 horas en punto |
|
||||
| `0 9 * * 1-5` | 09:00 lunes-viernes |
|
||||
| `0 21 * * 5` | 21:00 viernes |
|
||||
|
||||
Multiples cron en `schedule:` -> el DAG dispara por cada uno.
|
||||
|
||||
---
|
||||
|
||||
## 4. Comandos CLI
|
||||
|
||||
```bash
|
||||
./dag_engine run <path.yaml> # ejecuta un DAG ad-hoc
|
||||
./dag_engine list [dir] # lista DAGs con schedule + ultimo status
|
||||
./dag_engine status [dag_name] # historial de ejecuciones
|
||||
./dag_engine validate <path.yaml> # parse + validate (no ejecuta)
|
||||
./dag_engine server # arranca HTTP + WS hub + frontend embebido
|
||||
```
|
||||
|
||||
Flags del `server`:
|
||||
|
||||
| Flag | Default | Que |
|
||||
|---|---|---|
|
||||
| `--port` | 8090 | Puerto HTTP. |
|
||||
| `--dags-dir` | `apps/dag_engine/dags_migrated` (via systemd unit) | Dir scaneado para YAMLs. |
|
||||
| `--db` | `dag_engine.db` | SQLite con `dag_runs` + `dag_step_results`. |
|
||||
| `--scheduler` | false | Si presente, arranca cron tickers automaticamente. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Que hacer si algo falla
|
||||
|
||||
### 5.1. El DAG no aparece en la UI
|
||||
|
||||
**Sintoma:** anadiste un YAML pero `GET /api/dags` no lo lista.
|
||||
|
||||
| Causa | Diagnostico | Fix |
|
||||
|---|---|---|
|
||||
| YAML invalido | `./dag_engine validate <path>` muestra el error. | Corregir segun el mensaje (campo desconocido, indentacion, type wrong). |
|
||||
| Filename con extension fuera de `.yaml`/`.yml` | `ls apps/dag_engine/dags_migrated/` | Renombrar a `.yaml`. |
|
||||
| El servidor apunta a otro dir | `systemctl --user cat dag_engine.service` -> ver `--dags-dir`. | Ajustar unit y `daemon-reload + restart`. |
|
||||
| Cache UI antiguo | C++: pulsa `Refresh`. Web: `Ctrl+F5`. | — |
|
||||
|
||||
### 5.2. Validation: FAIL
|
||||
|
||||
`validate` muestra `parse error: ...` o `Validation: FAIL`. Causas tipicas:
|
||||
|
||||
| Mensaje | Causa | Fix |
|
||||
|---|---|---|
|
||||
| `yaml unmarshal: ...` | Sintaxis YAML rota (indentacion, tab vs espacios). | Usar 2 espacios consistentes. Validar online con `yamllint`. |
|
||||
| `dag_parse: step[N]: name is required` | Step sin `name:`. | Anadir `name:`. |
|
||||
| `dag_parse: step[N]: command or script required` | Step sin `command` ni `script`. | Anadir uno de los dos. |
|
||||
| `cycle detected: A -> B -> A` | `depends` forma ciclo. | Romper la dependencia o convertir uno de los nodos en step distinto. |
|
||||
| `unknown depends: <step>` | `depends:` referencia un step inexistente. | Comprobar nombres exactos (case-sensitive). |
|
||||
| `invalid cron: <expr>` | Cron mal formado (4 o 6 campos en vez de 5). | Verificar `0 9 * * *` (5 campos). |
|
||||
|
||||
### 5.3. El DAG corre pero un step falla
|
||||
|
||||
**Sintoma:** `status: failed` en la UI.
|
||||
|
||||
1. Abre `DAG Detail` y haz doble-click en el run rojo -> `Run Detail`.
|
||||
2. Expande el step que fallo (CollapsingHeader). Muestra `stdout` + `stderr`.
|
||||
3. Errores tipicos:
|
||||
|
||||
| stderr | Causa | Fix |
|
||||
|---|---|---|
|
||||
| `command not found` | `command:` apunta a un binario fuera de `PATH`. | Path absoluto o setear `env: [PATH: ...]`. |
|
||||
| `permission denied` | Script sin `chmod +x`. | `chmod +x <script>` (o usar `bash <script>`). |
|
||||
| `no such file or directory` | `working_dir:` mal o ruta relativa rota. | Path absoluto en `working_dir:`. |
|
||||
| Timeout | Step duro mas que `timeout_sec`. | Subir el limite o partir el step. |
|
||||
| Exit 137 / OOM kill | Out-of-memory. | Reducir batch o anadir swap. |
|
||||
|
||||
### 5.4. El scheduler no dispara
|
||||
|
||||
**Sintoma:** Hay `schedule:` valido pero el DAG no corre solo.
|
||||
|
||||
1. Verifica que el server arranco con `--scheduler`:
|
||||
```bash
|
||||
systemctl --user cat dag_engine.service | grep scheduler
|
||||
```
|
||||
2. Logs:
|
||||
```bash
|
||||
journalctl --user-unit dag_engine.service -n 50 --no-pager | grep -E "scheduler|ticker"
|
||||
```
|
||||
Debes ver `[scheduler] ticker started for <name> (<cron>), next: <ISO8601>`.
|
||||
3. Si `next:` es muy lejano (ej. en una semana) y necesitas probar -> dispara manual:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8090/api/dags/<name>/run
|
||||
```
|
||||
4. Hora del sistema descalibrada:
|
||||
```bash
|
||||
timedatectl status
|
||||
```
|
||||
Si difiere de la hora real, `sudo timedatectl set-ntp true`.
|
||||
|
||||
### 5.5. El frontend C++ no conecta WS
|
||||
|
||||
**Sintoma:** Panel `Live (WS)` muestra `disconnected`.
|
||||
|
||||
| Causa | Fix |
|
||||
|---|---|
|
||||
| Servidor caido | `systemctl --user status dag_engine.service`, `restart` si `inactive`. |
|
||||
| Puerto cambiado | El cliente apunta a `127.0.0.1:8090` por codigo (constante `g_ws_port`). Reedificar si cambiaste el puerto del server. |
|
||||
| Firewall Windows -> WSL | WSL2 expone `localhost`, normalmente OK. Si falla: `wsl --shutdown` y reabrir. |
|
||||
|
||||
### 5.6. Cleanup de runs viejos
|
||||
|
||||
`dag_runs` y `dag_step_results` crecen sin limite. Para limpiar:
|
||||
|
||||
```bash
|
||||
sqlite3 apps/dag_engine/dag_engine.db <<'SQL'
|
||||
DELETE FROM dag_step_results WHERE run_id IN (
|
||||
SELECT id FROM dag_runs WHERE started_at < datetime('now', '-30 days')
|
||||
);
|
||||
DELETE FROM dag_runs WHERE started_at < datetime('now', '-30 days');
|
||||
VACUUM;
|
||||
SQL
|
||||
```
|
||||
|
||||
### 5.7. Restaurar desde backup
|
||||
|
||||
Si rompes `dags_migrated/`, recupera desde el snapshot de `backup_all_bash_pipelines` (BACKUP_ROOT por defecto `~/backups/fn_registry`):
|
||||
|
||||
```bash
|
||||
cp ~/backups/fn_registry/registry/daily.0/dags_migrated/*.yaml \
|
||||
apps/dag_engine/dags_migrated/ 2>/dev/null || \
|
||||
git checkout HEAD -- apps/dag_engine/dags_migrated/
|
||||
systemctl --user restart dag_engine.service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Endpoints HTTP
|
||||
|
||||
| Metodo | Path | Que |
|
||||
|---|---|---|
|
||||
| GET | `/api/dags` | Lista DAGs + last_run + last_runs[5]. |
|
||||
| GET | `/api/dags/{name}` | Detalle + validation. |
|
||||
| POST | `/api/dags/{name}/run` | Dispara ejecucion (trigger=`api`). Devuelve `run_id`. |
|
||||
| GET | `/api/runs` | Historial. Query: `dag`, `limit`, `offset`. |
|
||||
| GET | `/api/runs/{id}` | Detalle de un run + sus step_results. |
|
||||
| GET | `/api/ws/dagruns` | WebSocket. Snapshot + deltas en vivo (issue 0095). |
|
||||
| GET | `/api/scheduler/status` | Tickers activos. |
|
||||
| POST | `/api/scheduler/start` | Arranca scheduler (si no estaba). |
|
||||
| POST | `/api/scheduler/stop` | Para scheduler. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Referencias
|
||||
|
||||
- Schema parser: `functions/core/dag_parse.go` (frontmatter en `dag_parse_go_core`).
|
||||
- Validator: `functions/core/dag_validate.go` (`dag_validate_go_core`).
|
||||
- Topo sort: `functions/core/dag_topo_sort.go` (`dag_topo_sort_go_core`).
|
||||
- Cron: `functions/core/parse_cron_expr.go` + `next_cron_time.go`.
|
||||
- Frontend C++: `cpp/apps/dag_engine_ui/` (issue 0095).
|
||||
- WS hub: `apps/dag_engine/events.go`.
|
||||
- dag_engine es el scheduler oficial del ecosistema. Single-binary Go + SQLite, sin dependencias externas.
|
||||
@@ -0,0 +1,55 @@
|
||||
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, hub *DagRunHub, 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))
|
||||
|
||||
// Function lookup proxy a registry.db (read-only).
|
||||
mux.HandleFunc("GET /api/functions/{id}", handleGetFunction())
|
||||
|
||||
mux.HandleFunc("POST /api/scheduler/start", handleSchedulerStart(scheduler))
|
||||
mux.HandleFunc("POST /api/scheduler/stop", handleSchedulerStop(scheduler))
|
||||
mux.HandleFunc("GET /api/scheduler/status", handleSchedulerStatus(scheduler))
|
||||
|
||||
// Live updates (WS hub).
|
||||
if hub != nil {
|
||||
mux.HandleFunc("GET /api/ws/dagruns", handleDagRunsWS(hub))
|
||||
}
|
||||
|
||||
// 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,143 @@
|
||||
---
|
||||
name: dag_engine
|
||||
lang: go
|
||||
domain: infra
|
||||
version: 0.1.0
|
||||
description: "Motor de ejecucion de DAGs del fn_registry: CLI + servidor HTTP + scheduler cron. Schema YAML propio con `function:` para invocar funciones del registry (`fn run <id>`) y `command:` para shell. Historial en SQLite. Scheduler oficial del ecosistema."
|
||||
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"
|
||||
service:
|
||||
port: 8090
|
||||
health_endpoint: /api/dags
|
||||
health_timeout_s: 3
|
||||
systemd_unit: dag_engine.service
|
||||
systemd_scope: user
|
||||
restart_policy: always
|
||||
runtime: systemd-user
|
||||
pc_targets:
|
||||
- aurgi-pc
|
||||
- home-wsl
|
||||
is_local_only: false
|
||||
---
|
||||
|
||||
## 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 apps/dag_engine/dags_migrated/fn_backup.yaml
|
||||
./dag-engine list apps/dag_engine/dags_migrated/
|
||||
|
||||
# Servidor web (production: gestionado por dag_engine.service systemd user unit)
|
||||
./dag-engine server --port 8090 --dags-dir apps/dag_engine/dags_migrated/ --scheduler
|
||||
# Browser: http://localhost:8090
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Schema YAML propio (ver `README.md` seccion 3 + ejemplos en `dags_migrated/`). Steps tipo `function:` invocan `fn run <id>` y propagan `function_id` a `dag_step_results` para el bucle reactivo. Puerto default 8090.
|
||||
|
||||
### 2026-05-16 — Fix function-not-found en steps `function:` + panel Logs en RunDetail `[done]`
|
||||
|
||||
Sintoma: `fn_backup` y `daily-registry-audit` fallaron 3 noches seguidas con `error: function "<id>" not found (tried as ID and name)` aunque las funciones existen en `registry.db` raiz.
|
||||
|
||||
Raiz: servicio systemd `dag_engine.service` tiene `WorkingDirectory=/home/lucas/fn_registry/apps/dag_engine`. Binario `fn` resuelve `registry.db` por (1) `FN_REGISTRY_ROOT`, (2) `root()` walk-up buscando `go.mod`, (3) exe dir (`cmd/fn/ops.go:1597-1628`). Sin `FN_REGISTRY_ROOT` seteado, (2) encuentra el `go.mod` de `apps/dag_engine/` y devuelve ese dir — donde habia una copia stale `apps/dag_engine/registry.db` (262 KB, May 15) sin las funciones recien creadas. Viola regla `.claude/rules/db_locations.md` (registry.db SOLO en raiz).
|
||||
|
||||
Fix:
|
||||
- Borrado `apps/dag_engine/registry.db` stale.
|
||||
- `~/.config/systemd/user/dag_engine.service`: anadido `Environment=FN_REGISTRY_ROOT=/home/lucas/fn_registry`, `FN_BIN=/home/lucas/fn_registry/fn`, `PATH=/usr/local/go/bin:/home/lucas/go/bin:...`, `HOME=/home/lucas`. Sin PATH el step `go vet` fallaba con `exec: "go": executable file not found in $PATH`.
|
||||
- `apps/dag_engine/executor.go`: para steps `function:` el spawn exporta `FN_REGISTRY_ROOT=<root>` en env y, si `step.dir`/`working_dir` vacios, fija `dir = fnRegistryRoot`. Belt-and-suspenders: aunque alguien lance el binario sin systemd, los `function:` steps usan el root canonico.
|
||||
|
||||
Verificacion: `POST /api/dags/daily-registry-audit/run` -> step `audit_capabilities` pasa (387 ms) en vez de fallar con not-found. Restantes failures (`audit_artefacts` exit 1, `fn_backup` exit 4 sin respetar `continue_on.exit_code`) son bugs reales independientes — fuera de scope.
|
||||
|
||||
### 2026-05-16 — Panel Logs en RunDetail (frontend) `[done]`
|
||||
|
||||
- `apps/dag_engine/frontend/src/pages/RunDetail.tsx`: nuevo `<Paper>` "Logs" al final con `<Code block>` scrollable (max-h 480) + `CopyButton` de Mantine (icono toggle copy/check teal).
|
||||
- Helper `buildLogText(run, steps)` compone texto plano: metadata del run (dag, path, status, trigger, started/finished ISO, duration ms, error) + por step (`[status] name exit=N Nms`, started, finished, error, stdout, stderr indentado 4 espacios).
|
||||
- Permite pegar log entero al LLM para debugging sin abrir N collapses del `StepTimeline`.
|
||||
- Build frontend pendiente: `pnpm build` rompe por errores preexistentes (`StepTimeline.tsx:49` usa API legacy `<Collapse in={opened}>`; `main.tsx:1` importa `@mantine/core/styles.css` sin tipos). Edit de RunDetail type-checkea limpio.
|
||||
|
||||
### 2026-05-16 — BBDDs canonicas (referencia rapida)
|
||||
|
||||
- `dag_engine.db`: `apps/dag_engine/dag_engine.db` (+ WAL sidecars). Migrations en `apps/dag_engine/store/migrations/` (`001_init.sql`, `002_step_function_id.sql`). Tablas `dag_runs`, `dag_step_results`.
|
||||
- NO debe coexistir copia de `registry.db` en este dir (viola `db_locations.md`). Si reaparece: borrarla.
|
||||
|
||||
## Lo siguiente que pega
|
||||
|
||||
- `audit_artefacts` falla con exit 1 en `daily-registry-audit` — investigar stderr real (probablemente artefacto huerfano o git drift). Step independiente, no bloquea el resto del DAG.
|
||||
- `fn_backup` step `run_backup_all` sale con exit 4 y el DAG no respeta `continue_on.exit_code: [4]`. Bug en executor: parsear `step.ContinueOn.ExitCode []int` y comparar con `result.ExitCode`. Hoy solo se mira `step.ContinueOn.Failure` (bool).
|
||||
- Frontend `pnpm build` roto por API drift de Mantine en `StepTimeline.tsx` (`<Collapse in={opened}>`) y CSS type import en `main.tsx`. Fix junto con un refresh general de tipos.
|
||||
|
||||
## Documentacion de usuario
|
||||
|
||||
Guia completa (formato YAML, anadir DAGs, troubleshooting, endpoints HTTP):
|
||||
**[apps/dag_engine/README.md](README.md)**.
|
||||
|
||||
|
||||
## Capability growth log
|
||||
|
||||
Una linea por bump SemVer. Bump-type segun `.claude/commands/version.md`:
|
||||
- `major`: breaking observable (CLI args, schema BBDD propia, formato wire).
|
||||
- `minor`: feature aditiva (nuevo panel, endpoint, opcion).
|
||||
- `patch`: bugfix sin cambio observable.
|
||||
|
||||
- v0.1.0 (2026-05-18) — baseline.
|
||||
@@ -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,26 @@
|
||||
name: fn_backup
|
||||
description: Backup diario de fn_registry (registry.db + operations.db + vaults) via funcion del registry
|
||||
|
||||
schedule:
|
||||
- "0 3 * * *"
|
||||
|
||||
tags: [backup, registry, daily]
|
||||
|
||||
env:
|
||||
- BACKUP_ROOT: /home/lucas/backups/fn_registry
|
||||
|
||||
steps:
|
||||
- name: ensure_dirs
|
||||
command: mkdir -p ${BACKUP_ROOT}
|
||||
|
||||
- name: run_backup_all
|
||||
description: "Snapshot atomico de registry.db + operations.db + vaults con retention 7/4/12"
|
||||
function: backup_all_bash_pipelines
|
||||
args: ["${BACKUP_ROOT}"]
|
||||
continue_on:
|
||||
exit_code: [4]
|
||||
depends: [ensure_dirs]
|
||||
|
||||
- name: report_status
|
||||
command: bash -c 'ls -lh ${BACKUP_ROOT}/registry/daily.0 ${BACKUP_ROOT}/operations/*/daily.0 2>/dev/null | tail -20'
|
||||
depends: [run_backup_all]
|
||||
@@ -0,0 +1,51 @@
|
||||
name: revision-viernes-finanzas
|
||||
description: Revisión semanal de finanzas personales - ingesta, informe y push a Gitea
|
||||
tags: [finanzas, semanal]
|
||||
type: graph
|
||||
|
||||
schedule: "0 9 * * 5"
|
||||
|
||||
env:
|
||||
- PROJECT_DIR: /home/lucas/analysis/finanzas_personales
|
||||
- PYTHON: /home/lucas/analysis/finanzas_personales/.venv/bin/python
|
||||
|
||||
handler_on:
|
||||
failure:
|
||||
command: echo "[$(date)] FALLÓ revision-viernes-finanzas" >> /home/lucas/dagu/logs/failures.log
|
||||
|
||||
steps:
|
||||
- id: ingest
|
||||
description: Procesar archivos nuevos del inbox (BBVA xlsx + Revolut csv)
|
||||
working_dir: ${PROJECT_DIR}
|
||||
command: ./bin/ingest -skip-notebooks
|
||||
continue_on:
|
||||
failure: true
|
||||
|
||||
- id: informe
|
||||
description: Generar informe semanal de cumplimiento del presupuesto
|
||||
command: ${PYTHON} /home/lucas/dagu/scripts/informe_finanzas.py
|
||||
depends: [ingest]
|
||||
|
||||
- id: git_push
|
||||
description: Commit y push del informe a Gitea
|
||||
working_dir: ${PROJECT_DIR}
|
||||
script: |
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
if git diff --quiet data/04_output/informe_semanal.md 2>/dev/null && \
|
||||
! git ls-files --others --exclude-standard | grep -q informe_semanal.md; then
|
||||
echo "Sin cambios en el informe, skip push"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git add data/04_output/informe_semanal.md
|
||||
git add data/03_processed/ 2>/dev/null || true
|
||||
|
||||
git commit -m "Informe semanal $(date +%Y-%m-%d)
|
||||
|
||||
Co-Authored-By: Dagu Automation <noreply@dagu.dev>"
|
||||
|
||||
git push origin master:main
|
||||
echo "Push completado"
|
||||
depends: [informe]
|
||||
@@ -0,0 +1,438 @@
|
||||
package main
|
||||
|
||||
// WebSocket hub para live updates de dag_runs + dag_step_results.
|
||||
// Patron: sqlite_api/events.go (CallMonitorHub) — issue 0095.
|
||||
//
|
||||
// Diseño:
|
||||
// - Hub global con N subscribers WS.
|
||||
// - Ticker arranca solo con >=1 subscriber. Cero overhead si nadie mira.
|
||||
// - Cada tick (500ms): query rowid>watermark + activos (status running/pending)
|
||||
// + recientes finished (ultimos 5s) -> broadcast upsert.
|
||||
// - Snapshot inicial: lista de DAGs + ultimos 50 runs + step_results.
|
||||
// - El cliente trata `runs` y `steps` como upserts por id.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
"nhooyr.io/websocket/wsjson"
|
||||
|
||||
"dag-engine/store"
|
||||
)
|
||||
|
||||
const (
|
||||
dagWSTickInterval = 500 * time.Millisecond
|
||||
dagWSTickIntervalIdle = 2 * time.Second
|
||||
dagWSIdleThreshold = 30 * time.Second
|
||||
dagWSSnapshotRuns = 50
|
||||
dagWSBroadcastTimeout = 2 * time.Second
|
||||
dagWSRecentFinishedS = 5
|
||||
)
|
||||
|
||||
type wsRun struct {
|
||||
ID string `json:"id"`
|
||||
DagName string `json:"dag_name"`
|
||||
DagPath string `json:"dag_path"`
|
||||
Status string `json:"status"`
|
||||
Trigger string `json:"trigger"`
|
||||
StartedAt string `json:"started_at"`
|
||||
FinishedAt string `json:"finished_at,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type wsStep struct {
|
||||
ID string `json:"id"`
|
||||
RunID string `json:"run_id"`
|
||||
StepName string `json:"step_name"`
|
||||
Status string `json:"status"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Stdout string `json:"stdout,omitempty"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
FinishedAt string `json:"finished_at,omitempty"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type wsWatermark struct {
|
||||
Runs int64 `json:"runs"`
|
||||
Steps int64 `json:"steps"`
|
||||
}
|
||||
|
||||
type wsDagMessage struct {
|
||||
Type string `json:"type"` // snapshot|delta|ping
|
||||
Watermark wsWatermark `json:"watermark"`
|
||||
Dags []DagInfo `json:"dags,omitempty"`
|
||||
Runs []wsRun `json:"runs,omitempty"`
|
||||
Steps []wsStep `json:"steps,omitempty"`
|
||||
ServerTime int64 `json:"server_time"`
|
||||
}
|
||||
|
||||
type wsDagClientCmd struct {
|
||||
Watermark wsWatermark `json:"watermark,omitempty"`
|
||||
}
|
||||
|
||||
type dagSubscriber struct {
|
||||
conn *websocket.Conn
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
out chan wsDagMessage
|
||||
watermark wsWatermark
|
||||
}
|
||||
|
||||
// DagRunHub broadcastea cambios de dag_runs + dag_step_results a clientes WS.
|
||||
type DagRunHub struct {
|
||||
db *store.DB
|
||||
executor *Executor
|
||||
|
||||
mu sync.Mutex
|
||||
subscribers map[*dagSubscriber]struct{}
|
||||
tickerStop chan struct{}
|
||||
tickerOn bool
|
||||
watermark wsWatermark
|
||||
lastEventAt time.Time
|
||||
}
|
||||
|
||||
func NewDagRunHub(db *store.DB, executor *Executor) *DagRunHub {
|
||||
return &DagRunHub{
|
||||
db: db,
|
||||
executor: executor,
|
||||
subscribers: make(map[*dagSubscriber]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DagRunHub) register(s *dagSubscriber) {
|
||||
h.mu.Lock()
|
||||
h.subscribers[s] = struct{}{}
|
||||
shouldStart := !h.tickerOn
|
||||
if shouldStart {
|
||||
h.tickerStop = make(chan struct{})
|
||||
h.tickerOn = true
|
||||
h.lastEventAt = time.Now()
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if shouldStart {
|
||||
go h.tickerLoop()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DagRunHub) unregister(s *dagSubscriber) {
|
||||
h.mu.Lock()
|
||||
if _, ok := h.subscribers[s]; !ok {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(h.subscribers, s)
|
||||
close(s.out)
|
||||
shouldStop := h.tickerOn && len(h.subscribers) == 0
|
||||
if shouldStop {
|
||||
close(h.tickerStop)
|
||||
h.tickerOn = false
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *DagRunHub) tickerLoop() {
|
||||
interval := dagWSTickInterval
|
||||
t := time.NewTimer(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-h.tickerStop:
|
||||
return
|
||||
case <-t.C:
|
||||
runs, steps, wm, err := h.fetchDelta(h.getWatermark())
|
||||
if err != nil {
|
||||
log.Printf("[dagws] fetchDelta: %v", err)
|
||||
} else if len(runs) > 0 || len(steps) > 0 {
|
||||
h.setWatermark(wm)
|
||||
h.recordActivity()
|
||||
h.broadcast(wsDagMessage{
|
||||
Type: "delta",
|
||||
Watermark: wm,
|
||||
Runs: runs,
|
||||
Steps: steps,
|
||||
ServerTime: time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
if time.Since(h.lastActivityAt()) > dagWSIdleThreshold {
|
||||
interval = dagWSTickIntervalIdle
|
||||
} else {
|
||||
interval = dagWSTickInterval
|
||||
}
|
||||
t.Reset(interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DagRunHub) getWatermark() wsWatermark {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.watermark
|
||||
}
|
||||
|
||||
func (h *DagRunHub) setWatermark(v wsWatermark) {
|
||||
h.mu.Lock()
|
||||
if v.Runs > h.watermark.Runs {
|
||||
h.watermark.Runs = v.Runs
|
||||
}
|
||||
if v.Steps > h.watermark.Steps {
|
||||
h.watermark.Steps = v.Steps
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *DagRunHub) recordActivity() {
|
||||
h.mu.Lock()
|
||||
h.lastEventAt = time.Now()
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *DagRunHub) lastActivityAt() time.Time {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.lastEventAt
|
||||
}
|
||||
|
||||
// fetchDelta devuelve runs/steps con (rowid > watermark) OR (status in-flight)
|
||||
// OR (recently finished). Watermark devuelto = max rowid visto.
|
||||
func (h *DagRunHub) fetchDelta(since wsWatermark) ([]wsRun, []wsStep, wsWatermark, error) {
|
||||
conn := h.db.Conn()
|
||||
if conn == nil {
|
||||
return nil, nil, since, nil
|
||||
}
|
||||
cutoff := time.Now().Add(-time.Duration(dagWSRecentFinishedS) * time.Second).Format(time.RFC3339)
|
||||
|
||||
runs, maxRuns, err := scanRuns(conn, `
|
||||
SELECT rowid, id, dag_name, dag_path, status, trigger, started_at,
|
||||
COALESCE(finished_at,''), error
|
||||
FROM dag_runs
|
||||
WHERE rowid > ?
|
||||
OR status IN ('running','pending')
|
||||
OR (finished_at IS NOT NULL AND finished_at >= ?)
|
||||
ORDER BY rowid ASC`, since.Runs, cutoff)
|
||||
if err != nil {
|
||||
return nil, nil, since, err
|
||||
}
|
||||
|
||||
steps, maxSteps, err := scanSteps(conn, `
|
||||
SELECT rowid, id, run_id, step_name, status, exit_code, stdout, stderr,
|
||||
COALESCE(started_at,''), COALESCE(finished_at,''), duration_ms, error
|
||||
FROM dag_step_results
|
||||
WHERE rowid > ?
|
||||
OR status IN ('running','pending')
|
||||
OR (finished_at IS NOT NULL AND finished_at >= ?)
|
||||
ORDER BY rowid ASC`, since.Steps, cutoff)
|
||||
if err != nil {
|
||||
return runs, nil, since, err
|
||||
}
|
||||
|
||||
out := wsWatermark{Runs: maxRuns, Steps: maxSteps}
|
||||
if out.Runs < since.Runs {
|
||||
out.Runs = since.Runs
|
||||
}
|
||||
if out.Steps < since.Steps {
|
||||
out.Steps = since.Steps
|
||||
}
|
||||
return runs, steps, out, nil
|
||||
}
|
||||
|
||||
// fetchSnapshot devuelve DAGs + ultimos N runs + sus step_results + watermark.
|
||||
func (h *DagRunHub) fetchSnapshot() ([]DagInfo, []wsRun, []wsStep, wsWatermark, error) {
|
||||
dags, err := h.executor.ListDAGs()
|
||||
if err != nil {
|
||||
log.Printf("[dagws] list dags: %v", err)
|
||||
dags = nil
|
||||
}
|
||||
conn := h.db.Conn()
|
||||
if conn == nil {
|
||||
return dags, nil, nil, wsWatermark{}, nil
|
||||
}
|
||||
|
||||
runs, maxRuns, err := scanRuns(conn, `
|
||||
SELECT rowid, id, dag_name, dag_path, status, trigger, started_at,
|
||||
COALESCE(finished_at,''), error
|
||||
FROM dag_runs
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ?`, dagWSSnapshotRuns)
|
||||
if err != nil {
|
||||
return dags, nil, nil, wsWatermark{}, err
|
||||
}
|
||||
|
||||
steps, maxSteps, err := scanSteps(conn, `
|
||||
SELECT rowid, id, run_id, step_name, status, exit_code, stdout, stderr,
|
||||
COALESCE(started_at,''), COALESCE(finished_at,''), duration_ms, error
|
||||
FROM dag_step_results
|
||||
WHERE run_id IN (SELECT id FROM dag_runs ORDER BY started_at DESC LIMIT ?)
|
||||
ORDER BY rowid ASC`, dagWSSnapshotRuns)
|
||||
if err != nil {
|
||||
return dags, runs, nil, wsWatermark{Runs: maxRuns}, err
|
||||
}
|
||||
|
||||
return dags, runs, steps, wsWatermark{Runs: maxRuns, Steps: maxSteps}, nil
|
||||
}
|
||||
|
||||
func scanRuns(conn *sql.DB, q string, args ...any) ([]wsRun, int64, error) {
|
||||
rows, err := conn.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []wsRun
|
||||
var max int64
|
||||
for rows.Next() {
|
||||
var r wsRun
|
||||
var rowid int64
|
||||
if err := rows.Scan(&rowid, &r.ID, &r.DagName, &r.DagPath, &r.Status,
|
||||
&r.Trigger, &r.StartedAt, &r.FinishedAt, &r.Error); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if rowid > max {
|
||||
max = rowid
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, max, rows.Err()
|
||||
}
|
||||
|
||||
func scanSteps(conn *sql.DB, q string, args ...any) ([]wsStep, int64, error) {
|
||||
rows, err := conn.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []wsStep
|
||||
var max int64
|
||||
for rows.Next() {
|
||||
var s wsStep
|
||||
var rowid int64
|
||||
if err := rows.Scan(&rowid, &s.ID, &s.RunID, &s.StepName, &s.Status,
|
||||
&s.ExitCode, &s.Stdout, &s.Stderr, &s.StartedAt, &s.FinishedAt,
|
||||
&s.DurationMs, &s.Error); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if rowid > max {
|
||||
max = rowid
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, max, rows.Err()
|
||||
}
|
||||
|
||||
func (h *DagRunHub) broadcast(msg wsDagMessage) {
|
||||
h.mu.Lock()
|
||||
subs := make([]*dagSubscriber, 0, len(h.subscribers))
|
||||
for s := range h.subscribers {
|
||||
subs = append(subs, s)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, s := range subs {
|
||||
select {
|
||||
case s.out <- msg:
|
||||
default:
|
||||
log.Printf("[dagws] dropping frame for slow subscriber")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleDagRunsWS upgrade WS y gestiona lifecycle.
|
||||
// Endpoint: GET /api/ws/dagruns
|
||||
func handleDagRunsWS(hub *DagRunHub) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
InsecureSkipVerify: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[dagws] accept: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close(websocket.StatusInternalError, "closing")
|
||||
|
||||
ctx, cancel := context.WithCancel(r.Context())
|
||||
defer cancel()
|
||||
|
||||
sub := &dagSubscriber{
|
||||
conn: conn,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
out: make(chan wsDagMessage, 64),
|
||||
}
|
||||
hub.register(sub)
|
||||
defer hub.unregister(sub)
|
||||
|
||||
dags, runs, steps, wm, err := hub.fetchSnapshot()
|
||||
if err != nil {
|
||||
log.Printf("[dagws] snapshot: %v", err)
|
||||
conn.Close(websocket.StatusInternalError, "snapshot failed")
|
||||
return
|
||||
}
|
||||
hub.setWatermark(wm)
|
||||
initial := wsDagMessage{
|
||||
Type: "snapshot",
|
||||
Watermark: wm,
|
||||
Dags: dags,
|
||||
Runs: runs,
|
||||
Steps: steps,
|
||||
ServerTime: time.Now().Unix(),
|
||||
}
|
||||
if err := wsjson.Write(ctx, conn, initial); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
readErr := make(chan error, 1)
|
||||
go func() {
|
||||
for {
|
||||
var cmd wsDagClientCmd
|
||||
if err := wsjson.Read(ctx, conn, &cmd); err != nil {
|
||||
readErr <- err
|
||||
return
|
||||
}
|
||||
if cmd.Watermark.Runs > 0 || cmd.Watermark.Steps > 0 {
|
||||
runs, steps, wm, err := hub.fetchDelta(cmd.Watermark)
|
||||
if err == nil && (len(runs) > 0 || len(steps) > 0) {
|
||||
hub.setWatermark(wm)
|
||||
select {
|
||||
case sub.out <- wsDagMessage{
|
||||
Type: "delta",
|
||||
Watermark: wm,
|
||||
Runs: runs,
|
||||
Steps: steps,
|
||||
ServerTime: time.Now().Unix(),
|
||||
}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case err := <-readErr:
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
case msg, ok := <-sub.out:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
wctx, wcancel := context.WithTimeout(ctx, dagWSBroadcastTimeout)
|
||||
err := wsjson.Write(wctx, conn, msg)
|
||||
wcancel()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
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()
|
||||
|
||||
// Resolve command source: function (registry) takes precedence over command/script.
|
||||
var command string
|
||||
var stepFunctionID string
|
||||
var fnRegistryRoot string
|
||||
if step.Function != "" {
|
||||
stepFunctionID = step.Function
|
||||
fnRegistryRoot = os.Getenv("FN_REGISTRY_ROOT")
|
||||
if fnRegistryRoot == "" {
|
||||
fnRegistryRoot = "/home/lucas/fn_registry"
|
||||
}
|
||||
fnBin := os.Getenv("FN_BIN")
|
||||
if fnBin == "" {
|
||||
fnBin = fnRegistryRoot + "/fn"
|
||||
}
|
||||
parts := []string{fnBin, "run", step.Function}
|
||||
parts = append(parts, step.Args...)
|
||||
command = strings.Join(parts, " ")
|
||||
} else if step.Command != "" {
|
||||
command = step.Command
|
||||
} else if step.Script != "" {
|
||||
command = step.Script
|
||||
}
|
||||
|
||||
e.store.InsertStepResult(&store.DagStepResult{
|
||||
ID: stepID,
|
||||
RunID: runID,
|
||||
StepName: stepName(step),
|
||||
FunctionID: stepFunctionID,
|
||||
Status: "running",
|
||||
StartedAt: &now,
|
||||
})
|
||||
|
||||
// Build environment.
|
||||
env := buildStepEnv(dag, step, daguEnvPath, outputs)
|
||||
|
||||
// For function: steps, force FN_REGISTRY_ROOT into env so `fn run`
|
||||
// resolves the canonical registry.db (not whatever lives at the spawn cwd).
|
||||
// Prevents the apps/dag_engine/registry.db stale-shadow bug (2026-05-16).
|
||||
if stepFunctionID != "" {
|
||||
env = append(env, "FN_REGISTRY_ROOT="+fnRegistryRoot)
|
||||
}
|
||||
|
||||
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. function: steps default to FN_REGISTRY_ROOT
|
||||
// so `fn` resolves registry.db correctly via go.mod walk-up.
|
||||
dir := step.Dir
|
||||
if dir == "" {
|
||||
dir = dag.WorkingDir
|
||||
}
|
||||
if dir == "" && stepFunctionID != "" {
|
||||
dir = fnRegistryRoot
|
||||
}
|
||||
|
||||
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"`
|
||||
LastRuns []store.DagRun `json:"last_runs,omitempty"` // 5 mas recientes (mas reciente primero)
|
||||
}
|
||||
|
||||
// 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 5 runs (most recent first).
|
||||
runs, _, _ := e.store.ListRuns(dag.Name, 5, 0)
|
||||
if len(runs) > 0 {
|
||||
info.LastRun = &runs[0]
|
||||
info.LastRuns = runs
|
||||
}
|
||||
|
||||
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,191 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Title,
|
||||
Text,
|
||||
Group,
|
||||
Button,
|
||||
Stack,
|
||||
Paper,
|
||||
Alert,
|
||||
Loader,
|
||||
CopyButton,
|
||||
Tooltip,
|
||||
ActionIcon,
|
||||
Code,
|
||||
} from "@mantine/core";
|
||||
import { IconArrowLeft, IconCopy, IconCheck } from "@tabler/icons-react";
|
||||
import { getRun } from "../api";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { StepTimeline } from "../components/StepTimeline";
|
||||
import type { RunDetail as RunDetailType, DagStepResult, DagRun } from "../types";
|
||||
|
||||
function buildLogText(run: DagRun, steps: DagStepResult[]): string {
|
||||
const lines: string[] = [];
|
||||
const started = run.StartedAt ? new Date(run.StartedAt) : null;
|
||||
const finished = run.FinishedAt ? new Date(run.FinishedAt) : null;
|
||||
const durationMs =
|
||||
started && finished ? finished.getTime() - started.getTime() : null;
|
||||
|
||||
lines.push(`=== DAG run ${run.ID} ===`);
|
||||
lines.push(`dag: ${run.DagName}`);
|
||||
lines.push(`path: ${run.DagPath}`);
|
||||
lines.push(`status: ${run.Status}`);
|
||||
lines.push(`trigger: ${run.Trigger}`);
|
||||
lines.push(`started: ${started ? started.toISOString() : "-"}`);
|
||||
lines.push(`finished: ${finished ? finished.toISOString() : "-"}`);
|
||||
lines.push(
|
||||
`duration: ${durationMs !== null ? `${durationMs} ms` : "running..."}`
|
||||
);
|
||||
if (run.Error) {
|
||||
lines.push("");
|
||||
lines.push("run error:");
|
||||
lines.push(run.Error);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(`--- steps (${steps.length}) ---`);
|
||||
for (const s of steps) {
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`[${s.Status}] ${s.StepName} exit=${s.ExitCode} ${s.DurationMs}ms`
|
||||
);
|
||||
if (s.StartedAt) lines.push(` started: ${s.StartedAt}`);
|
||||
if (s.FinishedAt) lines.push(` finished: ${s.FinishedAt}`);
|
||||
if (s.Error) {
|
||||
lines.push(" error:");
|
||||
lines.push(s.Error.split("\n").map((l) => " " + l).join("\n"));
|
||||
}
|
||||
if (s.Stdout) {
|
||||
lines.push(" stdout:");
|
||||
lines.push(s.Stdout.split("\n").map((l) => " " + l).join("\n"));
|
||||
}
|
||||
if (s.Stderr) {
|
||||
lines.push(" stderr:");
|
||||
lines.push(s.Stderr.split("\n").map((l) => " " + l).join("\n"));
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<Paper p="md" withBorder>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Title order={4}>Logs</Title>
|
||||
<CopyButton value={buildLogText(run, steps || [])} timeout={1500}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip
|
||||
label={copied ? "Copiado" : "Copiar log completo"}
|
||||
withArrow
|
||||
position="left"
|
||||
>
|
||||
<ActionIcon
|
||||
variant={copied ? "filled" : "light"}
|
||||
color={copied ? "teal" : "blue"}
|
||||
onClick={copy}
|
||||
aria-label="Copiar logs"
|
||||
>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
<Code
|
||||
block
|
||||
style={{
|
||||
maxHeight: 480,
|
||||
overflow: "auto",
|
||||
whiteSpace: "pre",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{buildLogText(run, steps || [])}
|
||||
</Code>
|
||||
</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,51 @@
|
||||
module dag-engine
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
fn-registry v0.0.0-00010101000000-000000000000
|
||||
github.com/mattn/go-sqlite3 v1.14.37
|
||||
nhooyr.io/websocket v1.8.17
|
||||
)
|
||||
|
||||
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/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/crypto v0.50.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/tools v0.43.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,176 @@
|
||||
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/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
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.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
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/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
|
||||
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.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
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.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
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=
|
||||
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
|
||||
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
|
||||
@@ -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,
|
||||
"recent_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,337 @@
|
||||
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)
|
||||
dagRunHub := NewDagRunHub(db, executor)
|
||||
|
||||
// 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, dagRunHub, 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,266 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// applyMigrations executes every embedded migrations/*.sql in order.
|
||||
// Each statement is idempotent (IF NOT EXISTS / ADD COLUMN). Duplicate-column
|
||||
// errors from re-running ALTER TABLE ADD COLUMN are tolerated.
|
||||
func applyMigrations(conn *sql.DB) error {
|
||||
files, err := fs.Glob(migrationsFS, "migrations/*.sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Strings(files)
|
||||
for _, f := range files {
|
||||
b, err := migrationsFS.ReadFile(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: read: %w", f, err)
|
||||
}
|
||||
if _, err := conn.Exec(string(b)); err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate column") ||
|
||||
strings.Contains(err.Error(), "already exists") {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("%s: %w", f, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 := applyMigrations(conn); 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()
|
||||
}
|
||||
|
||||
// Conn exposes the underlying *sql.DB for read-only queries from other
|
||||
// packages (e.g. WS hub in events.go). Do not Close() the returned conn.
|
||||
func (db *DB) Conn() *sql.DB {
|
||||
return db.conn
|
||||
}
|
||||
|
||||
// --- DagRun CRUD ---
|
||||
|
||||
// DagRun mirrors infra.DagRun for the store layer.
|
||||
type DagRun struct {
|
||||
ID string `json:"id"`
|
||||
DagName string `json:"dag_name"`
|
||||
DagPath string `json:"dag_path"`
|
||||
Status string `json:"status"`
|
||||
Trigger string `json:"trigger"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// 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 `json:"id"`
|
||||
RunID string `json:"run_id"`
|
||||
StepName string `json:"step_name"`
|
||||
FunctionID string `json:"function_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Stdout string `json:"stdout,omitempty"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// 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, function_id, status, exit_code, stdout, stderr, started_at, finished_at, duration_ms, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
r.ID, r.RunID, r.StepName, r.FunctionID, 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, function_id, 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.FunctionID, &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,118 @@
|
||||
---
|
||||
name: shaders_lab
|
||||
lang: cpp
|
||||
domain: gfx
|
||||
version: 0.1.0
|
||||
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: "apps/shaders_lab"
|
||||
repo_url: ""
|
||||
icon:
|
||||
phosphor: "palette"
|
||||
accent: "#ea580c"
|
||||
---
|
||||
|
||||
## 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`).
|
||||
|
||||
|
||||
## Capability growth log
|
||||
|
||||
Una linea por bump SemVer. Bump-type segun `.claude/commands/version.md`:
|
||||
- `major`: breaking observable (CLI args, schema BBDD propia, formato wire).
|
||||
- `minor`: feature aditiva (nuevo panel, endpoint, opcion).
|
||||
- `patch`: bugfix sin cambio observable.
|
||||
|
||||
- v0.1.0 (2026-05-18) — baseline.
|
||||
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.
|
||||
@@ -1,103 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# install_chromium_proxy_extension — 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.
|
||||
#
|
||||
# Por que /etc/chromium.d en vez de managed policy con .crx force_installed:
|
||||
# el wrapper de Chromium de Debian/Ubuntu (xtradeb y derivados), cuando NO se
|
||||
# pasa --enable-remote-extensions, anade --disable-extensions-except=<solo las
|
||||
# de --load-extension> y --disable-background-networking. Eso deshabilita
|
||||
# cualquier extension force_installed por policy y bloquea su update check. La
|
||||
# via fiable es habilitar --enable-remote-extensions y cargar la extension
|
||||
# desempaquetada con --load-extension, ambos inyectados de forma global desde
|
||||
# /etc/chromium.d/, que el wrapper hace `source` en cada lanzamiento.
|
||||
|
||||
install_chromium_proxy_extension() {
|
||||
local ext_dir=""
|
||||
local name="web_proxy_ext"
|
||||
local stable_dir="$HOME/.web_proxy/extension"
|
||||
local chromiumd="/etc/chromium.d"
|
||||
local uninstall="no"
|
||||
|
||||
# Permite sudo no interactivo via SUDO_ASKPASS (sudo -A) cuando se ejecuta
|
||||
# sin terminal (agentes, CI). Con terminal interactivo usa sudo normal.
|
||||
local SUDO="sudo"
|
||||
[[ -n "${SUDO_ASKPASS:-}" ]] && SUDO="sudo -A"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--ext-dir) ext_dir="$2"; shift 2 ;;
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--stable-dir) stable_dir="$2"; shift 2 ;;
|
||||
--uninstall) uninstall="yes"; shift ;;
|
||||
*) echo "ERROR: argumento desconocido: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -d "$chromiumd" ]]; then
|
||||
echo "ERROR: $chromiumd no existe. Este Chromium no usa el wrapper con /etc/chromium.d." >&2
|
||||
echo " Comprueba 'head -1 \$(command -v chromium)'; si no es un wrapper shell, usa otra via." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Desinstalacion: quitar el fragmento global y la copia estable.
|
||||
if [[ "$uninstall" == "yes" ]]; then
|
||||
$SUDO rm -f "${chromiumd}/${name}" || {
|
||||
echo "ERROR: no se pudo eliminar ${chromiumd}/${name} (requiere sudo)." >&2
|
||||
return 1
|
||||
}
|
||||
rm -rf "$stable_dir"
|
||||
printf '{"uninstalled": true, "name": "%s"}\n' "$name"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Instalacion: validar la extension de origen.
|
||||
if [[ -z "$ext_dir" || ! -f "${ext_dir}/manifest.json" ]]; then
|
||||
echo "ERROR: --ext-dir debe apuntar a un directorio con manifest.json." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Copiar la extension a una ubicacion estable, independiente del repo, para
|
||||
# que --load-extension no se rompa si el repo se mueve o se limpia.
|
||||
mkdir -p "$stable_dir" || {
|
||||
echo "ERROR: no se pudo crear $stable_dir." >&2
|
||||
return 1
|
||||
}
|
||||
# Vaciar destino y copiar el contenido del origen.
|
||||
rm -rf "${stable_dir:?}/"* 2>/dev/null
|
||||
cp -r "${ext_dir}/." "$stable_dir/" || {
|
||||
echo "ERROR: no se pudo copiar la extension a $stable_dir." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Escribir el fragmento que el wrapper carga en cada arranque. Se hace via
|
||||
# archivo temporal + sudo cp para no exponer el contenido por una tuberia.
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
printf 'export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --enable-remote-extensions --load-extension=%s"\n' "$stable_dir" > "$tmp"
|
||||
if ! $SUDO cp "$tmp" "${chromiumd}/${name}"; then
|
||||
rm -f "$tmp"
|
||||
echo "ERROR: no se pudo escribir ${chromiumd}/${name} (requiere sudo)." >&2
|
||||
return 1
|
||||
fi
|
||||
$SUDO chmod 0644 "${chromiumd}/${name}" 2>/dev/null
|
||||
rm -f "$tmp"
|
||||
|
||||
# ID de extension desempaquetada (deterministico: sha256 del path estable).
|
||||
local ext_id
|
||||
ext_id="$(python3 - "$stable_dir" <<'PY' 2>/dev/null
|
||||
import hashlib, sys
|
||||
h = hashlib.sha256(sys.argv[1].encode()).hexdigest()[:32]
|
||||
print(''.join(chr(ord('a') + int(c, 16)) for c in h))
|
||||
PY
|
||||
)"
|
||||
|
||||
printf '{"installed": true, "name": "%s", "stable_dir": "%s", "chromiumd": "%s/%s", "ext_id": "%s"}\n' \
|
||||
"$name" "$stable_dir" "$chromiumd" "$name" "$ext_id"
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente (fn run / bash <file>)
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
install_chromium_proxy_extension "$@"
|
||||
fi
|
||||
@@ -1,74 +0,0 @@
|
||||
---
|
||||
name: launch_chromium_proxy
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "launch_chromium_proxy [--proxy URL] [--profile DIR] [--url URL] [--ca-cert PATH] [--extra \"ARGS\"]"
|
||||
description: "Lanza Chromium (o Chrome) apuntando a un proxy HTTP/HTTPS local con un perfil completamente aislado del perfil real del usuario. Pensado para capturar trafico con un proxy de interceptacion (mitmproxy, Burp Suite) sin contaminar la sesion normal de navegacion. Emite un JSON con el PID del proceso lanzado."
|
||||
tags: [chromium, chrome, proxy, mitmproxy, burp, browser, web-proxy, intercept, tls]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "--proxy URL"
|
||||
desc: "URL del proxy HTTP/HTTPS local, ej. http://127.0.0.1:8080. Se pasa a Chromium como --proxy-server=URL. Default: http://127.0.0.1:8080."
|
||||
- name: "--profile DIR"
|
||||
desc: "Directorio de perfil aislado para Chromium (--user-data-dir). Se crea automaticamente si no existe. Default: /tmp/chromium-proxy. Usar un path distinto por sesion si se quiere aislamiento total entre corridas."
|
||||
- name: "--url URL"
|
||||
desc: "URL inicial a abrir al arrancar el navegador. Opcional. Si se omite, Chromium abre su pagina de nueva pestana."
|
||||
- name: "--ca-cert PATH"
|
||||
desc: "Ruta a un CA cert PEM del proxy (ej. ~/.mitmproxy/mitmproxy-ca-cert.pem). Si se pasa, la funcion NO agrega --ignore-certificate-errors y asume que el usuario ya importo el CA en el perfil o en el sistema. Si se omite, se agrega --ignore-certificate-errors automaticamente para que el proxy MITM no rompa HTTPS (menos seguro)."
|
||||
- name: "--extra \"ARGS\""
|
||||
desc: "Flags extra que se pasan directamente a chromium, ej. --extra \"--disable-gpu --window-size=1280,800\". El valor completo debe ir entre comillas."
|
||||
output: "JSON en stdout: {\"pid\": <pid>, \"browser\": \"<binario>\", \"proxy\": \"<url>\", \"profile\": \"<dir>\", \"log\": \"<ruta_log>\"}. Mensajes de estado e informacion de CA en stderr. El navegador corre en background desacoplado de la sesion. Exit codes: 0=lanzado correctamente, 1=binario no encontrado o argumento invalido o error al crear el directorio de perfil."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/browser/launch_chromium_proxy.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/browser/launch_chromium_proxy.sh
|
||||
|
||||
# Caso mas comun: interceptar trafico con mitmproxy corriendo en 8080
|
||||
# (sin CA instalado: --ignore-certificate-errors se aplica automaticamente)
|
||||
result=$(launch_chromium_proxy --proxy http://127.0.0.1:8080 --url https://httpbin.org/get)
|
||||
echo "$result"
|
||||
# {"pid":12345,"browser":"chromium","proxy":"http://127.0.0.1:8080","profile":"/tmp/chromium-proxy","log":"/tmp/chromium-proxy-12345.log"}
|
||||
|
||||
# Con CA cert instalado (mitmproxy): sin --ignore-certificate-errors
|
||||
launch_chromium_proxy \
|
||||
--proxy http://127.0.0.1:8080 \
|
||||
--ca-cert ~/.mitmproxy/mitmproxy-ca-cert.pem \
|
||||
--profile /tmp/mitm-session \
|
||||
--url https://api.ejemplo.com/v1/test
|
||||
|
||||
# Con Burp Suite en puerto 8081, perfil aislado y ventana de tamano fijo
|
||||
launch_chromium_proxy \
|
||||
--proxy http://127.0.0.1:8081 \
|
||||
--profile /tmp/burp-session \
|
||||
--extra "--window-size=1440,900" \
|
||||
--url https://app.objetivo.com
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas capturar y analizar trafico HTTPS de un navegador con mitmproxy, Burp Suite u otro proxy de interceptacion, sin tocar el perfil real del usuario ni sus cookies/credenciales guardadas. Ideal antes de hacer analisis de trafico de una app web o API, o al reproducir un flujo autenticado desde una sesion limpia.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Deteccion de binario en orden**: la funcion prueba `chromium`, `chromium-browser`, `google-chrome-stable`, `google-chrome`. En sistemas donde solo existe `google-chrome`, ese sera el binario usado. Si ninguno esta en el PATH, retorna exit 1. Instalar con `sudo apt install chromium` o `chromium-browser`.
|
||||
- **`--ignore-certificate-errors` sin `--ca-cert`**: este flag desactiva toda la validacion TLS del navegador. Es conveniente para empezar rapido, pero reduce la seguridad de la sesion. Para produccion o analisis de seguridad serio, instalar el CA del proxy en el sistema (`sudo cp mitmproxy-ca-cert.pem /usr/local/share/ca-certificates/mitmproxy.crt && sudo update-ca-certificates`) o en el perfil de Chromium (chrome://settings/certificates), y pasar `--ca-cert` para que la funcion omita el flag inseguro.
|
||||
- **`--proxy-bypass-list="<-loopback>"`**: fuerza que el trafico loopback (127.0.0.1, localhost) TAMBIEN pase por el proxy. Sin esto, Chromium excluye loopback del proxy por defecto y no veras esas peticiones en mitmproxy. Si quieres el comportamiento estandar (excluir loopback), elimina este flag via `--extra`.
|
||||
- **Perfil persistente entre sesiones**: el perfil en `/tmp/chromium-proxy` (o el directorio que elijas) persiste entre ejecuciones. Si quieres una sesion 100% limpia cada vez, pasa `--profile /tmp/chromium-proxy-$$` (usa el PID del shell como sufijo) o borra el directorio antes de llamar a la funcion.
|
||||
- **`setsid` + `disown`**: el navegador se lanza desacoplado de la sesion del agente. Si la shell/sesion que llamo a la funcion termina, el proceso Chromium sigue vivo. Para matarlo, usar `kill <pid>` con el PID del JSON de salida.
|
||||
- **Log del navegador**: stdout y stderr de Chromium se redirigen a `/tmp/chromium-proxy-<pid>.log`. Si el navegador no arranca, revisar ese archivo para ver el error.
|
||||
- **Chrome STABLE 138+**: al igual que `chrome_load_extensions`, algunos flags de automatizacion estan bloqueados en Chrome STABLE. Para interceptacion de trafico `--proxy-server` y `--user-data-dir` siguen funcionando en todas las versiones. Esta funcion es compatible con Chrome/Chromium en cualquier canal.
|
||||
- **Multiples instancias**: si ya hay una instancia de Chromium corriendo con el mismo `--user-data-dir`, Chromium puede reusar esa instancia en lugar de abrir una nueva. Usar un directorio de perfil distinto por sesion concurrente.
|
||||
@@ -1,118 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# launch_chromium_proxy — Lanza Chromium apuntando a un proxy HTTP/HTTPS local con perfil aislado.
|
||||
|
||||
launch_chromium_proxy() {
|
||||
local proxy_url="http://127.0.0.1:8080"
|
||||
local profile_dir="/tmp/chromium-proxy"
|
||||
local start_url=""
|
||||
local ca_cert=""
|
||||
local extra_args=""
|
||||
local ext_dir=""
|
||||
|
||||
# Parsear argumentos
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--proxy)
|
||||
proxy_url="$2"; shift 2 ;;
|
||||
--profile)
|
||||
profile_dir="$2"; shift 2 ;;
|
||||
--url)
|
||||
start_url="$2"; shift 2 ;;
|
||||
--ca-cert)
|
||||
ca_cert="$2"; shift 2 ;;
|
||||
--ext)
|
||||
ext_dir="$2"; shift 2 ;;
|
||||
--extra)
|
||||
extra_args="$2"; shift 2 ;;
|
||||
*)
|
||||
echo "ERROR: argumento desconocido: $1" >&2
|
||||
return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Detectar binario del navegador
|
||||
local browser_bin=""
|
||||
for candidate in chromium chromium-browser google-chrome-stable google-chrome; do
|
||||
if command -v "$candidate" &>/dev/null; then
|
||||
browser_bin="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "$browser_bin" ]]; then
|
||||
echo "ERROR: no se encontro ningun binario Chromium/Chrome en el PATH." >&2
|
||||
echo " Probados: chromium, chromium-browser, google-chrome-stable, google-chrome." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Crear directorio de perfil si no existe
|
||||
if [[ ! -d "$profile_dir" ]]; then
|
||||
mkdir -p "$profile_dir" || {
|
||||
echo "ERROR: no se pudo crear el directorio de perfil: $profile_dir" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Construir argumentos del navegador
|
||||
local args=(
|
||||
"--user-data-dir=${profile_dir}"
|
||||
"--no-first-run"
|
||||
"--no-default-browser-check"
|
||||
)
|
||||
|
||||
# Proxy fijo opcional. Con "--proxy none" (o vacio) no se fija proxy en el
|
||||
# cmdline: util cuando una extension de proxy gestiona la conexion (toggle).
|
||||
if [[ -n "$proxy_url" && "$proxy_url" != "none" ]]; then
|
||||
args+=("--proxy-server=${proxy_url}" "--proxy-bypass-list=<-loopback>")
|
||||
fi
|
||||
|
||||
# Cargar una extension desempaquetada (--load-extension). Funciona en
|
||||
# Chromium (no en Chrome stable 138+). Para persistencia en todos los
|
||||
# perfiles se usa managed policy en su lugar.
|
||||
if [[ -n "$ext_dir" ]]; then
|
||||
args+=("--load-extension=${ext_dir}")
|
||||
fi
|
||||
|
||||
# Manejo de certificados TLS
|
||||
if [[ -n "$ca_cert" ]]; then
|
||||
# El usuario instalo el CA en el perfil; no ignorar errores de certificado.
|
||||
# (El CA se instala en el sistema o en el perfil antes de lanzar.)
|
||||
echo "INFO: CA cert declarado: $ca_cert" >&2
|
||||
echo "INFO: Asegurate de haber importado el CA en el perfil o en el sistema antes de navegar HTTPS." >&2
|
||||
else
|
||||
# Sin CA cert: ignorar errores de certificado para que mitmproxy/Burp funcionen sin configuracion extra.
|
||||
# ADVERTENCIA: esto desactiva la validacion TLS completa del navegador.
|
||||
args+=("--ignore-certificate-errors")
|
||||
echo "WARN: --ignore-certificate-errors activo. Usa --ca-cert si instalaste el CA del proxy." >&2
|
||||
fi
|
||||
|
||||
# URL inicial opcional
|
||||
if [[ -n "$start_url" ]]; then
|
||||
args+=("$start_url")
|
||||
fi
|
||||
|
||||
# Argumentos extra pasados por el usuario
|
||||
# shellcheck disable=SC2206
|
||||
local extra_arr=()
|
||||
if [[ -n "$extra_args" ]]; then
|
||||
read -r -a extra_arr <<< "$extra_args"
|
||||
args+=("${extra_arr[@]}")
|
||||
fi
|
||||
|
||||
# Log temporal para stderr/stdout del navegador
|
||||
local log_file="/tmp/chromium-proxy-$$.log"
|
||||
|
||||
# Lanzar en background desacoplado de la sesion del agente
|
||||
setsid "$browser_bin" "${args[@]}" </dev/null >"$log_file" 2>&1 &
|
||||
local browser_pid=$!
|
||||
disown "$browser_pid"
|
||||
|
||||
# Emitir JSON con informacion del proceso lanzado
|
||||
printf '{"pid":%d,"browser":"%s","proxy":"%s","profile":"%s","log":"%s"}\n' \
|
||||
"$browser_pid" "$browser_bin" "$proxy_url" "$profile_dir" "$log_file"
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente (fn run / bash <file>)
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
launch_chromium_proxy "$@"
|
||||
fi
|
||||
@@ -1,74 +0,0 @@
|
||||
---
|
||||
name: prepare_chrome_profile
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "prepare_chrome_profile --src <user-data-dir> --dst <user-data-dir> [--keep <ext_id>]... [--force]"
|
||||
description: "Clona un user-data-dir de Chrome/Chromium creando un perfil de scraping limpio: conserva solo las extensiones de una lista blanca (por defecto uBlock Origin Lite) y excluye caché, locks y sesiones antiguas."
|
||||
tags: [chrome, browser, profile, scraping, extensions, navegator]
|
||||
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/prepare_chrome_profile.sh"
|
||||
params:
|
||||
- name: --src
|
||||
desc: "user-data-dir origen con un perfil Chrome/Chromium ya configurado (debe existir --src/Default)"
|
||||
- name: --dst
|
||||
desc: "Ruta de destino del nuevo perfil; no debe existir salvo que se pase --force"
|
||||
- name: --keep
|
||||
desc: "ID de extensión Chrome a conservar (repetible). Si no se pasa ninguno el default es ddkjiahejlhfcafbddmgiahcphecmpfh (uBlock Origin Lite)"
|
||||
- name: --force
|
||||
desc: "Borra --dst si existe antes de recrearlo. Sin este flag la función aborta si --dst ya existe"
|
||||
output: "JSON en stdout: {dst, kept: [id...], removed: [id...]}. Exit 0 en éxito."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source $HOME/fn_registry/bash/functions/browser/prepare_chrome_profile.sh
|
||||
|
||||
prepare_chrome_profile \
|
||||
--src "$HOME/.config/chromium" \
|
||||
--dst "$HOME/.local/share/web_scraping/chrome-profile"
|
||||
|
||||
# Con extensión adicional conservada
|
||||
prepare_chrome_profile \
|
||||
--src "$HOME/.config/chromium" \
|
||||
--dst "$HOME/.local/share/web_scraping/chrome-profile" \
|
||||
--keep "ddkjiahejlhfcafbddmgiahcphecmpfh" \
|
||||
--keep "cjpalhdlnbpafiamejdnhcphjbkeiagm" \
|
||||
--force
|
||||
|
||||
# Salida esperada (ejemplo):
|
||||
# {"dst":"/home/enmanuel/.local/share/web_scraping/chrome-profile","kept":["ddkjiahejlhfcafbddmgiahcphecmpfh"],"removed":["abcdefghijklmnopabcdefghijklmnop","dark-reader-id"]}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala antes de lanzar una sesión de scraping/automatización para partir de un perfil aislado: con uBlock Origin Lite activo (menos anuncios/trackers = DOM más limpio, respuestas más rápidas) pero sin extensiones que interfieren (Dark Reader muta colores del DOM, NoScript bloquea JS, OneTab modifica tabs). También sirve para aislar sesiones de diferentes proyectos de scraping sin contaminar el perfil personal.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Chrome debe estar CERRADO sobre `--src`** antes de ejecutar. Los archivos SQLite (`Cookies`, `History`, `Login Data`, etc.) estarán bloqueados si Chrome está abierto, y `rsync` copiará versiones inconsistentes. Verificar con `pgrep -x chromium` o `pgrep -x chrome`.
|
||||
- **HMAC de Secure Preferences**: el archivo `Local State` contiene la semilla HMAC que Chrome usa para verificar `Preferences` y `Secure Preferences`. Si no se copia (o se copia entre máquinas distintas con distinto binding), Chrome puede invalidar las extensiones al arrancar y resetear configuraciones. La función copia `Local State` automáticamente, pero la copia entre máquinas puede seguir produciendo resets de extensiones — esto es comportamiento esperado de Chrome, no un bug de esta función.
|
||||
- **Purga de referencias en Preferences**: tras borrar las carpetas de extensiones fuera de la whitelist, la función también elimina con `python3` las entradas `extensions.settings.<id>` de `Default/Preferences` y `Default/Secure Preferences`, los IDs de `extensions.pinned_extensions` y las claves `protection.macs.extensions.settings.<id>`. Sin esta limpieza Chrome detecta las entradas en Preferences (con `from_webstore`/install_source) y **vuelve a descargar la extensión del Web Store al arrancar**, deshaciendo el filtrado (caso real: Dark Reader reaparece y oscurece páginas rompiendo screenshots). Si `python3` falla al procesar un Preferences concreto se emite un warning a stderr pero la función no aborta — el borrado de carpetas ya es el efecto principal.
|
||||
- **`--force` borra `--dst` completamente**: si `--dst` es un perfil con datos que quieres conservar, no uses `--force` sin antes hacer backup.
|
||||
- **Extensiones instaladas desde Web Store vs unpacked**: esta función opera sobre la carpeta `Extensions/` física. Las extensiones instaladas desde la Web Store tienen IDs de 32 caracteres en minúsculas. Las extensiones unpacked (`--load-extension`) no viven en `Extensions/` y no se ven afectadas.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Código | Significado |
|
||||
|--------|------------|
|
||||
| 0 | Éxito |
|
||||
| 1 | Argumento inválido o `--src/Default` no existe |
|
||||
| 2 | `--dst` ya existe y no se pasó `--force` |
|
||||
| 3 | `--src` y `--dst` resuelven al mismo path real |
|
||||
| 4 | Error durante `rsync` |
|
||||
@@ -1,223 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# prepare_chrome_profile — clona un user-data-dir de Chrome/Chromium conservando solo
|
||||
# las extensiones de una lista blanca. Sirve para perfiles de scraping limpios.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── defaults ──────────────────────────────────────────────────────────────────
|
||||
_SRC=""
|
||||
_DST=""
|
||||
_FORCE=0
|
||||
# uBlock Origin Lite por defecto
|
||||
_KEEP=()
|
||||
_DEFAULT_EXT="ddkjiahejlhfcafbddmgiahcphecmpfh"
|
||||
|
||||
# ── parse args ────────────────────────────────────────────────────────────────
|
||||
_usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage: prepare_chrome_profile --src <user-data-dir> --dst <user-data-dir> \
|
||||
[--keep <ext_id>]... [--force]
|
||||
|
||||
--src user-data-dir origen (ej. $HOME/.config/chromium)
|
||||
--dst user-data-dir destino a crear
|
||||
--keep ID de extensión a conservar (repetible). Default: uBlock Origin Lite
|
||||
--force si --dst existe, lo borra y recrea; sin flag aborta si existe
|
||||
|
||||
Exit codes:
|
||||
0 éxito
|
||||
1 error de argumento o validación
|
||||
2 --dst ya existe y no se pasó --force
|
||||
3 --src igual a --dst (mismo path real)
|
||||
4 error de copia/rsync
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--src) _SRC="$2"; shift 2 ;;
|
||||
--dst) _DST="$2"; shift 2 ;;
|
||||
--keep) _KEEP+=("$2"); shift 2 ;;
|
||||
--force) _FORCE=1; shift ;;
|
||||
-h|--help) _usage ;;
|
||||
*) echo "prepare_chrome_profile: argumento desconocido: $1" >&2; _usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── validaciones básicas ──────────────────────────────────────────────────────
|
||||
if [[ -z "$_SRC" || -z "$_DST" ]]; then
|
||||
echo "prepare_chrome_profile: --src y --dst son obligatorios" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$_SRC/Default" ]]; then
|
||||
echo "prepare_chrome_profile: $_SRC/Default no existe; ¿es un user-data-dir válido?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolver paths reales para comparar (evitar borrar src cuando src==dst)
|
||||
_SRC_REAL="$(realpath "$_SRC")"
|
||||
_DST_REAL="$(realpath -m "$_DST")" # -m: no requiere que exista
|
||||
|
||||
if [[ "$_SRC_REAL" == "$_DST_REAL" ]]; then
|
||||
echo "prepare_chrome_profile: --src y --dst resuelven al mismo path: $_SRC_REAL" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# También rechazar si --dst es prefijo de --src (evitar borrar el origen)
|
||||
if [[ "$_SRC_REAL" == "$_DST_REAL"/* ]]; then
|
||||
echo "prepare_chrome_profile: --src está dentro de --dst; operación peligrosa, abortando" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# ── lista blanca de extensiones ───────────────────────────────────────────────
|
||||
if [[ ${#_KEEP[@]} -eq 0 ]]; then
|
||||
_KEEP=("$_DEFAULT_EXT")
|
||||
fi
|
||||
|
||||
# ── gestionar destino ─────────────────────────────────────────────────────────
|
||||
if [[ -d "$_DST" ]]; then
|
||||
if [[ $_FORCE -eq 1 ]]; then
|
||||
rm -rf "$_DST"
|
||||
else
|
||||
echo "prepare_chrome_profile: $_DST ya existe; usa --force para sobreescribir" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$_DST/Default"
|
||||
|
||||
# ── copiar Local State (HMAC seed para Secure Preferences) ────────────────────
|
||||
if [[ -f "$_SRC/Local State" ]]; then
|
||||
cp "$_SRC/Local State" "$_DST/Local State"
|
||||
fi
|
||||
|
||||
# ── rsync del perfil Default excluyendo caché y locks ─────────────────────────
|
||||
rsync -a \
|
||||
--exclude='Cache/' \
|
||||
--exclude='Code Cache/' \
|
||||
--exclude='GPUCache/' \
|
||||
--exclude='Dawn Cache/' \
|
||||
--exclude='DawnGraphiteCache/' \
|
||||
--exclude='DawnWebGPUCache/' \
|
||||
--exclude='Service Worker/CacheStorage/' \
|
||||
--exclude='Service Worker/ScriptCache/' \
|
||||
--exclude='Singleton*' \
|
||||
--exclude='*.lock' \
|
||||
--exclude='lockfile' \
|
||||
--exclude='Sessions/' \
|
||||
--exclude='Session Storage/' \
|
||||
--exclude='Current Session' \
|
||||
--exclude='Current Tabs' \
|
||||
--exclude='Last Session' \
|
||||
--exclude='Last Tabs' \
|
||||
"$_SRC/Default/" "$_DST/Default/" || {
|
||||
echo "prepare_chrome_profile: rsync falló (exit $?)" >&2
|
||||
exit 4
|
||||
}
|
||||
|
||||
# ── eliminar extensiones fuera de la lista blanca ────────────────────────────
|
||||
_EXT_DIR="$_DST/Default/Extensions"
|
||||
_removed=()
|
||||
_kept=()
|
||||
|
||||
if [[ -d "$_EXT_DIR" ]]; then
|
||||
while IFS= read -r -d '' ext_path; do
|
||||
ext_id="$(basename "$ext_path")"
|
||||
# Conservar siempre la carpeta Temp (usada por Chrome durante installs)
|
||||
if [[ "$ext_id" == "Temp" ]]; then
|
||||
continue
|
||||
fi
|
||||
# Comprobar si está en la lista blanca
|
||||
_in_keep=0
|
||||
for keep_id in "${_KEEP[@]}"; do
|
||||
if [[ "$ext_id" == "$keep_id" ]]; then
|
||||
_in_keep=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ $_in_keep -eq 1 ]]; then
|
||||
_kept+=("$ext_id")
|
||||
else
|
||||
rm -rf "$ext_path"
|
||||
_removed+=("$ext_id")
|
||||
fi
|
||||
done < <(find "$_EXT_DIR" -mindepth 1 -maxdepth 1 -type d -print0)
|
||||
fi
|
||||
|
||||
# ── purgar referencias a extensiones eliminadas en Preferences ───────────────
|
||||
# Chrome re-descarga del Web Store cualquier extensión que aparezca en
|
||||
# extensions.settings aunque su carpeta haya sido borrada. Editamos el JSON
|
||||
# con python3 para evitar ese comportamiento.
|
||||
if [[ ${#_removed[@]} -gt 0 ]]; then
|
||||
# Construir lista Python de IDs eliminados
|
||||
_py_ids_list=""
|
||||
for _id in "${_removed[@]}"; do
|
||||
_py_ids_list+="\"${_id}\","
|
||||
done
|
||||
_py_ids_list="[${_py_ids_list%,}]"
|
||||
|
||||
for _prefs_file in "$_DST/Default/Preferences" "$_DST/Default/Secure Preferences"; do
|
||||
if [[ -f "$_prefs_file" ]]; then
|
||||
python3 - "$_prefs_file" "$_py_ids_list" <<'PY' || \
|
||||
echo "prepare_chrome_profile: advertencia — no se pudieron purgar refs en $(basename "$_prefs_file")" >&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)
|
||||
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
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── emitir resultado JSON ─────────────────────────────────────────────────────
|
||||
_json_array() {
|
||||
# Convierte array bash en JSON array de strings
|
||||
local arr=("$@")
|
||||
local out="["
|
||||
local first=1
|
||||
for item in "${arr[@]}"; do
|
||||
if [[ $first -eq 1 ]]; then
|
||||
out+="\"$item\""
|
||||
first=0
|
||||
else
|
||||
out+=",\"$item\""
|
||||
fi
|
||||
done
|
||||
out+="]"
|
||||
echo "$out"
|
||||
}
|
||||
|
||||
_kept_json="$(_json_array "${_kept[@]+"${_kept[@]}"}")"
|
||||
_removed_json="$(_json_array "${_removed[@]+"${_removed[@]}"}")"
|
||||
|
||||
printf '{"dst":"%s","kept":%s,"removed":%s}\n' \
|
||||
"$_DST_REAL" \
|
||||
"$_kept_json" \
|
||||
"$_removed_json"
|
||||
@@ -1,93 +0,0 @@
|
||||
---
|
||||
name: restore_chrome_bookmarks
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "restore_chrome_bookmarks --backup-dir <ts-dir> [--user-data-dir <dir>] [--profile <name>]... [--dry-run]"
|
||||
description: "Restaura archivos Bookmarks de Chrome/Chromium desde un directorio de backup generado por backup_chrome_bookmarks hacia los perfiles destino en user-data-dir. Copia byte a byte con cp -p para preservar el checksum MD5 interno del archivo. Nunca parsea ni reserializa el JSON. Requiere que Chromium esté cerrado antes de ejecutar."
|
||||
tags: [navegator, chromium, bookmarks, restore, browser, scraping, profile]
|
||||
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/restore_chrome_bookmarks.sh"
|
||||
params:
|
||||
- name: --backup-dir
|
||||
desc: "Directorio de backup con timestamp generado por backup_chrome_bookmarks. Debe contener subdirectorios <profile>/Bookmarks. OBLIGATORIO."
|
||||
- name: --user-data-dir
|
||||
desc: "Ruta raíz del user-data-dir de Chrome/Chromium destino. Default: ~/.config/chromium"
|
||||
- name: --profile
|
||||
desc: "Nombre del perfil a restaurar (repetible, ej. Default, Profile 1). Si no se pasa ninguno se restauran TODOS los perfiles presentes en el backup-dir."
|
||||
- name: --dry-run
|
||||
desc: "Muestra qué archivos se copiarían y cuáles Bookmarks.bak se borrarían, sin tocar nada en disco."
|
||||
output: "JSON en stdout: {\"restored\": [{\"profile\": \"Default\", \"dst\": \"<path>\", \"bytes\": N}, ...]}. Exit 0 en éxito o dry-run. Errores a stderr con exit != 0."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# PASO 1 — cerrar Chromium (OBLIGATORIO en modo real)
|
||||
pkill -TERM chromium
|
||||
|
||||
# PASO 2 — restaurar todos los perfiles desde el backup más reciente
|
||||
source $HOME/fn_registry/bash/functions/browser/restore_chrome_bookmarks.sh
|
||||
restore_chrome_bookmarks \
|
||||
--user-data-dir "$HOME/.config/chromium" \
|
||||
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00"
|
||||
|
||||
# Restaurar solo un perfil concreto
|
||||
restore_chrome_bookmarks \
|
||||
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \
|
||||
--profile Default
|
||||
|
||||
# Restaurar dos perfiles específicos
|
||||
restore_chrome_bookmarks \
|
||||
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \
|
||||
--profile Default \
|
||||
--profile "Profile 1"
|
||||
|
||||
# Previsualizar sin tocar nada (no necesita Chromium cerrado)
|
||||
restore_chrome_bookmarks \
|
||||
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \
|
||||
--dry-run
|
||||
|
||||
# Salida esperada:
|
||||
# {"restored":[{"profile":"Default","dst":"/home/enmanuel/.config/chromium/Default/Bookmarks","bytes":12453}]}
|
||||
```
|
||||
|
||||
También ejecutable directamente con `fn run`:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
./fn run restore_chrome_bookmarks_bash_browser -- \
|
||||
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala después de una sesión de scraping o automatización que haya alterado los bookmarks, o para recuperar bookmarks tras formatear/recrear un perfil de Chromium. Combínala con `backup_chrome_bookmarks` (que genera el `--backup-dir` con la estructura esperada) para tener un ciclo completo de backup/restore. También útil para propagar bookmarks de un perfil o PC a otro.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Chromium DEBE estar cerrado** antes de ejecutar en modo real. Chromium mantiene los bookmarks en memoria y los reescribe al archivo `Bookmarks` al cerrar; si restauras con Chromium abierto, el proceso sobreescribirá tu restauración al cerrarse. La función lo comprueba con `pgrep -x chromium` y aborta con exit 2 si hay procesos vivos. En `--dry-run` este check se omite.
|
||||
- **Copia verbatim — nunca reserializar el JSON**. El archivo `Bookmarks` contiene un campo `checksum` con el MD5 del propio contenido JSON (calculado por Chromium internamente). Si se parsea y reserializa el JSON (aunque sea equivalente), el checksum queda inválido y Chromium descarta silenciosamente el archivo y regenera uno vacío. Esta función usa `cp -p` para garantizar que los bytes son idénticos al original.
|
||||
- **En Chromium 148 los bookmarks NO están bajo `super_mac` de Secure Preferences**. No es necesario tocar `Preferences` ni `Secure Preferences` al restaurar bookmarks (a diferencia de extensiones). La función solo opera sobre el archivo `Bookmarks`.
|
||||
- **`Bookmarks.bak` residual se borra**. Chromium crea `Bookmarks.bak` como copia de seguridad interna. Si existe antes de la restauración, esta función lo borra para que Chromium no lo use como fallback en lugar del archivo recién restaurado.
|
||||
- **El directorio destino del perfil se crea si no existe**. Si el perfil aún no tiene directorio en `user-data-dir`, se crea con `mkdir -p`. Chromium lo inicializará correctamente la primera vez que arranque con ese perfil.
|
||||
- **Opera por perfil**. Si no pasas `--profile`, restaura todos los perfiles presentes en el backup. Pasa `--profile` explícito para restaurar selectivamente y evitar sobreescribir perfiles sin querer.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Código | Significado |
|
||||
|--------|------------|
|
||||
| 0 | Éxito o dry-run completado |
|
||||
| 1 | Argumento inválido, backup-dir/user-data-dir no encontrado, o perfil no presente en backup |
|
||||
| 2 | Chromium está corriendo (solo en modo real) |
|
||||
@@ -1,172 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# restore_chrome_bookmarks — restaura archivos Bookmarks de un backup generado por
|
||||
# backup_chrome_bookmarks hacia los perfiles destino en user-data-dir.
|
||||
# Copia byte a byte con cp -p (nunca parsea ni reserializa el JSON).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
restore_chrome_bookmarks() {
|
||||
# ── defaults ──────────────────────────────────────────────────────────────
|
||||
local _user_data_dir="${HOME}/.config/chromium"
|
||||
local _backup_dir=""
|
||||
local _profiles=()
|
||||
local _dry_run=0
|
||||
|
||||
# ── parse args ────────────────────────────────────────────────────────────
|
||||
_usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage: restore_chrome_bookmarks --backup-dir <ts-dir>
|
||||
[--user-data-dir <dir>] [--profile <name>]... [--dry-run]
|
||||
|
||||
--user-data-dir Raíz de perfiles destino. Default: ~/.config/chromium
|
||||
--backup-dir Directorio de backup con timestamp generado por
|
||||
backup_chrome_bookmarks. Debe contener subdirectorios
|
||||
<profile>/Bookmarks. OBLIGATORIO.
|
||||
--profile <name> Perfil a restaurar (repetible). Si no se pasa ninguno
|
||||
se restauran TODOS los perfiles presentes en backup-dir.
|
||||
--dry-run Muestra qué se copiarí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 ;;
|
||||
--backup-dir) _backup_dir="$2"; shift 2 ;;
|
||||
--profile) _profiles+=("$2"); shift 2 ;;
|
||||
--dry-run) _dry_run=1; shift ;;
|
||||
-h|--help) _usage; return 0 ;;
|
||||
*) echo "restore_chrome_bookmarks: argumento desconocido: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── validaciones ──────────────────────────────────────────────────────────
|
||||
if [[ -z "$_backup_dir" ]]; then
|
||||
echo "restore_chrome_bookmarks: --backup-dir es obligatorio" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$_backup_dir" ]]; then
|
||||
echo "restore_chrome_bookmarks: backup-dir no encontrado: ${_backup_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$_user_data_dir" ]]; then
|
||||
echo "restore_chrome_bookmarks: user-data-dir no encontrado: ${_user_data_dir}" >&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`: el path del udd contiene "chromium"
|
||||
# (~/.config/chromium-cdp), así que un `pgrep -af '[c]hromium' | grep <udd>` se detecta a sí mismo.
|
||||
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 "restore_chrome_bookmarks: hay un chromium con este user-data-dir abierto — ciérralo antes de restaurar:" >&2
|
||||
echo " pkill -TERM chromium" >&2
|
||||
echo "(Chromium reescribe Bookmarks desde memoria al cerrar y desharía la restauración)" >&2
|
||||
return 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── determinar perfiles a restaurar ───────────────────────────────────────
|
||||
local _target_profiles=()
|
||||
|
||||
if [[ ${#_profiles[@]} -gt 0 ]]; then
|
||||
# Perfiles explícitos: verificar que existen en el backup
|
||||
local _p
|
||||
for _p in "${_profiles[@]}"; do
|
||||
if [[ ! -f "${_backup_dir}/${_p}/Bookmarks" ]]; then
|
||||
echo "restore_chrome_bookmarks: backup no contiene perfil '${_p}': ${_backup_dir}/${_p}/Bookmarks" >&2
|
||||
return 1
|
||||
fi
|
||||
_target_profiles+=("$_p")
|
||||
done
|
||||
else
|
||||
# Autodescubrir todos los perfiles en el backup
|
||||
local _profile_path
|
||||
while IFS= read -r -d '' _profile_path; do
|
||||
local _pname
|
||||
_pname="$(basename "$(dirname "$_profile_path")")"
|
||||
_target_profiles+=("$_pname")
|
||||
done < <(find "$_backup_dir" -mindepth 2 -maxdepth 2 -name "Bookmarks" -print0 | sort -z)
|
||||
|
||||
if [[ ${#_target_profiles[@]} -eq 0 ]]; then
|
||||
echo "restore_chrome_bookmarks: no se encontraron archivos Bookmarks en: ${_backup_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── restaurar cada perfil ─────────────────────────────────────────────────
|
||||
local _restored_json=""
|
||||
local _first=1
|
||||
|
||||
local _prof
|
||||
for _prof in "${_target_profiles[@]}"; do
|
||||
local _src="${_backup_dir}/${_prof}/Bookmarks"
|
||||
local _dst_dir="${_user_data_dir}/${_prof}"
|
||||
local _dst="${_dst_dir}/Bookmarks"
|
||||
local _dst_bak="${_dst_dir}/Bookmarks.bak"
|
||||
|
||||
# Tamaño del archivo fuente para el JSON de salida
|
||||
local _bytes=0
|
||||
if [[ -f "$_src" ]]; then
|
||||
_bytes="$(wc -c < "$_src")"
|
||||
# Eliminar espacios que wc puede añadir en algunas plataformas
|
||||
_bytes="${_bytes// /}"
|
||||
fi
|
||||
|
||||
if [[ $_dry_run -eq 1 ]]; then
|
||||
echo "=== restore_chrome_bookmarks DRY-RUN ===" >&2
|
||||
echo " Perfil : ${_prof}" >&2
|
||||
echo " src : ${_src}" >&2
|
||||
echo " dst : ${_dst}" >&2
|
||||
echo " bytes : ${_bytes}" >&2
|
||||
if [[ -f "$_dst_bak" ]]; then
|
||||
echo " .bak : borraría ${_dst_bak}" >&2
|
||||
fi
|
||||
else
|
||||
# Crear directorio destino si no existe
|
||||
mkdir -p "$_dst_dir"
|
||||
|
||||
# Copiar byte a byte preservando timestamps (NUNCA reserializar)
|
||||
cp -p "$_src" "$_dst"
|
||||
|
||||
# Borrar Bookmarks.bak residual si existe
|
||||
if [[ -f "$_dst_bak" ]]; then
|
||||
rm -f "$_dst_bak"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Construir fragmento JSON para este perfil
|
||||
local _entry
|
||||
_entry="$(printf '{"profile":"%s","dst":"%s","bytes":%s}' \
|
||||
"$_prof" "$_dst" "$_bytes")"
|
||||
|
||||
if [[ $_first -eq 1 ]]; then
|
||||
_restored_json="${_entry}"
|
||||
_first=0
|
||||
else
|
||||
_restored_json+=",$_entry"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── emitir resultado JSON ─────────────────────────────────────────────────
|
||||
printf '{"restored":[%s]}\n' "$_restored_json"
|
||||
}
|
||||
|
||||
# ── auto-ejecución ────────────────────────────────────────────────────────────
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
restore_chrome_bookmarks "$@"
|
||||
fi
|
||||
@@ -1,100 +0,0 @@
|
||||
---
|
||||
name: set_chrome_profile_appearance
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "set_chrome_profile_appearance --user-data-dir <dir> --profile <dir-name> [--avatar <N|ruta.png>] [--color <#rrggbb>] [--variant <0..4>] [--dry-run]"
|
||||
description: "Personaliza la apariencia visual de un perfil Chrome/Chromium existente: asigna un avatar built-in (índice 0..55) o una imagen PNG/JPG custom, y/o un color de acento (hex #rrggbb). Con --color aplica el tinte tanto al círculo del avatar en Local State (profile_highlight_color, profile_color_seed, default_avatar_fill_color) como al tema completo del navegador en el Preferences del perfil (browser.theme.user_color2, browser_color_variant, extensions.theme.system_theme), tiñendo toolbar, frame, barra de pestañas y omnibox. Requiere que Chromium esté cerrado sobre el user-data-dir. Hace backup de Local State y Preferences antes de escribir y valida el JSON resultante."
|
||||
tags: [navegator, chromium, profile, browser, cdp, scraping, appearance, avatar, color]
|
||||
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/set_chrome_profile_appearance.sh"
|
||||
params:
|
||||
- name: --user-data-dir
|
||||
desc: "Raíz del user-data-dir de Chrome/Chromium donde vive el perfil. El directorio y Local State deben existir. Obligatorio."
|
||||
- name: --profile
|
||||
desc: "Nombre de la carpeta del perfil dentro de user-data-dir, por ejemplo: Default, Automation, \"Profile 1\". El perfil debe existir previamente en info_cache de Local State. Obligatorio."
|
||||
- name: --avatar
|
||||
desc: "Índice entero 0..55 del avatar built-in de Chrome (56 avatares: animales, objetos, personas) o ruta absoluta/relativa a un archivo PNG/JPG para avatar custom. Con índice: sets avatar_icon=IDR_PROFILE_AVATAR_<N> e is_using_default_avatar=true. Con imagen: copia el archivo al perfil como 'Google Profile Picture.png' y sets is_using_default_avatar=false. Opcional; al menos uno de --avatar o --color debe darse."
|
||||
- name: --color
|
||||
desc: "Color de acento del perfil en hex #rrggbb, con o sin el '#' inicial. Se convierte a int32 con signo en formato ARGB 0xFFRRGGBB. Aplica el color en dos lugares: (1) Local State info_cache (profile_highlight_color, profile_color_seed, default_avatar_fill_color) para el círculo del avatar; (2) Preferences del perfil (browser.theme.user_color2 + browser_color_variant + extensions.theme.system_theme=0) para teñir toolbar, frame, barra de pestañas y omnibox. Opcional; al menos uno de --avatar o --color debe darse."
|
||||
- name: --variant
|
||||
desc: "Intensidad del tema de color aplicado al navegador (browser_color_variant). Entero 0..4: 0=system, 1=tonal_spot, 2=neutral, 3=vibrant (default), 4=expressive. Valores más altos dan tintes más saturados e identificables. Solo tiene efecto cuando se usa --color. Opcional."
|
||||
- name: --dry-run
|
||||
desc: "Describe las acciones que se ejecutarían (campos a modificar en Local State y Preferences, conversión de color, ruta del Preferences) sin escribir nada ni verificar si Chromium está corriendo. Emite JSON de resultado con dry_run:true."
|
||||
output: "JSON en stdout con los campos resultantes del perfil: {\"profile\":\"<dir>\",\"avatar_icon\":\"...\",\"is_using_default_avatar\":true|false,\"profile_highlight_color\":<int>,\"profile_color_seed\":<int>,\"default_avatar_fill_color\":<int>,\"theme_applied\":true|false,\"variant\":<int>,\"preferences_path\":\"...\",\"browser_theme_user_color2\":<int>,\"browser_theme_color_variant\":<int>,\"extensions_theme_system_theme\":<int>,\"backup\":\"Local State.bak.YYYYMMDD\"}. En dry-run: {\"profile\":\"...\",\"avatar_applied\":true|false,\"color_applied\":true|false,\"theme_applied\":true|false,\"variant\":<int>,\"dry_run\":true}. Mensajes de diagnóstico a stderr. Exit 0 en éxito."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source $HOME/fn_registry/bash/functions/browser/set_chrome_profile_appearance.sh
|
||||
|
||||
# Asignar avatar #30 y tinte verde a toolbar/frame/omnibox del perfil Automation
|
||||
# (verde #16a34a tiñe toda la chrome del navegador, no solo el círculo del avatar)
|
||||
set_chrome_profile_appearance \
|
||||
--user-data-dir ~/.config/chromium-cdp \
|
||||
--profile Automation \
|
||||
--avatar 30 \
|
||||
--color "#16a34a"
|
||||
# Salida JSON incluye: theme_applied:true, variant:3, browser_theme_user_color2:-15293622
|
||||
|
||||
# Color con intensidad personalizada (expressive = máxima saturación)
|
||||
set_chrome_profile_appearance \
|
||||
--user-data-dir ~/.config/chromium-cdp \
|
||||
--profile Scraping \
|
||||
--color "#1f6feb" \
|
||||
--variant 4
|
||||
|
||||
# Solo cambiar avatar (no toca Preferences del perfil)
|
||||
set_chrome_profile_appearance \
|
||||
--user-data-dir ~/.config/chromium-cdp \
|
||||
--profile "Profile 1" \
|
||||
--avatar 5
|
||||
|
||||
# Dry-run: ver qué se aplicaría en Local State y Preferences sin escribir
|
||||
set_chrome_profile_appearance \
|
||||
--user-data-dir ~/.config/chromium-cdp \
|
||||
--profile Automation \
|
||||
--avatar 30 \
|
||||
--color "#16a34a" \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala para diferenciar visualmente los perfiles de un user-data-dir de automatización — un color y avatar distintos por perfil hacen inmediata la identificación en el selector de Chrome Y en la chrome del navegador (toolbar/frame visible mientras navega). Ejecútala justo después de `create_chrome_profile` (con `--no-launch`) o como paso independiente de personalización batch antes de lanzar sesiones CDP. Si solo quieres teñir el círculo del avatar (sin el tema), basta esta función; si quieres el tinte completo del navegador (lo más identificable), pasa `--color`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Chromium debe estar cerrado**: Chrome reescribe `Local State` y `Preferences` completos desde memoria al cerrar; si se ejecuta mientras hay un proceso chromium vivo sobre el mismo user-data-dir, Chrome sobreescribirá los cambios al salir. La función detecta esto con `pgrep -x chromium` filtrando por cmdline y sale con exit 2 antes de modificar nada. Usa `pkill -TERM chromium` para cerrar y espera unos segundos.
|
||||
- **El tema se escribe en Preferences del perfil, distinto de Local State**: los cambios de color al avatar van en `<user-data-dir>/Local State` (global a todos los perfiles); los cambios de tema del navegador van en `<user-data-dir>/<profile_dir>/Preferences` (específico de cada perfil). La función hace backup de ambos archivos por separado antes de tocarlos.
|
||||
- **El perfil debe existir en info_cache**: esta función personaliza perfiles existentes; no los crea. Usa `create_chrome_profile` primero (con `--no-launch` basta para que aparezca en Local State) y luego `set_chrome_profile_appearance`.
|
||||
- **color es int32 con signo en ARGB**: Chrome almacena el color como entero con signo de 32 bits en formato `0xAARRGGBB`. Un color como `#16a34a` (verde) da ARGB `0xFF16A34A` → signed int32 `-15293622`. La función hace la conversión internamente; tú pasas siempre hex `#rrggbb`.
|
||||
- **En modo oscuro del sistema el tinte sale más apagado**: en temas oscuros del sistema el color se mezcla con el fondo oscuro y queda menos saturado. Para compensar, usa `--variant 3` (vibrant, default) o `--variant 4` (expressive); valores bajos como 1 o 2 pueden resultar casi imperceptibles en modo oscuro.
|
||||
- **`extensions.theme.system_theme` se fuerza a 0**: si el perfil usaba el tema GTK del sistema (`system_theme=1`), el GTK puede ignorar el `user_color`. Esta función lo fuerza a 0 (tema propio de Chrome) para que el `user_color2` tenga efecto. Si quieres devolver el perfil al tema del sistema, tendrás que resetear `system_theme` manualmente.
|
||||
- **Avatar custom (imagen) es best-effort**: el campo `gaia_picture_file_name` y `is_using_default_avatar=false` se aplican correctamente en Local State y la imagen se copia al directorio del perfil. Sin embargo, Chrome puede ignorar la foto de perfil en perfiles sin sesión Google activa (Chromium sin cuenta). El camino robusto y garantizado es usar el índice built-in (`--avatar 0..55`): 56 avatares (animales, objetos, personas) son más que suficientes para diferenciar perfiles de automatización.
|
||||
- **Backup diario**: se crea `Local State.bak.YYYYMMDD` y `Preferences.bak.YYYYMMDD` antes de cualquier escritura. Si ya existen los backups del día no se sobreescriben. Si el JSON resultante es inválido, se restaura automáticamente el backup correspondiente.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Código | Significado |
|
||||
|--------|------------|
|
||||
| 0 | Éxito |
|
||||
| 1 | Argumento obligatorio faltante, rango inválido o archivo de imagen no encontrado |
|
||||
| 2 | Lock: hay un chromium usando el mismo user-data-dir |
|
||||
| 3 | El perfil no existe en info_cache de Local State |
|
||||
| 4 | Error editando Local State o Preferences (JSON inválido tras escritura, restaurado backup) |
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.1.0 (2026-06-06) — --color ahora aplica también el tema del navegador (toolbar/frame/omnibox) escribiendo browser.theme.user_color2 + browser_color_variant en el Preferences del perfil, no solo el color del avatar en Local State. Nuevo flag --variant (0..4, default 3 vibrant). Verificado con captura en Chromium 148.
|
||||
@@ -1,426 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# set_chrome_profile_appearance — personaliza la apariencia visual de un perfil
|
||||
# Chrome/Chromium existente: asigna un avatar built-in (índice 0..55) o una imagen
|
||||
# PNG/JPG custom, y/o un color de acento (hex #rrggbb). Edita Local State Y el
|
||||
# Preferences del perfil (browser.theme.* para teñir toolbar/frame/omnibox).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
set_chrome_profile_appearance() {
|
||||
# ── defaults ──────────────────────────────────────────────────────────────
|
||||
local _udd=""
|
||||
local _profile_dir=""
|
||||
local _avatar=""
|
||||
local _color=""
|
||||
local _variant=3
|
||||
local _dry_run=0
|
||||
|
||||
# ── parse args ─────────────────────────────────────────────────────────────
|
||||
_usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage: set_chrome_profile_appearance --user-data-dir <dir> --profile <dir-name>
|
||||
[--avatar <N|ruta.png>] [--color <#rrggbb>] [--variant <0..4>] [--dry-run]
|
||||
|
||||
--user-data-dir Raíz del user-data-dir de Chrome/Chromium (obligatorio).
|
||||
--profile Nombre de la carpeta del perfil, ej: Default, Automation,
|
||||
"Profile 1" (obligatorio). El perfil debe existir.
|
||||
--avatar Índice entero 0..55 del avatar built-in de Chrome, o ruta a
|
||||
un archivo PNG/JPG para avatar custom (opcional).
|
||||
--color Color de acento del perfil en formato hex #rrggbb, con o sin
|
||||
el '#' inicial (opcional). Aplica el color tanto al círculo
|
||||
del avatar (Local State) como al tema del navegador
|
||||
(toolbar/frame/omnibox via Preferences del perfil).
|
||||
--variant Intensidad del tema de color: 0=system, 1=tonal_spot,
|
||||
2=neutral, 3=vibrant (default), 4=expressive. Solo tiene
|
||||
efecto cuando se usa --color.
|
||||
--dry-run Describe las acciones sin modificar nada.
|
||||
|
||||
Al menos uno de --avatar o --color debe indicarse.
|
||||
|
||||
Exit codes:
|
||||
0 éxito
|
||||
1 error de argumento o validación
|
||||
2 lock: hay un chromium corriendo con este user-data-dir
|
||||
3 el perfil no existe en info_cache de Local State
|
||||
4 error editando Local State o Preferences (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 ;;
|
||||
--avatar) _avatar="$2"; shift 2 ;;
|
||||
--color) _color="$2"; shift 2 ;;
|
||||
--variant) _variant="$2"; shift 2 ;;
|
||||
--dry-run) _dry_run=1; shift ;;
|
||||
-h|--help) _usage; return 0 ;;
|
||||
*) echo "set_chrome_profile_appearance: argumento desconocido: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── validaciones obligatorias ──────────────────────────────────────────────
|
||||
if [[ -z "$_udd" ]]; then
|
||||
echo "set_chrome_profile_appearance: --user-data-dir es obligatorio" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$_profile_dir" ]]; then
|
||||
echo "set_chrome_profile_appearance: --profile es obligatorio" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$_avatar" && -z "$_color" ]]; then
|
||||
echo "set_chrome_profile_appearance: al menos --avatar o --color debe indicarse" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validar --variant
|
||||
if ! [[ "$_variant" =~ ^[0-4]$ ]]; then
|
||||
echo "set_chrome_profile_appearance: --variant debe ser un entero 0..4, recibido: ${_variant}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Expandir ~ en el user-data-dir
|
||||
_udd="${_udd/#\~/$HOME}"
|
||||
|
||||
local _local_state="${_udd}/Local State"
|
||||
|
||||
# Verificar que user-data-dir y Local State existen
|
||||
if [[ ! -d "$_udd" ]]; then
|
||||
echo "set_chrome_profile_appearance: user-data-dir no encontrado: ${_udd}" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -f "$_local_state" ]]; then
|
||||
echo "set_chrome_profile_appearance: Local State no encontrado: ${_local_state}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── validar --avatar ──────────────────────────────────────────────────────
|
||||
local _avatar_index=-1
|
||||
local _avatar_image_path=""
|
||||
|
||||
if [[ -n "$_avatar" ]]; then
|
||||
if [[ "$_avatar" =~ ^[0-9]+$ ]]; then
|
||||
# Índice built-in
|
||||
_avatar_index=$(( _avatar ))
|
||||
if [[ $_avatar_index -lt 0 || $_avatar_index -gt 55 ]]; then
|
||||
echo "set_chrome_profile_appearance: índice de avatar fuera de rango (0..55): ${_avatar}" >&2
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
# Ruta a imagen custom
|
||||
local _img_path="${_avatar/#\~/$HOME}"
|
||||
if [[ ! -f "$_img_path" ]]; then
|
||||
echo "set_chrome_profile_appearance: archivo de imagen no encontrado: ${_img_path}" >&2
|
||||
return 1
|
||||
fi
|
||||
_avatar_image_path="$_img_path"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── validar --color ───────────────────────────────────────────────────────
|
||||
local _color_hex=""
|
||||
if [[ -n "$_color" ]]; then
|
||||
_color_hex="${_color/#\#/}" # quitar # inicial si lo hay
|
||||
if ! [[ "$_color_hex" =~ ^[0-9a-fA-F]{6}$ ]]; then
|
||||
echo "set_chrome_profile_appearance: color hex inválido (espera rrggbb): ${_color}" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── guard: ningún chromium debe tener ESTE user-data-dir abierto ──────────
|
||||
# pgrep -x chromium lista solo procesos cuyo comm es exactamente "chromium",
|
||||
# nunca grep/pgrep/bash. Así evitamos auto-matchear el propio script cuando
|
||||
# el path del udd contiene "chromium" (p.ej. ~/.config/chromium-cdp).
|
||||
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 -- "$_udd"; then
|
||||
_busy=1; break
|
||||
fi
|
||||
done
|
||||
if [[ $_busy -eq 1 ]]; then
|
||||
echo "set_chrome_profile_appearance: hay un chromium corriendo con este user-data-dir — ciérralo primero:" >&2
|
||||
echo " pkill -TERM chromium" >&2
|
||||
echo " (Chrome reescribe Local State y Preferences al cerrar y pierde los cambios)" >&2
|
||||
return 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── verificar que el perfil existe en info_cache ──────────────────────────
|
||||
if [[ $_dry_run -eq 0 ]]; then
|
||||
local _profile_exists
|
||||
_profile_exists="$(python3 -c "
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1]))
|
||||
ic = data.get('profile', {}).get('info_cache', {})
|
||||
print('yes' if sys.argv[2] in ic else 'no')
|
||||
" "$_local_state" "$_profile_dir" 2>/dev/null || echo "no")"
|
||||
if [[ "$_profile_exists" != "yes" ]]; then
|
||||
echo "set_chrome_profile_appearance: perfil '${_profile_dir}' no existe en info_cache de Local State" >&2
|
||||
echo " Perfiles disponibles:" >&2
|
||||
python3 -c "
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1]))
|
||||
ic = data.get('profile', {}).get('info_cache', {})
|
||||
for k in ic: print(' ', k)
|
||||
" "$_local_state" >&2 2>/dev/null || true
|
||||
return 3
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── modo dry-run ──────────────────────────────────────────────────────────
|
||||
if [[ $_dry_run -eq 1 ]]; then
|
||||
echo "=== set_chrome_profile_appearance DRY-RUN ===" >&2
|
||||
echo " user-data-dir : ${_udd}" >&2
|
||||
echo " profile : ${_profile_dir}" >&2
|
||||
if [[ $_avatar_index -ge 0 ]]; then
|
||||
echo " avatar : built-in #${_avatar_index} → avatar_icon=chrome://theme/IDR_PROFILE_AVATAR_${_avatar_index}" >&2
|
||||
echo " is_using_default_avatar=true" >&2
|
||||
elif [[ -n "$_avatar_image_path" ]]; then
|
||||
local _dest_img="${_udd}/${_profile_dir}/Google Profile Picture.png"
|
||||
echo " avatar : imagen custom ${_avatar_image_path}" >&2
|
||||
echo " copiaría a ${_dest_img}" >&2
|
||||
echo " is_using_default_avatar=false" >&2
|
||||
echo " gaia_picture_file_name=Google Profile Picture.png" >&2
|
||||
fi
|
||||
if [[ -n "$_color_hex" ]]; then
|
||||
local _signed_preview
|
||||
_signed_preview="$(python3 -c "
|
||||
rgb = int('${_color_hex}', 16)
|
||||
argb = 0xFF000000 | rgb
|
||||
signed = argb - 0x100000000 if argb >= 0x80000000 else argb
|
||||
print(signed)
|
||||
" 2>/dev/null || echo '?')"
|
||||
echo " color : #${_color_hex} → signed int32 ${_signed_preview}" >&2
|
||||
echo " Local State: profile_highlight_color, profile_color_seed, default_avatar_fill_color" >&2
|
||||
echo " Preferences: browser.theme.user_color2=${_signed_preview}, browser_color_variant=${_variant}, is_grayscale2=false" >&2
|
||||
echo " Preferences: extensions.theme.system_theme=0" >&2
|
||||
local _prefs_path="${_udd}/${_profile_dir}/Preferences"
|
||||
echo " Preferences : ${_prefs_path}" >&2
|
||||
fi
|
||||
echo " Local State : ${_local_state}" >&2
|
||||
printf '{"profile":"%s","avatar_applied":%s,"color_applied":%s,"theme_applied":%s,"variant":%d,"dry_run":true}\n' \
|
||||
"$_profile_dir" \
|
||||
"$([[ -n "$_avatar" ]] && echo 'true' || echo 'false')" \
|
||||
"$([[ -n "$_color_hex" ]] && echo 'true' || echo 'false')" \
|
||||
"$([[ -n "$_color_hex" ]] && echo 'true' || echo 'false')" \
|
||||
"$_variant"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── backup de Local State (no sobreescribir el del mismo día) ────────────
|
||||
local _today
|
||||
_today="$(date +%Y%m%d)"
|
||||
local _backup="${_local_state}.bak.${_today}"
|
||||
if [[ ! -f "$_backup" ]]; then
|
||||
cp "$_local_state" "$_backup"
|
||||
fi
|
||||
|
||||
# ── copiar imagen custom si es necesario ──────────────────────────────────
|
||||
local _copy_image_done=false
|
||||
if [[ -n "$_avatar_image_path" ]]; then
|
||||
local _profile_path="${_udd}/${_profile_dir}"
|
||||
mkdir -p "$_profile_path"
|
||||
cp "$_avatar_image_path" "${_profile_path}/Google Profile Picture.png"
|
||||
_copy_image_done=true
|
||||
fi
|
||||
|
||||
# ── editar Local State con python3 ────────────────────────────────────────
|
||||
if ! python3 - \
|
||||
"$_local_state" \
|
||||
"$_profile_dir" \
|
||||
"${_avatar_index}" \
|
||||
"${_avatar_image_path}" \
|
||||
"${_color_hex}" <<'PY'; then
|
||||
import sys, json
|
||||
|
||||
ls_path = sys.argv[1]
|
||||
prof_dir = sys.argv[2]
|
||||
avatar_index = int(sys.argv[3]) # -1 = no cambiar avatar
|
||||
avatar_img = sys.argv[4] # "" = no usar imagen
|
||||
color_hex = sys.argv[5] # "" = no cambiar color
|
||||
|
||||
with open(ls_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
profile_section = data.setdefault("profile", {})
|
||||
info_cache = profile_section.setdefault("info_cache", {})
|
||||
|
||||
# El perfil debe existir (ya validado en bash, pero doble check)
|
||||
if prof_dir not in info_cache:
|
||||
print(f"error: perfil '{prof_dir}' no existe en info_cache", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
entry = info_cache[prof_dir]
|
||||
|
||||
# ── Avatar ────────────────────────────────────────────────────────────────────
|
||||
if avatar_index >= 0:
|
||||
# Avatar built-in: IDR_PROFILE_AVATAR_<N>
|
||||
entry["avatar_icon"] = f"chrome://theme/IDR_PROFILE_AVATAR_{avatar_index}"
|
||||
entry["is_using_default_avatar"] = True
|
||||
elif avatar_img:
|
||||
# Avatar custom imagen: Chrome necesita gaia_picture_file_name
|
||||
entry["avatar_icon"] = "chrome://theme/IDR_PROFILE_AVATAR_0"
|
||||
entry["is_using_default_avatar"] = False
|
||||
entry["gaia_picture_file_name"] = "Google Profile Picture.png"
|
||||
|
||||
# ── Color ─────────────────────────────────────────────────────────────────────
|
||||
if color_hex:
|
||||
rgb = int(color_hex, 16) # 0xRRGGBB
|
||||
argb = 0xFF000000 | rgb # alpha=FF opaco → 0xFFRRGGBB
|
||||
# Convertir a int32 con signo (Python usa enteros arbitrarios)
|
||||
signed = argb - 0x100000000 if argb >= 0x80000000 else argb
|
||||
|
||||
entry["profile_highlight_color"] = signed
|
||||
entry["profile_color_seed"] = signed
|
||||
entry["default_avatar_fill_color"] = signed
|
||||
|
||||
with open(ls_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, separators=(",", ":"))
|
||||
PY
|
||||
echo "set_chrome_profile_appearance: error editando Local State con python3" >&2
|
||||
return 4
|
||||
fi
|
||||
|
||||
# ── validar JSON de Local State tras escritura ────────────────────────────
|
||||
if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$_local_state" 2>/dev/null; then
|
||||
echo "set_chrome_profile_appearance: JSON inválido tras escribir Local State; restaurando backup" >&2
|
||||
cp "$_backup" "$_local_state"
|
||||
return 4
|
||||
fi
|
||||
|
||||
# ── editar Preferences del perfil (browser.theme.*) si hay color ─────────
|
||||
local _prefs_path="${_udd}/${_profile_dir}/Preferences"
|
||||
local _prefs_backup=""
|
||||
local _theme_applied=false
|
||||
|
||||
if [[ -n "$_color_hex" ]]; then
|
||||
_theme_applied=true
|
||||
|
||||
# Backup de Preferences antes de escribir (mismo patrón que Local State)
|
||||
if [[ -f "$_prefs_path" ]]; then
|
||||
_prefs_backup="${_prefs_path}.bak.${_today}"
|
||||
if [[ ! -f "$_prefs_backup" ]]; then
|
||||
cp "$_prefs_path" "$_prefs_backup"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Editar/crear Preferences con python3
|
||||
if ! python3 - \
|
||||
"$_prefs_path" \
|
||||
"${_color_hex}" \
|
||||
"${_variant}" <<'PY'; then
|
||||
import sys, json, os
|
||||
|
||||
prefs_path = sys.argv[1]
|
||||
color_hex = sys.argv[2]
|
||||
variant = int(sys.argv[3])
|
||||
|
||||
# Calcular el signed int32 ARGB
|
||||
rgb = int(color_hex, 16)
|
||||
argb = 0xFF000000 | rgb
|
||||
signed = argb - 0x100000000 if argb >= 0x80000000 else argb
|
||||
|
||||
# Cargar Preferences existente o arrancar desde vacío
|
||||
if os.path.isfile(prefs_path):
|
||||
with open(prefs_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
else:
|
||||
data = {}
|
||||
|
||||
# ── browser.theme.* ──────────────────────────────────────────────────────────
|
||||
browser = data.setdefault("browser", {})
|
||||
theme = browser.setdefault("theme", {})
|
||||
|
||||
# Claves modernas (sufijo "2") — verificadas en Chromium 148
|
||||
theme["user_color2"] = signed
|
||||
theme["browser_color_variant"] = variant
|
||||
theme["is_grayscale2"] = False
|
||||
|
||||
# Claves legacy (sin sufijo "2") — compatibilidad con versiones anteriores
|
||||
theme["user_color"] = signed
|
||||
theme["color_variant"] = variant
|
||||
theme["is_grayscale"] = False
|
||||
|
||||
# ── extensions.theme.system_theme = 0 ────────────────────────────────────────
|
||||
# 0=color propio, 1=GTK, 2=Qt. Forzar 0 para que el user_color tenga efecto.
|
||||
extensions = data.setdefault("extensions", {})
|
||||
ext_theme = extensions.setdefault("theme", {})
|
||||
ext_theme["system_theme"] = 0
|
||||
|
||||
# Escribir directorio si no existe (perfil recién creado sin arrancar)
|
||||
os.makedirs(os.path.dirname(prefs_path), exist_ok=True)
|
||||
|
||||
with open(prefs_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, separators=(",", ":"))
|
||||
PY
|
||||
echo "set_chrome_profile_appearance: error editando Preferences con python3" >&2
|
||||
# Restaurar Preferences si teníamos backup
|
||||
if [[ -n "$_prefs_backup" && -f "$_prefs_backup" ]]; then
|
||||
cp "$_prefs_backup" "$_prefs_path"
|
||||
elif [[ -f "$_prefs_path" ]]; then
|
||||
rm -f "$_prefs_path"
|
||||
fi
|
||||
return 4
|
||||
fi
|
||||
|
||||
# Validar JSON de Preferences tras escritura
|
||||
if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$_prefs_path" 2>/dev/null; then
|
||||
echo "set_chrome_profile_appearance: JSON inválido tras escribir Preferences; restaurando backup" >&2
|
||||
if [[ -n "$_prefs_backup" && -f "$_prefs_backup" ]]; then
|
||||
cp "$_prefs_backup" "$_prefs_path"
|
||||
fi
|
||||
return 4
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── leer valores resultantes para el JSON de salida ───────────────────────
|
||||
local _result_json
|
||||
_result_json="$(python3 - "$_local_state" "$_profile_dir" "$_prefs_path" "$_theme_applied" "$_variant" <<'PY'
|
||||
import json, sys, os
|
||||
|
||||
ls_path = sys.argv[1]
|
||||
prof_dir = sys.argv[2]
|
||||
prefs_path = sys.argv[3]
|
||||
theme_applied = sys.argv[4] == "true"
|
||||
variant = int(sys.argv[5])
|
||||
|
||||
data = json.load(open(ls_path))
|
||||
entry = data.get("profile", {}).get("info_cache", {}).get(prof_dir, {})
|
||||
|
||||
out = {
|
||||
"profile": prof_dir,
|
||||
"avatar_icon": entry.get("avatar_icon", ""),
|
||||
"is_using_default_avatar": entry.get("is_using_default_avatar", True),
|
||||
"profile_highlight_color": entry.get("profile_highlight_color", 0),
|
||||
"profile_color_seed": entry.get("profile_color_seed", 0),
|
||||
"default_avatar_fill_color": entry.get("default_avatar_fill_color", 0),
|
||||
"theme_applied": theme_applied,
|
||||
"variant": variant,
|
||||
"preferences_path": prefs_path if theme_applied else "",
|
||||
"backup": "Local State.bak." + __import__("datetime").date.today().strftime("%Y%m%d"),
|
||||
}
|
||||
|
||||
# Añadir valores de theme si se aplicó
|
||||
if theme_applied and os.path.isfile(prefs_path):
|
||||
try:
|
||||
prefs = json.load(open(prefs_path))
|
||||
bt = prefs.get("browser", {}).get("theme", {})
|
||||
out["browser_theme_user_color2"] = bt.get("user_color2", 0)
|
||||
out["browser_theme_color_variant"] = bt.get("browser_color_variant", 0)
|
||||
out["extensions_theme_system_theme"] = prefs.get("extensions", {}).get("theme", {}).get("system_theme", -1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(json.dumps(out, separators=(",",":")))
|
||||
PY
|
||||
)"
|
||||
|
||||
echo "$_result_json"
|
||||
}
|
||||
|
||||
# ── auto-ejecución ────────────────────────────────────────────────────────────
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
set_chrome_profile_appearance "$@"
|
||||
fi
|
||||
@@ -1,88 +0,0 @@
|
||||
---
|
||||
name: query_mitm_flows
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "query_mitm_flows(file_or_glob: string, filter?: string, har?: string, mitmdump?: string) -> void"
|
||||
description: "Consulta capturas .mitm guardadas con mitmproxy: vuelca los flujos que coinciden con un filtro de mitmproxy a stdout, o exporta la captura a HAR. Acepta uno o varios archivos .mitm (incluyendo globs expandidos por el shell). Autodetecta mitmdump en PATH y $HOME/.local/bin."
|
||||
tags: [bash, cybersecurity, mitmproxy, web-proxy, query, har, capture, traffic, proxy, network]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: file_or_glob
|
||||
desc: "Ruta a un archivo .mitm o glob expandido por el shell (ej. ~/captures/traffic-*.mitm). Acepta múltiples archivos como argumentos posicionales."
|
||||
- name: filter
|
||||
desc: "Expresión de filtro mitmproxy (ej. '~m POST & ~u /api', '~c 500', '~d example.com'). Si se omite, vuelca todos los flujos."
|
||||
- name: har
|
||||
desc: "Ruta de salida para exportar en formato HAR (--set hardump=OUT). Si se omite, el volcado va a stdout."
|
||||
- name: mitmdump
|
||||
desc: "Ruta al binario mitmdump. Si se omite, autodetecta en PATH y luego en $HOME/.local/bin/mitmdump."
|
||||
output: "Vuelca los flujos capturados a stdout (modo default) o exporta a un archivo HAR (con --har); informa la ruta de exportación en stderr al terminar. Exit code de mitmdump propagado."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/cybersecurity/query_mitm_flows.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/cybersecurity/query_mitm_flows.sh
|
||||
|
||||
# Volcar todos los flujos de una captura
|
||||
query_mitm_flows ~/captures/traffic-20260602.mitm
|
||||
|
||||
# Filtrar solo peticiones POST a /api (glob expandido por el shell)
|
||||
query_mitm_flows ~/captures/traffic-20260602-*.mitm --filter "~m POST & ~u /api"
|
||||
|
||||
# Ver solo respuestas con código 500
|
||||
query_mitm_flows session.mitm --filter "~c 500"
|
||||
|
||||
# Exportar a HAR para abrir en el Network tab del browser DevTools
|
||||
query_mitm_flows ~/captures/traffic-20260602.mitm --har salida.har
|
||||
|
||||
# Exportar a HAR con filtro aplicado
|
||||
query_mitm_flows session.mitm --filter "~d example.com" --har example_flows.har
|
||||
|
||||
# Especificar mitmdump manualmente (ej. en un venv de Python)
|
||||
query_mitm_flows session.mitm --mitmdump ~/.venv/bin/mitmdump --filter "~m POST"
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando hayas capturado tráfico HTTP/HTTPS con mitmproxy y necesites consultarlo después sin abrir una interfaz interactiva: filtrar flujos específicos por método, dominio, URL o código de respuesta, o exportar la captura a HAR para análisis posterior en herramientas externas (browser DevTools, Insomnia, Postman). Úsala desde scripts de análisis automatizados o pipelines de revisión de seguridad.
|
||||
|
||||
## Gotchas
|
||||
|
||||
**Sintaxis de filtros mitmproxy** — los filtros se pasan como expresión entre comillas:
|
||||
|
||||
| Operador | Significado | Ejemplo |
|
||||
|---|---|---|
|
||||
| `~u <regex>` | URL (path + query) | `~u /api/login` |
|
||||
| `~d <regex>` | Dominio del host | `~d example.com` |
|
||||
| `~m <método>` | Método HTTP | `~m POST` |
|
||||
| `~c <código>` | Código de respuesta | `~c 500` |
|
||||
| `~bq <regex>` | Body del request | `~bq password` |
|
||||
| `~bs <regex>` | Body del response | `~bs token` |
|
||||
| `~t <regex>` | Content-Type | `~t application/json` |
|
||||
| `~s` | Solo respuestas | `~s` |
|
||||
| `~q` | Solo requests | `~q` |
|
||||
|
||||
Combinar con `&` (AND), `|` (OR), `!` (NOT). Ejemplos:
|
||||
- `"~m POST & ~u /api"` — POST a rutas /api
|
||||
- `"~c 500 | ~c 503"` — errores de servidor
|
||||
- `"~d example.com & !~u /static"` — todo de example.com excepto estáticos
|
||||
|
||||
**Globs:** el shell expande el glob *antes* de pasar los argumentos a la función. Pasar `~/captures/traffic-*.mitm` sin comillas para que el shell expanda; con comillas el glob llega literalmente y fallará si el archivo no existe.
|
||||
|
||||
**Inspección interactiva:** para navegar los flujos con UI, usar `mitmweb -r <file>` (web) o `mitmproxy -r <file>` (TUI), no esta función. Esta función es para consulta programática y scripting.
|
||||
|
||||
**Múltiples archivos con `-r`:** mitmdump acepta varios flags `-r` y concatena los flujos en orden. El filtro se aplica sobre el conjunto completo.
|
||||
|
||||
**HAR y filtros juntos:** al usar `--har` con `--filter`, solo los flujos que pasen el filtro se exportan al HAR.
|
||||
@@ -1,101 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# query_mitm_flows — Consulta capturas .mitm con mitmdump: vuelca flujos a stdout
|
||||
# o exporta a HAR. Acepta uno o varios archivos (también globs expandidos por el shell).
|
||||
|
||||
query_mitm_flows() {
|
||||
local -a files=()
|
||||
local filter=""
|
||||
local har_out=""
|
||||
local mitmdump_bin=""
|
||||
|
||||
# ── Parseo de argumentos ────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--filter)
|
||||
[[ -z "${2:-}" ]] && { echo "ERROR: --filter requiere un valor" >&2; return 1; }
|
||||
filter="$2"; shift 2 ;;
|
||||
--har)
|
||||
[[ -z "${2:-}" ]] && { echo "ERROR: --har requiere una ruta de salida" >&2; return 1; }
|
||||
har_out="$2"; shift 2 ;;
|
||||
--mitmdump)
|
||||
[[ -z "${2:-}" ]] && { echo "ERROR: --mitmdump requiere la ruta al binario" >&2; return 1; }
|
||||
mitmdump_bin="$2"; shift 2 ;;
|
||||
--*)
|
||||
echo "ERROR: opcion desconocida: $1" >&2; return 1 ;;
|
||||
*)
|
||||
files+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Validar que se paso al menos un archivo ─────────────────────────────────
|
||||
if [[ ${#files[@]} -eq 0 ]]; then
|
||||
echo "ERROR: se requiere al menos un archivo .mitm como argumento" >&2
|
||||
echo "Uso: query_mitm_flows <file_or_glob> [--filter EXPR] [--har OUT] [--mitmdump BIN]" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── Verificar que cada archivo existe ──────────────────────────────────────
|
||||
local -a valid_files=()
|
||||
for f in "${files[@]}"; do
|
||||
if [[ -f "$f" ]]; then
|
||||
valid_files+=("$f")
|
||||
else
|
||||
echo "ERROR: archivo no encontrado: $f" >&2
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#valid_files[@]} -eq 0 ]]; then
|
||||
echo "ERROR: ningun archivo valido encontrado (el patron no matcheo nada)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── Autodetectar mitmdump ───────────────────────────────────────────────────
|
||||
if [[ -z "$mitmdump_bin" ]]; then
|
||||
if command -v mitmdump &>/dev/null; then
|
||||
mitmdump_bin="mitmdump"
|
||||
elif [[ -x "$HOME/.local/bin/mitmdump" ]]; then
|
||||
mitmdump_bin="$HOME/.local/bin/mitmdump"
|
||||
else
|
||||
echo "ERROR: mitmdump no encontrado. Instala mitmproxy:" >&2
|
||||
echo " pip install mitmproxy o pip install --user mitmproxy" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -x "$(command -v "$mitmdump_bin" 2>/dev/null || echo "$mitmdump_bin")" ]]; then
|
||||
echo "ERROR: binario mitmdump no ejecutable: $mitmdump_bin" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── Construir y ejecutar el comando ────────────────────────────────────────
|
||||
local -a cmd=("$mitmdump_bin" -n)
|
||||
|
||||
# Añadir todos los archivos de entrada con -r
|
||||
for f in "${valid_files[@]}"; do
|
||||
cmd+=(-r "$f")
|
||||
done
|
||||
|
||||
# Modo HAR
|
||||
if [[ -n "$har_out" ]]; then
|
||||
cmd+=(--set "hardump=${har_out}")
|
||||
fi
|
||||
|
||||
# Filtro de flujos (argumento posicional al final)
|
||||
if [[ -n "$filter" ]]; then
|
||||
cmd+=("$filter")
|
||||
fi
|
||||
|
||||
"${cmd[@]}"
|
||||
local exit_code=$?
|
||||
|
||||
if [[ -n "$har_out" && $exit_code -eq 0 ]]; then
|
||||
echo "HAR exportado a ${har_out}" >&2
|
||||
fi
|
||||
|
||||
return $exit_code
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
query_mitm_flows "$@"
|
||||
fi
|
||||
@@ -38,7 +38,7 @@ if [[ -n "$matches" ]]; then
|
||||
fi
|
||||
|
||||
# Escanear repo especifico
|
||||
scan_secrets_in_dirty $HOME/fn_registry
|
||||
scan_secrets_in_dirty /home/lucas/fn_registry
|
||||
```
|
||||
|
||||
## Patrones detectados
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user