Compare commits
615 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11e6e27ad1 | |||
| a59b12d467 | |||
| fe4320af89 | |||
| f71e0f4c9a | |||
| 46b4385331 | |||
| 580238b32e | |||
| ed767360c1 | |||
| 5bac05ce13 | |||
| d0ceea6f3d | |||
| 0f905b78e0 | |||
| 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 |
+2
-7
@@ -21,11 +21,9 @@ 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, playgrounds y reports — 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`, `.claude/rules/playgrounds.md` y `.claude/rules/reports.md`.
|
||||
|
||||
**Reports:** reportes de trabajo (entregable de una tarea: resumen + cambios + verificacion con evidencia + gaps). Son **artefacto local**: viven en `reports/` o `projects/<p>/reports/`, estan gitignored (salvo `reports/.gitkeep`), NO suben a Gitea ni se versionan en el padre y NO se indexan — igual que los vaults/playgrounds. Compartir = pasar la ruta del `.md`. Convencion + plantilla en `.claude/rules/reports.md`. Decision: ADR 0006.
|
||||
**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`
|
||||
|
||||
@@ -195,7 +193,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.
|
||||
|
||||
@@ -233,8 +230,6 @@ fn-registry/
|
||||
docs/ # Specs de diseño
|
||||
docs/templates/ # Plantillas de frontmatter
|
||||
temp/ # Workspace efimero — pruebas, APIs, prototipos (gitignored, no indexado)
|
||||
reports/ # Reportes de trabajo (artefacto local: gitignored salvo .gitkeep, no Gitea, no indexado)
|
||||
projects/*/reports/ # Reportes de un proyecto concreto (mismo trato: gitignored, local)
|
||||
<artefacto>/playground/ # Prototipo rapido dentro de un artefacto padre (analysis/app/proyecto). No se indexa
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+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
|
||||
```
|
||||
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
---
|
||||
name: orquestador
|
||||
description: "Modo orquestador: el Claude principal NO hace el trabajo pesado — descompone la tarea y lanza Claudes SECUNDARIOS interactivos, cada uno en su propia terminal kitty con un prompt autonomo y aislamiento git impuesto. El humano habla solo con el orquestador, ve a los secundarios en sus kitties y puede saltar a cualquiera. El orquestador sigue la flota, lee sus reports e integra. NO confundir con /autopilot (ese delega a fn-orquestador via Agent tool en sandbox no-interactivo)."
|
||||
---
|
||||
|
||||
# /orquestador — coordinar Claudes secundarios interactivos en kitty
|
||||
|
||||
Activa un **modo de comportamiento** persistente. Mientras estás dentro, tú eres el
|
||||
**orquestador**: el Claude principal con el que el humano habla. Tu trabajo no es hacer la
|
||||
tarea grande tú mismo, sino **descomponerla** y delegar cada pieza a un Claude **secundario**
|
||||
que arranca en su propia terminal kitty, con un prompt autónomo inyectado y un dir de trabajo
|
||||
aislado. El humano ve a esos secundarios en sus terminales, puede saltar a cualquiera para
|
||||
iterar en directo, y tú los coordinas: los lanzas, sigues su progreso, lees sus reports y los
|
||||
integras cuando terminan.
|
||||
|
||||
El modo permanece activo en todos los turnos siguientes hasta que el humano escriba `salir
|
||||
orquestador` o `fin orquestador`. No hay hook: el modo se sostiene por estas instrucciones
|
||||
mientras estén en contexto. Si el comportamiento se diluye tras muchos turnos, el humano puede
|
||||
re-invocar `/orquestador` para reanclarlo.
|
||||
|
||||
Al entrar, responde con una sola línea de confirmación y queda a la espera de la tarea grande:
|
||||
|
||||
```
|
||||
MODO ORQUESTADOR activo. Dame la tarea grande; la descompongo y lanzo secundarios. 'fin orquestador' para terminar.
|
||||
```
|
||||
|
||||
## Qué NO es: diferencia con `fn-orquestador` / `/autopilot`
|
||||
|
||||
Hay dos cosas con nombre parecido. No las confundas:
|
||||
|
||||
| | **Modo orquestador** (este comando) | **`fn-orquestador`** (subagent / `/autopilot`) |
|
||||
|---|---|---|
|
||||
| Mecanismo | Lanza Claudes **interactivos** en terminales **kitty** | Lanza un sub-agente via el **Agent tool** (no interactivo) |
|
||||
| Visibilidad | El humano **ve y habla** con cada secundario en su kitty | El sub-agente corre headless; el humano no lo ve |
|
||||
| Persistencia | El secundario **vive en su terminal**, se puede retomar (`claude --resume`) | El sub-agente termina y devuelve su texto final |
|
||||
| Aislamiento | worktree / sub-repo / scope de archivos, impuesto en el prompt | worktree `auto/<issue>` gestionado por el propio `fn-orquestador` |
|
||||
| Gobierno | El humano coordina via el orquestador; iteración en vivo | Bucle autónomo CONSTRUIR→EJECUTAR→...→MEJORAR hasta converger, PR draft |
|
||||
| Regla de referencia | esta página | `.claude/rules/autonomous_loop.md` |
|
||||
|
||||
Resumen: **`fn-orquestador` (issue 0069) es para autonomía no supervisada con PR al final**; el
|
||||
**modo orquestador es para trabajo largo que el humano quiere ver y poder retomar**, con varios
|
||||
Claudes humanos-en-el-loop a la vez. Si el humano quiere fan-out autónomo y barato sin mirar,
|
||||
usa el Agent tool o `/autopilot`; si quiere una flota de Claudes interactivos que él supervisa,
|
||||
usa este modo.
|
||||
|
||||
## El ciclo del orquestador (8 pasos)
|
||||
|
||||
### 1. Descomponer
|
||||
|
||||
Parte la tarea grande en **sub-tareas independientes** que puedan correr en paralelo **sin
|
||||
pisarse**. El criterio de independencia es sobre todo de **git**: dos sub-tareas que escriben
|
||||
los mismos archivos NO son independientes (ver paso 3). Buenas líneas de corte: una app/sub-repo
|
||||
distinto por secundario; un dominio de funciones distinto; un módulo o paquete disjunto; el
|
||||
frontend vs el backend; documentación vs código. Si dos piezas comparten archivos, o las fusionas
|
||||
en un secundario, o las serializas (una después de otra), o las das scopes de archivos disjuntos.
|
||||
|
||||
### 2. Lanzar cada secundario
|
||||
|
||||
Comando canónico de lanzamiento (memoria `lanzar-agentes-skip-permissions`), **siempre** con
|
||||
`--dangerously-skip-permissions` porque los secundarios trabajan autónomos y desatendidos y los
|
||||
prompts de permiso en cada Bash los atascarían:
|
||||
|
||||
```bash
|
||||
setsid nohup kitty --title "<PROYECTO> · <subtarea>" --directory <dir-aislado> \
|
||||
zsh -ic 'claude --dangerously-skip-permissions "$(cat /tmp/orq_<slug>.md)"; exec zsh' \
|
||||
>/tmp/orq_<slug>_kitty.log 2>&1 & disown
|
||||
```
|
||||
|
||||
`setsid nohup ... & disown` hace que la kitty sobreviva al cierre de la terminal padre. El
|
||||
`zsh -ic '...; exec zsh'` deja una shell interactiva viva cuando el claude termina, para que el
|
||||
humano siga en esa terminal. El log de `/tmp/orq_<slug>_kitty.log` es donde se ve el arranque.
|
||||
|
||||
**Prefiere la función del registry** en vez de teclear el one-liner a mano (registry-first,
|
||||
queda en telemetría):
|
||||
|
||||
```bash
|
||||
./fn run launch_claude_agent_kitty "<PROYECTO> · <subtarea>" <dir-aislado> /tmp/orq_<slug>.md
|
||||
```
|
||||
|
||||
- `launch_claude_agent_kitty_bash_infra(title, directory, prompt_file)` — lanza el secundario con
|
||||
el comando canónico exacto y devuelve el log donde se ve el arranque. Valida que el dir y el
|
||||
prompt_file existan y que kitty esté instalado.
|
||||
|
||||
### 3. Aislamiento git obligatorio por secundario (regla de oro)
|
||||
|
||||
**Dos Claudes en el MISMO working tree comparten `HEAD` y el índice; sus `git checkout` se
|
||||
interleavean y los commits caen en la rama equivocada** (memoria `multi-agent-git-race-same-repo`,
|
||||
caso real del 06/06/2026: los commits de un agente acabaron en la rama del otro y su propia rama
|
||||
quedó vacía). Por eso **cada secundario trabaja en un espacio aislado**, y el orquestador elige
|
||||
cuál y se lo **impone** en el prompt del secundario:
|
||||
|
||||
| Opción | Cómo | Cuándo |
|
||||
|---|---|---|
|
||||
| **(a) Sub-repo Gitea propio** | El secundario trabaja dentro de `apps/<x>/`, `analysis/<x>/`, `projects/<p>/...` — cada uno tiene su `.git` independiente (regla `apps_subrepo.md`) | Cuando las sub-tareas caen en apps/analyses/projects distintos. Es el aislamiento natural del monorepo. |
|
||||
| **(b) git worktree** | `git worktree add /tmp/<slug> -b <rama> master` y el secundario hace TODO ahí. Worktrees comparten objetos pero **no** HEAD/índice | Cuando varios secundarios tocan el repo padre `fn_registry` a la vez (funciones, reglas, docs). |
|
||||
| **(c) Scope de archivos disjunto** | Mismo working tree pero cada secundario commitea **solo sus paths**: `git add <paths-específicos>`, **nunca** `git add -A` | Último recurso, solo si los scopes están garantizados disjuntos y no hay `git checkout` de rama de por medio. Frágil; prefiere (a) o (b). |
|
||||
|
||||
Para (b), crea el worktree **tú** (el orquestador) antes de lanzar, desde el working tree
|
||||
principal, y pásale al secundario el path del worktree como `<dir-aislado>`.
|
||||
|
||||
### 4. El prompt de cada secundario
|
||||
|
||||
Lo escribes tú en `/tmp/orq_<slug>.md` antes de lanzar. El secundario **no ve este historial**;
|
||||
el prompt debe ser **autocontenido**. Incluye SIEMPRE:
|
||||
|
||||
1. **Objetivo claro** — qué construir/arreglar, acotado y verificable.
|
||||
2. **Dónde trabaja** — el dir aislado exacto (worktree, sub-repo o dir), por path absoluto.
|
||||
3. **Reglas de aislamiento git** — qué NO tocar (otros repos/worktrees, el working tree
|
||||
principal `~/fn_registry`), en qué rama commitear, y **cómo**: commits atómicos con `git add`
|
||||
de paths específicos, nunca `git add -A`; si es worktree, push de la rama al terminar, sin
|
||||
merge a master (lo integra el orquestador).
|
||||
4. **Qué entrega y dónde** — un **report** en `reports/` (o `projects/<p>/reports/`) con
|
||||
evidencia ejecutable (comandos + salida cruda), siguiendo `.claude/rules/reports.md` y
|
||||
`.claude/rules/dod_quality.md`. Reports son artefacto local gitignored: se escriben, no se
|
||||
commitean.
|
||||
5. **Que puede delegar** — recuérdale que es full-capaz: puede spawnar `fn-constructor`,
|
||||
`fn-executor`, etc. via el Agent tool, y debe seguir registry-first (`registry_calls.md`,
|
||||
`delegation.md`).
|
||||
6. **La coletilla**: *"reporta tu progreso en esta terminal"* — para que el humano que mire la
|
||||
kitty vea el estado sin abrir el report.
|
||||
|
||||
Mira `/tmp/unibus_agent_*.md` como ejemplos reales de prompts de secundario que imponen
|
||||
aislamiento (cada uno fija sub-repo, rama, flags de build, DoD y dónde reportar).
|
||||
|
||||
### 5. Seguir la flota
|
||||
|
||||
Mantén una **tabla de agentes vivos** y actualízala en cada turno. La fuente de verdad del
|
||||
mapeo PID→sessionId→cwd son los archivos `~/.claude/sessions/<PID>.json` (memoria
|
||||
`claude-session-pid-mapping`). Usa la función del registry para listarla:
|
||||
|
||||
```bash
|
||||
./fn run list_claude_agents # tabla: PID, STATUS, ETIME, KITTY, SELF, SESSION_ID, CWD
|
||||
./fn run list_claude_agents --json # para parsear y decidir
|
||||
```
|
||||
|
||||
- `list_claude_agents_bash_infra([--json] [--exclude-current])` — cruza `pgrep -x claude` con los
|
||||
`sessions/<PID>.json` (con validación anti-PID-reciclado), marca tu propia sesión como `SELF`,
|
||||
y reporta cwd + sessionId de cada secundario (para retomar con `claude --resume <sessionId>`).
|
||||
|
||||
Tu tabla de seguimiento, una fila por secundario:
|
||||
|
||||
| slug | título kitty | PID | cwd / dir aislado | rama | log | report | estado |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| docs | fn_registry · docs | 3637133 | /tmp/orq_docs_wt | orq/docs | /tmp/orq_docs_kitty.log | reports/00NN-…-docs.md | en curso |
|
||||
|
||||
Cuando un secundario parezca terminado, confirma: ¿pusheó la rama? ¿escribió el report? Lee el
|
||||
report (`reports/`), revisa los commits de su rama (`git -C <dir> log --oneline`).
|
||||
|
||||
### 6. NUNCA `pkill`/`killall` sobre claude
|
||||
|
||||
Un `pkill claude` o `killall claude` **te mata a ti mismo** (el orquestador) junto con la flota.
|
||||
Para parar un secundario:
|
||||
|
||||
- **Kill por PID exacto** del secundario (lo tienes en la tabla / `list_claude_agents`):
|
||||
`kill <PID>` (o `kill <KITTY_PID>` para cerrar su ventana). Verifica que NO es tu `SELF`.
|
||||
- **`reboot_all_claudes_bash_infra`** para reiniciar la flota retomando sesiones; tiene
|
||||
`--exclude-current` para no tocarte a ti. Es dry-run por defecto; `--go` para ejecutar.
|
||||
|
||||
### 7. Integrar
|
||||
|
||||
Cuando un secundario termina (rama pusheada + report verde):
|
||||
|
||||
1. **Revisa** su diff y su report. Si el report no trae evidencia ejecutable o falla la DoD,
|
||||
devuélvele trabajo (el humano puede saltar a su kitty, o tú le mandas otro prompt).
|
||||
2. **Mergea si procede** desde el **working tree principal** (ahí suele estar `master`
|
||||
checked-out): `git -C ~/fn_registry merge --no-ff <rama>` para apps con TBD, o el flujo que
|
||||
corresponda al sub-repo. Para funciones nuevas del registry padre, sus archivos viajan en la
|
||||
rama y el merge los lleva a master.
|
||||
3. **Informa al humano** y **resume el estado de la flota** en cada turno: quién terminó, quién
|
||||
sigue, qué se integró, qué falta.
|
||||
|
||||
### 8. kitty vs Agent tool — cuándo cada uno
|
||||
|
||||
- **kitty (este modo)**: trabajo **largo e interactivo** que el humano quiere **ver** y poder
|
||||
**retomar** — implementar una feature de horas, depurar en vivo, una sesión que evoluciona.
|
||||
- **Agent tool directo**: fan-out **acotado y no interactivo** — buscar en el codebase, crear
|
||||
una función con `fn-constructor`, auditar N apps con `fn-recopilador`. Más barato, sin
|
||||
terminal, sin supervisión humana. Para esto NO lances kitty: usa `Agent(...)` y ya.
|
||||
|
||||
Regla práctica: si el humano va a querer hablar con ello o mirarlo trabajar → kitty. Si es una
|
||||
sub-tarea que devuelve un resultado y se acabó → Agent tool.
|
||||
|
||||
## Reglas duras del modo
|
||||
|
||||
- **El orquestador no hace el trabajo pesado.** Descompone, lanza, sigue, integra. Si te
|
||||
encuentras escribiendo tú la feature, párate: ¿no debería ser un secundario?
|
||||
- **Cada secundario, su aislamiento.** Nunca lances dos secundarios sobre el mismo working tree
|
||||
sin worktrees/sub-repos/scopes disjuntos. Es la causa nº1 de commits perdidos.
|
||||
- **El prompt del secundario lleva SIEMPRE las reglas de aislamiento.** Un prompt sin "trabaja
|
||||
aquí, no toques aquello, commitea así" es un secundario que contaminará otro repo.
|
||||
- **Nunca `git add -A` en un secundario** salvo que su dir aislado sea exclusivamente suyo
|
||||
(worktree/sub-repo). En scope compartido, paths específicos.
|
||||
- **Nunca `pkill`/`killall claude`.** Kill por PID exacto o `reboot_all_claudes --exclude-current`.
|
||||
- **El humano habla contigo.** Tú resumes la flota; no le hagas perseguir 5 terminales.
|
||||
|
||||
## Anti-patrones
|
||||
|
||||
| Anti-patrón | Por qué es malo | En su lugar |
|
||||
|---|---|---|
|
||||
| `pkill claude` para parar la flota | Te mata a ti (el orquestador) también | Kill por PID exacto / `reboot_all_claudes --exclude-current` |
|
||||
| Dos secundarios en el mismo working tree | Comparten HEAD/índice → commits dispersos, ramas vacías | worktree / sub-repo / scope disjunto por secundario |
|
||||
| Prompt de secundario sin reglas de aislamiento | El secundario contamina el repo padre u otro worktree | El prompt fija dir, qué NO tocar, rama y cómo commitear |
|
||||
| `git add -A` en scope compartido | Arrastra cambios de otra sub-tarea al commit | `git add <paths-específicos>` |
|
||||
| Lanzar kitty para un fan-out trivial | Caro y sin supervisión que aporte | Agent tool directo (`fn-constructor`, `Explore`, …) |
|
||||
| Hacer tú la feature "porque es rápido" | Pierdes el sentido del modo; el humano no lo ve evolucionar | Descompón y lanza un secundario |
|
||||
| Lanzar sin `--dangerously-skip-permissions` | El secundario se atasca pidiendo permiso en cada Bash | Siempre `--dangerously-skip-permissions` (riesgo asumido) |
|
||||
| Mergear desde el dir del secundario | Master suele estar en el working tree principal; colisión de HEAD | Mergear desde `~/fn_registry` |
|
||||
|
||||
## Funciones del registry que usa este modo (grupo `orchestration`)
|
||||
|
||||
| Función | Para qué |
|
||||
|---|---|
|
||||
| `launch_claude_agent_kitty_bash_infra` | Lanzar un secundario en kitty con prompt autónomo + `--dangerously-skip-permissions` |
|
||||
| `list_claude_agents_bash_infra` | Listar la flota de Claudes vivos (PID, sessionId, cwd, status, kitty) para seguirla |
|
||||
| `reboot_all_claudes_bash_infra` | Reiniciar/parar la flota retomando sesiones; `--exclude-current` para no tocarte |
|
||||
|
||||
## Ejemplo end-to-end
|
||||
|
||||
Tarea grande: *"añade un endpoint `/api/health` al backend de la app `kanban` y, en paralelo,
|
||||
documenta el grupo de capacidad `deploy` en `docs/capabilities/deploy.md`"*. Dos piezas
|
||||
independientes: una toca el sub-repo `apps/kanban` (su propio `.git`), la otra toca el repo
|
||||
padre `fn_registry` (docs). Aislamiento natural distinto para cada una.
|
||||
|
||||
```bash
|
||||
# 1. Descomponer → 2 secundarios independientes:
|
||||
# A) health endpoint → sub-repo apps/kanban (aislamiento (a))
|
||||
# B) doc capability → worktree del padre (aislamiento (b))
|
||||
|
||||
# 2. Preparar aislamiento de B (worktree del padre; A ya está aislado por su sub-repo):
|
||||
git -C ~/fn_registry worktree add /tmp/orq_capdoc -b orq/cap-deploy master
|
||||
|
||||
# 3. Escribir los prompts autónomos (autocontenidos, con reglas de aislamiento):
|
||||
# /tmp/orq_health.md → "trabaja en apps/kanban (sub-repo propio), rama issue/health,
|
||||
# commits atómicos de tus paths, push al terminar, report en reports/. No toques el
|
||||
# repo padre. Reporta tu progreso en esta terminal."
|
||||
# /tmp/orq_capdoc.md → "trabaja SOLO en /tmp/orq_capdoc (worktree), rama orq/cap-deploy,
|
||||
# toca solo docs/capabilities/deploy.md, git add de ese path, push al terminar, report
|
||||
# en reports/. No toques ~/fn_registry. Reporta tu progreso en esta terminal."
|
||||
|
||||
# 4. Lanzar ambos secundarios (cada uno su kitty, su dir aislado):
|
||||
./fn run launch_claude_agent_kitty "kanban · health endpoint" \
|
||||
~/fn_registry/apps/kanban /tmp/orq_health.md
|
||||
./fn run launch_claude_agent_kitty "fn_registry · doc deploy" \
|
||||
/tmp/orq_capdoc /tmp/orq_capdoc.md
|
||||
|
||||
# 5. Seguir la flota (cada turno):
|
||||
./fn run list_claude_agents
|
||||
# → tabla con los 2 secundarios vivos (PID, cwd, sessionId, status) + tu SELF.
|
||||
# Lee /tmp/orq_*_kitty.log para el arranque; cuando terminen, lee sus reports/.
|
||||
|
||||
# 7. Integrar (desde el working tree principal):
|
||||
git -C ~/fn_registry/apps/kanban merge --no-ff issue/health # sub-repo de la app
|
||||
git -C ~/fn_registry merge --no-ff orq/cap-deploy # repo padre (la doc)
|
||||
git -C ~/fn_registry worktree remove /tmp/orq_capdoc # limpiar worktree
|
||||
|
||||
# Resumen al humano: A integrado (endpoint + test verde), B integrado (doc),
|
||||
# flota vacía. Tarea grande hecha.
|
||||
```
|
||||
|
||||
## Salida del modo
|
||||
|
||||
Cuando el humano escriba `salir orquestador` o `fin orquestador`, cierra con un resumen de la
|
||||
flota: secundarios lanzados, cuáles terminaron e integraste, cuáles siguen vivos (con su kitty
|
||||
para que el humano decida), y los reports generados. Si quedan secundarios vivos, recuérdale que
|
||||
`list_claude_agents` los lista y que para pararlos es kill por PID exacto, nunca `pkill`.
|
||||
|
||||
## Relación con otras reglas
|
||||
|
||||
- `.claude/rules/autonomous_loop.md` — `fn-orquestador` (Agent tool, sandbox no-interactivo). Es
|
||||
lo que este modo **no** es; tenlas claras separadas.
|
||||
- `.claude/rules/apps_subrepo.md` — apps/analyses/projects son sub-repos Gitea (`apps/*`
|
||||
gitignored): el aislamiento natural (opción (a)) y el gotcha de `git init` antes de limpiar un
|
||||
worktree con una app nueva dentro.
|
||||
- `.claude/rules/reports.md` + `.claude/rules/dod_quality.md` — qué entrega cada secundario:
|
||||
report con evidencia ejecutable + gaps.
|
||||
- `.claude/rules/delegation.md` + `.claude/rules/registry_calls.md` — los secundarios siguen
|
||||
registry-first y delegan a `fn-constructor` igual que tú.
|
||||
- Memorias: `lanzar-agentes-skip-permissions`, `multi-agent-git-race-same-repo`,
|
||||
`claude-session-pid-mapping`, `prefiere-kitty-terminal`.
|
||||
@@ -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) |
|
||||
@@ -38,7 +38,3 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
||||
| 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. |
|
||||
| 36 | [reports.md](reports.md) | Reports: reportes de trabajo como artefacto local (entregable de tarea con evidencia). Gitignored salvo `.gitkeep`, NO suben a Gitea ni se indexan (como vaults+playgrounds). Viven en `reports/` o `projects/<p>/reports/`. Convencion + plantilla. ADR 0006. |
|
||||
| 37 | [flow_replay.md](flow_replay.md) | Flow replay: guardar un flujo web (login, reiniciar server, formulario) como funcion del registry. Patron grabar→destilar→reproducir con jerarquia HTTP puro > headless chromium > visible humanizado. Empieza por Nivel 1. Seguridad: HAR sensible, secrets a pass, acciones con efecto exigen confirmacion. Grupo `flow-replay`. Issue 0087. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## Artefactos: termino colectivo
|
||||
|
||||
**"Artefacto"** es el termino paraguas para todo lo que vive en el registry pero NO es codigo reutilizable de `functions/` o `types/`. Sirve para no repetir "apps, analysis, vaults, projects, playgrounds, reports" cada vez.
|
||||
**"Artefacto"** es el termino paraguas para todo lo que vive en el registry pero NO es codigo reutilizable de `functions/` o `types/`. Sirve para no repetir "apps, analysis, vaults, projects, playgrounds" cada vez.
|
||||
|
||||
Tipos de artefacto:
|
||||
|
||||
@@ -11,7 +11,6 @@ Tipos de artefacto:
|
||||
| **vault** | `projects/<p>/vaults/<v>` (symlink) | tabla `vaults` | no (datos fuera del repo) |
|
||||
| **project** | `projects/<p>/` | tabla `projects` | no (vive dentro de fn_registry) |
|
||||
| **playground** | `<artefacto_padre>/playground/` | NO se indexa | no (vive dentro del padre) |
|
||||
| **report** | `reports/`, `projects/<p>/reports/` | NO se indexa | no (local, gitignored, no sube a Gitea — como vaults) |
|
||||
|
||||
Caracteristicas comunes de los artefactos:
|
||||
- NO son codigo reutilizable. La reutilizacion vive en `functions/`.
|
||||
@@ -19,8 +18,6 @@ Caracteristicas comunes de los artefactos:
|
||||
- `pc_locations` los unifica via `entity_type` (app, analysis, project, vault).
|
||||
- Pueden importar funciones del registry; el registry NUNCA importa de un artefacto.
|
||||
|
||||
**Reports** son el caso mas ligero: artefacto local (gitignored salvo `reports/.gitkeep`), NO sube a Gitea ni se versiona en el padre (como los vaults), NO se indexa (como los playgrounds). Convencion en [[reports]]. Pueden vivir sueltos en `reports/` o dentro de un proyecto en `projects/<p>/reports/`.
|
||||
|
||||
### Cuando usar el termino
|
||||
|
||||
Usa "artefacto" cuando hablas de varios tipos a la vez o cuando la afirmacion aplica a todos:
|
||||
|
||||
@@ -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,76 +0,0 @@
|
||||
## Flow replay: guardar un flujo web como función reproducible
|
||||
|
||||
Cuando una acción web se hace **más de una vez** (login en un panel, reiniciar un servidor
|
||||
desde su consola, rellenar un formulario recurrente, descargar un export), deja de hacerse a
|
||||
mano: se **graba una vez y se promueve a función del registry**. Es la doctrina del issue 0087
|
||||
aplicada a la navegación — el registry crece convirtiendo secuencias repetidas en operaciones
|
||||
de un solo paso, no inflando funciones existentes.
|
||||
|
||||
Grupo de capacidad: `flow-replay`. Página madre: `docs/capabilities/flow-replay.md`. Graba con
|
||||
el grupo `web-proxy`; destila y reproduce con `flow-replay`.
|
||||
|
||||
### El patrón: grabar → destilar → reproducir
|
||||
|
||||
1. **Grabar** (una vez, con browser + proxy): `web_proxy` ON, haces la acción a mano,
|
||||
exportas el tramo a HAR (`query_mitm_flows --har`).
|
||||
2. **Destilar**: `har_filter_flows_py_cybersecurity` (quita ruido) →
|
||||
`har_extract_calls_py_cybersecurity` (call specs reproducibles).
|
||||
3. **Reproducir**, en esta jerarquía de preferencia (de barato a caro):
|
||||
|
||||
| Nivel | Mecanismo | Cuándo |
|
||||
|---|---|---|
|
||||
| **1 — HTTP puro** | `http_replay_sequence_py_infra` | **Por defecto.** Rápido, headless, scriptable. La mayoría de paneles admin funcionan con cookie de sesión + requests. |
|
||||
| **2 — headless chromium** | action recipe (reutiliza `cdp_extract_recipe` + `cdp_save_storage_state`) | Token dinámico firmado en cliente, challenge JS obligatorio, WAF con fingerprint. |
|
||||
| **3 — chromium visible + humanizado** | `cdp_click_xy_human`, `cdp_move_mouse_human` | Headless detectado/bloqueado. Último recurso. |
|
||||
|
||||
**Empieza SIEMPRE por el Nivel 1.** Solo baja de nivel cuando el anterior demuestre no
|
||||
reproducir el efecto. Construir el runner de Nivel 2/3 por adelantado, sin un caso que lo
|
||||
exija, es especular (KISS): se monta cuando un flujo real falle en HTTP puro.
|
||||
|
||||
### Flujo de autoría (cómo guardar una función-acción nueva)
|
||||
|
||||
1. Grabar el flujo y exportar el HAR del tramo.
|
||||
2. `har_filter_flows` + `har_extract_calls` → boceto de la secuencia. El agente **lee** el
|
||||
HAR (es texto) e identifica los 2-4 requests que producen el efecto (auth + acción +
|
||||
confirmación), descartando el resto.
|
||||
3. Parametrizar: marcar los valores variables (ids, tokens) como `{{param}}`; definir las
|
||||
reglas `extract` para los tokens que una respuesta genera y otro request consume.
|
||||
4. Validar el replay con `http_replay_sequence`. Si reproduce el efecto sin navegador → Nivel 1.
|
||||
5. **Promover a función del registry**: delegar a `fn-constructor` una función-acción nombrada
|
||||
con verbo (`reboot_vps_server_<panel>`, `login_<panel>`, `export_<panel>_report`) que
|
||||
internamente llama a `http_replay_sequence` con su secuencia fija, recibe los parámetros
|
||||
del caller y resuelve los secretos desde `pass`/vault. Tag de grupo `flow-replay` + el
|
||||
dominio que toque (infra, cybersecurity, …). `fn index` + usar en el mismo turno.
|
||||
|
||||
### Reglas duras de seguridad
|
||||
|
||||
- **El HAR es un secreto**: lleva cookies/tokens en crudo. Gitignored, no subir a Gitea, no
|
||||
indexar, borrar tras destilar. El output de `har_extract_calls` también, hasta sustituir por
|
||||
`{{param}}`.
|
||||
- **Secretos a `pass`/vault**, jamás hardcodeados en la función-acción.
|
||||
- **Replay con efectos = peligroso.** Una acción destructiva o irreversible (reiniciar, borrar,
|
||||
pagar, enviar) NUNCA se reproduce a ciegas: la función-acción exige confirmación o un flag
|
||||
explícito (`confirm=True` / `--yes`) antes de disparar.
|
||||
- `http_replay_sequence` usa `verify_tls=True` y sigue redirects por defecto; la extracción
|
||||
JSON es dot-path simple, no JSONPath completo.
|
||||
|
||||
### Anti-patrones
|
||||
|
||||
| Anti-patrón | Por qué es malo | Sustituir por |
|
||||
|---|---|---|
|
||||
| Repetir el flujo a mano cada vez | No capitaliza; lento; propenso a error | Grabar una vez → función-acción |
|
||||
| Reescribir requests inline en un heredoc/app cada vez | Reinvento, sin telemetría | Función-acción que llama `http_replay_sequence` |
|
||||
| Empezar por chromium headless "por si acaso" | Más caro y frágil que HTTP puro | Nivel 1 primero, bajar solo si falla |
|
||||
| Hardcodear cookie/token del HAR en el código | Secreto filtrado + caduca | `{{param}}` desde `pass`/vault |
|
||||
| Commitear el HAR o el output crudo de extract | Filtración de credenciales | Tratar como secreto, gitignored |
|
||||
| Replay ciego de un POST destructivo | Daño irreversible | Confirmación / flag explícito |
|
||||
|
||||
### Relación con otras reglas
|
||||
|
||||
- [[registry_first]] — buscar/reutilizar antes de escribir; la función-acción se delega a
|
||||
`fn-constructor`, no se escribe inline.
|
||||
- [[function_growth_and_self_docs]] — el registry crece por promoción de composiciones
|
||||
repetidas a funciones one-shot (issue 0087); esto es ese patrón para la navegación.
|
||||
- [[registry_calls]] — invocar las funciones del grupo por los patrones canónicos (MCP /
|
||||
`fn run` / heredoc que importa).
|
||||
- Grupo `web-proxy` (`docs/capabilities/web-proxy.md`) — la captura que alimenta la Fase 0.
|
||||
@@ -13,7 +13,7 @@ IDs: `{name}_{lang}_{domain}` (ej: `filter_slice_go_core`). Predictibilidad alta
|
||||
|
||||
Lista no exhaustiva pero cubre la mayoria. Anadir aqui (y al validator en `apps/registry_mcp/naming.go`) cuando se introduzca un verbo nuevo recurrente.
|
||||
|
||||
`get, set, list, find, search, show, read, load, fetch, scan, query, lookup, parse, format, encode, decode, marshal, unmarshal, serialize, deserialize, validate, check, ensure, verify, audit, diagnose, test, match, filter, map, reduce, sort, group, count, sum, aggregate, compute, calculate, score, rank, cluster, classify, detect, init, create, make, build, generate, scaffold, install, setup, configure, register, add, insert, append, prepend, update, upsert, modify, edit, patch, replace, delete, remove, clear, drop, prune, clean, copy, move, rename, sync, clone, extract, inject, import, export, send, post, put, call, dispatch, exec, run, launch, relaunch, start, stop, kill, restart, reboot, redeploy, deploy, open, close, connect, disconnect, login, logout, authenticate, enable, disable, toggle, lock, unlock, propose, promote, deprecate, approve, reject, emit, render, draw, paint, serve, host, pull, push, checkout, commit, tag, merge, rebase, watch, monitor, observe, log, trace, profile, benchmark, snapshot, backup, restore, archive, compress, decompress, hash, encrypt, decrypt, sign, taskkill, recopile, vault, propose, apply, gather, collect, fold, head, tail, take, drop, slice, chunk, batch, debounce, throttle, retry, await, sleep, ping, kill, prime, warm, refresh, invalidate, reload, reset, rollback, fork, spawn, daemon, observe, plot, draw, capture, replay, recopilate`
|
||||
`get, set, list, find, search, show, read, load, fetch, scan, query, lookup, parse, format, encode, decode, marshal, unmarshal, serialize, deserialize, validate, check, ensure, verify, audit, diagnose, test, match, filter, map, reduce, sort, group, count, sum, aggregate, compute, calculate, score, rank, cluster, classify, detect, init, create, make, build, generate, scaffold, install, setup, configure, register, add, insert, append, prepend, update, upsert, modify, edit, patch, replace, delete, remove, clear, drop, prune, clean, copy, move, rename, sync, clone, extract, inject, import, export, send, post, put, call, dispatch, exec, run, launch, start, stop, kill, restart, redeploy, deploy, open, close, connect, disconnect, login, logout, authenticate, enable, disable, toggle, lock, unlock, propose, promote, deprecate, approve, reject, emit, render, draw, paint, serve, host, pull, push, checkout, commit, tag, merge, rebase, watch, monitor, observe, log, trace, profile, benchmark, snapshot, backup, restore, archive, compress, decompress, hash, encrypt, decrypt, sign, taskkill, recopile, vault, propose, apply, gather, collect, fold, head, tail, take, drop, slice, chunk, batch, debounce, throttle, retry, await, sleep, ping, kill, prime, warm, refresh, invalidate, reload, reset, rollback, fork, spawn, daemon, observe, plot, draw, capture, replay, recopilate`
|
||||
|
||||
### Excepciones
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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,78 +0,0 @@
|
||||
## Reports: reportes de trabajo como artefacto local
|
||||
|
||||
Un **report** es el entregable escrito de una tarea no trivial: qué se hizo, cómo se verificó y qué quedó pendiente, en formato copiable de un vistazo. Sirve para conservar el resultado fuera del chat y compartirlo rápido pasando la ruta del archivo.
|
||||
|
||||
Un report es un **artefacto** (ver `artefactos.md`), no documentación del registry. En consecuencia:
|
||||
|
||||
- **NO se versiona en el git del padre `fn_registry`** ni en ningún sub-repo: `reports/*` está en el `.gitignore` (solo el marcador `reports/.gitkeep` se versiona). Igual que los **vaults**.
|
||||
- **NO sube a Gitea**: un report no tiene repo propio. Vive local en la máquina que lo generó. Compartir = pasar la ruta o copiar el contenido, no `git push`.
|
||||
- **NO se indexa en `registry.db`**: no hay tabla `reports` ni schema. KISS — son texto plano efímero, como los `playgrounds`.
|
||||
|
||||
### Qué NO es un report
|
||||
|
||||
| Es | Va a |
|
||||
|---|---|
|
||||
| Decisión de diseño (qué se decidió y por qué) | `docs/adr/` (versionado) |
|
||||
| Norma operativa / convención | `.claude/rules/` (versionado) |
|
||||
| Bitácora cronológica libre | `docs/diary/` (versionado) |
|
||||
| **Resultado de una tarea concreta + su evidencia** | **`reports/` (artefacto local, NO versionado)** |
|
||||
|
||||
Si durante el trabajo aparece una decisión de diseño, esa decisión va a `docs/adr/` y el report solo la referencia.
|
||||
|
||||
### Ubicación
|
||||
|
||||
Como cualquier artefacto, un report puede vivir en dos sitios:
|
||||
|
||||
| Ubicación | Para qué |
|
||||
|---|---|
|
||||
| `reports/` (raíz) | Reportes que no pertenecen a ningún proyecto |
|
||||
| `projects/<p>/reports/` | Reportes del trabajo de un proyecto concreto |
|
||||
|
||||
Ambas rutas están gitignored (`reports/*`, `projects/*/reports/`). Se pueden crear subcarpetas bajo `reports/` para agrupar (`reports/browser/`, `reports/audits/`, …).
|
||||
|
||||
### Convención de nombre
|
||||
|
||||
```
|
||||
NNNN-YYYY-MM-DD-slug-corto.md
|
||||
```
|
||||
|
||||
- `NNNN` — número incremental de 4 dígitos por carpeta (0001, 0002, …). Referencia corta ("report 0003").
|
||||
- `YYYY-MM-DD` — fecha del trabajo (ISO en el nombre; en el cuerpo, fechas en formato europeo DD/MM/AAAA).
|
||||
- `slug-corto` — kebab-case descriptivo. Ej: `browser-domain-audit-fixes`.
|
||||
|
||||
### Plantilla mínima
|
||||
|
||||
```markdown
|
||||
# Report NNNN — Título
|
||||
|
||||
- **Fecha:** DD/MM/AAAA
|
||||
- **Autor:** (agente/humano)
|
||||
- **Ámbito:** (dominio/app/módulo tocado)
|
||||
- **Estado:** done | parcial | bloqueado
|
||||
|
||||
## Resumen
|
||||
Qué se hizo y el resultado, en 2-4 líneas.
|
||||
|
||||
## Cambios
|
||||
Tabla o lista de lo tocado/creado, con el porqué.
|
||||
|
||||
## Verificación
|
||||
Comandos ejecutados + salida cruda (build/test/vet/e2e). Sin "verde" sin evidencia.
|
||||
|
||||
## Gaps / pendientes
|
||||
Lo que NO se cubrió y por qué (honesto: requiere Chrome, scope, etc.).
|
||||
```
|
||||
|
||||
### Reglas
|
||||
|
||||
- **Cuándo escribir uno**: auditorías, tandas de fixes con verificación, refactors, investigaciones — cualquier trabajo cuyo resumen pedirías "para compartir rápido". Un fix de una línea NO necesita report; basta el commit.
|
||||
- **Evidencia ejecutable obligatoria**: cada "pasa" lleva su comando/salida. Nada de smoke "no petó". Alineado con `dod_quality.md`.
|
||||
- **Honestidad sobre gaps**: declarar siempre qué quedó sin cubrir.
|
||||
- **Índice opcional**: si una carpeta de reports acumula muchos, mantener un `INDEX.md` local (también gitignored) ayuda a navegar; no es obligatorio.
|
||||
|
||||
### Relación con otras reglas y ADRs
|
||||
|
||||
- [[artefactos]] — report es un tipo de artefacto (no código reutilizable, ciclo de vida propio).
|
||||
- [[playgrounds]] — mismo espíritu (artefacto local no indexado); el playground es prototipo de código, el report es resultado escrito.
|
||||
- [[dod_quality]] — los reports heredan su exigencia de evidencia + gaps.
|
||||
- ADR 0006 (`docs/adr/0006-reports-folder.md`) — decisión que crea la carpeta `reports/`.
|
||||
@@ -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
-10
@@ -46,13 +46,6 @@ projects/*/
|
||||
vaults/*/
|
||||
!vaults/vault.yaml
|
||||
|
||||
# Reports — artefacto local: reportes de trabajo. Como los vaults, NO suben a
|
||||
# Gitea ni se versionan en el padre (solo el marcador .gitkeep). Conviven en
|
||||
# reports/ (raíz) o projects/<p>/reports/. Convención: .claude/rules/reports.md
|
||||
reports/*
|
||||
!reports/.gitkeep
|
||||
projects/*/reports/
|
||||
|
||||
# Node / pnpm
|
||||
**/node_modules/
|
||||
|
||||
@@ -74,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
|
||||
@@ -88,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