Compare commits
645 Commits
master
..
3b348670cc
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b348670cc | |||
| fc4180cbb3 | |||
| 7eef442444 | |||
| 876020addf | |||
| 469e40ba40 | |||
| fce88032ca | |||
| fec8ebd4ec | |||
| f5f05e4624 | |||
| 532f3d0ea8 | |||
| fe65c5e527 | |||
| de9bfec498 | |||
| e9c64a4687 | |||
| 70ec825e32 | |||
| 22692c1ed2 | |||
| d128ad89ac | |||
| bd9f0d8437 | |||
| 207c08c3b7 | |||
| 01bc2aeb14 | |||
| 9ec7751f6f | |||
| fef86250a0 | |||
| 472b6092bb | |||
| ea5c94fc8a | |||
| a8b09ad154 | |||
| 6aa874f2b6 | |||
| 93352a7780 | |||
| 0ffae6daa4 | |||
| 74b58cf0d0 | |||
| 9752fb106a | |||
| 8cb0121573 | |||
| 90115270d2 | |||
| 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 |
+3
-8
@@ -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).
|
**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`.
|
**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`.
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
**Reglas y convenciones:** ver `.claude/rules/INDEX.md`
|
**Reglas y convenciones:** ver `.claude/rules/INDEX.md`
|
||||||
|
|
||||||
@@ -150,7 +148,7 @@ Cualquier `SELECT ... FROM functions/types/apps/proposals WHERE ...` plano se ha
|
|||||||
**functions** — columnas: `id, name, kind, lang, domain, version, purity, signature, description, tags, uses_functions, uses_types, returns, returns_optional, error_type, imports, example, tested, tests, test_file_path, file_path, created_at, updated_at, props, emits, has_state, framework, variant, notes, documentation, code, content_hash, source_repo, source_license, source_file, params_schema`
|
**functions** — columnas: `id, name, kind, lang, domain, version, purity, signature, description, tags, uses_functions, uses_types, returns, returns_optional, error_type, imports, example, tested, tests, test_file_path, file_path, created_at, updated_at, props, emits, has_state, framework, variant, notes, documentation, code, content_hash, source_repo, source_license, source_file, params_schema`
|
||||||
- `params_schema`: JSON con semántica de inputs/outputs. Formato: `{"params":[{"name":"x","desc":"..."}],"output":"..."}`. Buscable via FTS5.
|
- `params_schema`: JSON con semántica de inputs/outputs. Formato: `{"params":[{"name":"x","desc":"..."}],"output":"..."}`. Buscable via FTS5.
|
||||||
- Enums: `kind`(function|pipeline|component) `purity`(pure|impure) `lang`(go|py|bash|ps)
|
- Enums: `kind`(function|pipeline|component) `purity`(pure|impure) `lang`(go|py|bash|ps)
|
||||||
- Dominios: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines, browser, obsidian
|
- Dominios: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines, browser
|
||||||
|
|
||||||
**types** — columnas: `id, name, lang, domain, version, algebraic, definition, description, tags, uses_types, file_path, created_at, updated_at, examples, notes, documentation, code, content_hash, source_repo, source_license, source_file`
|
**types** — columnas: `id, name, lang, domain, version, algebraic, definition, description, tags, uses_types, file_path, created_at, updated_at, examples, notes, documentation, code, content_hash, source_repo, source_license, source_file`
|
||||||
- Enums: `algebraic`(product|sum)
|
- Enums: `algebraic`(product|sum)
|
||||||
@@ -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` |
|
| `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/` |
|
| 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>` |
|
| 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.
|
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/ # Specs de diseño
|
||||||
docs/templates/ # Plantillas de frontmatter
|
docs/templates/ # Plantillas de frontmatter
|
||||||
temp/ # Workspace efimero — pruebas, APIs, prototipos (gitignored, no indexado)
|
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
|
<artefacto>/playground/ # Prototipo rapido dentro de un artefacto padre (analysis/app/proyecto). No se indexa
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: fn-analizador
|
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)."
|
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
|
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: fn-constructor
|
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."
|
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
|
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: fn-executor
|
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."
|
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
|
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: fn-mejorador
|
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."
|
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
|
tools: Read, Bash, Grep, Glob
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: fn-orquestador
|
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."
|
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
|
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: fn-recopilador
|
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."
|
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
|
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- "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.
|
- "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.
|
WSL no tiene GLX 4.3 nativo — los tests corren bajo `xvfb` con software renderer Mesa. Wrapper canonico:
|
||||||
|
|
||||||
Dos formas de lanzar, según el entorno:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd "$ROOT/cpp/build/linux_tests"
|
cd "$ROOT/cpp/build/linux_tests"
|
||||||
TEST_BIN="$(find . -name "${APP_ARG}_tests" -type f -executable | head -1)"
|
TEST_BIN="$(find . -name "${APP_ARG}_tests" -type f -executable | head -1)"
|
||||||
[ -z "$TEST_BIN" ] && { echo "no encuentro el binario de tests"; exit 1; }
|
[ -z "$TEST_BIN" ] && { echo "no encuentro el binario de tests"; exit 1; }
|
||||||
|
|
||||||
if [ -n "$DISPLAY" ] && command -v glxinfo >/dev/null 2>&1 \
|
timeout 90 xvfb-run -a -s "-screen 0 1280x800x24" \
|
||||||
&& glxinfo 2>/dev/null | grep -q "OpenGL core profile version"; then
|
env LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe \
|
||||||
# Host con GL nativo (PC enmanuel, X11 + GPU): binario directo.
|
"$TEST_BIN" 2>&1
|
||||||
# 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
|
|
||||||
EXIT=$?
|
EXIT=$?
|
||||||
echo "EXIT: $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.
|
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).
|
||||||
|
|
||||||
**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
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9. Reportar
|
### 9. Reportar
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
---
|
|
||||||
description: Muestra la flota de Claudes vivos (sessionId + objetivo + estado) y, con argumento, salta con foco a esa conversación dentro de la sesión tmux fleet.
|
|
||||||
argument-hint: "[texto|sessionId|PID para saltar — vacío = listar la flota]"
|
|
||||||
---
|
|
||||||
|
|
||||||
# /fleet — ver y navegar la flota de Claudes
|
|
||||||
|
|
||||||
Inspecciona la flota de procesos Claude Code vivos de este PC y, opcionalmente, salta con foco a cualquiera de ellos dentro de la interfaz tmux (perfil fleetview).
|
|
||||||
|
|
||||||
Se apoya en el modo CLI de la app `fleetview` (`fleetview list` / `fleetview focus`), que opera sobre el socket tmux del perfil **desde el que se invoca el comando** (`$FLEET_SOCKET`, default `fleet`). Es decir, lista y enfoca solo los Claudes del mismo perfil en el que corres.
|
|
||||||
|
|
||||||
## Binario
|
|
||||||
|
|
||||||
Ruta: `${FN_REGISTRY_ROOT:-$HOME/fn_registry}/apps/fleetview/fleetview`
|
|
||||||
|
|
||||||
Si el binario no existe, compílalo antes de usarlo:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd "${FN_REGISTRY_ROOT:-$HOME/fn_registry}/apps/fleetview" && go build -o fleetview .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Comportamiento según `$ARGUMENTS`
|
|
||||||
|
|
||||||
### Sin argumentos → listar la flota
|
|
||||||
|
|
||||||
1. Ejecuta:
|
|
||||||
```bash
|
|
||||||
"${FN_REGISTRY_ROOT:-$HOME/fn_registry}/apps/fleetview/fleetview" list --json
|
|
||||||
```
|
|
||||||
2. Presenta el resultado como una tabla legible para el usuario, una fila por Claude, con: estado (idle/busy/waiting/shell), objetivo (`goal`), `sessionId` corto (primeros 8 caracteres), PID y window tmux.
|
|
||||||
3. Marca con claridad:
|
|
||||||
- el Claude **activo** (`active: true`) — el que está embebido en el pane derecho de la window `console`.
|
|
||||||
- la sesión actual / orquestador si la puedes identificar (su `session_id` coincide con el de quien invoca).
|
|
||||||
4. Si la lista está vacía, indícalo y sugiere que el perfil fleet podría no estar activo (revisar `$FLEET_SOCKET` y que la sesión tmux exista).
|
|
||||||
|
|
||||||
### Con argumentos → saltar con foco
|
|
||||||
|
|
||||||
El usuario quiere que la interfaz tmux salte a una conversación concreta. `$ARGUMENTS` es el query: texto del objetivo, prefijo de `sessionId`, o PID.
|
|
||||||
|
|
||||||
1. Ejecuta:
|
|
||||||
```bash
|
|
||||||
"${FN_REGISTRY_ROOT:-$HOME/fn_registry}/apps/fleetview/fleetview" focus "$ARGUMENTS"
|
|
||||||
```
|
|
||||||
2. Interpreta el exit code:
|
|
||||||
- `0`: salto hecho. Confirma al usuario a qué Claude saltó (usa la línea `→ ...` de stdout).
|
|
||||||
- `2`: query ambiguo. El binario lista los candidatos por stderr; muéstralos y pide al usuario que afine (por `sessionId` o PID).
|
|
||||||
- `1`: sin match o sin window tmux. Ejecuta `fleetview list` y muestra las opciones disponibles para que elija.
|
|
||||||
|
|
||||||
## Notas
|
|
||||||
|
|
||||||
- El salto usa el modelo de la TUI: trae el Claude elegido al pane derecho de la window `console` (con el sidebar fleetview siempre visible a la izquierda) y enfoca esa window. No es destructivo — el Claude que estuviera antes se aparca en su propia window, sigue vivo.
|
|
||||||
- El comando opera solo sobre el perfil tmux desde el que se invoca (`$FLEET_SOCKET`). Si pides un Claude que vive en otro perfil/socket, no aparecerá en la lista ni se podrá enfocar desde aquí.
|
|
||||||
- Para reabrir sesiones cerradas (`claude --resume`) usa la TUI fleetview (tecla `u`); este comando solo lista y enfoca Claudes vivos.
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,306 +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 con un prompt autonomo, aislamiento git impuesto y un DoD-contrato fijo. El humano habla solo con el orquestador, ve a los secundarios y puede saltar a cualquiera. El orquestador vigila la salud de la flota por su DoD (no por 'esta vivo'): consume la cola de eventos del watcher de fleetview, verifica los cierres con un agente comprobador independiente, empuja a los estancados, escala a la persona solo lo que pide decision, 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, 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`. El hook `hook_fleet_state_inject.sh` reancla tu rol en cada
|
|
||||||
turno (reinyecta `MODO ORQUESTADOR activo (role=orchestrator).`), así que el modo no depende
|
|
||||||
solo de que este prompt siga en contexto. Si el comportamiento se diluye, el humano puede
|
|
||||||
re-invocar `/orquestador`.
|
|
||||||
|
|
||||||
## Arranque: márcate `role=orchestrator`
|
|
||||||
|
|
||||||
**Al entrar, ANTES de confirmar, márcate `role=orchestrator`** (paso obligatorio). Sin esto
|
|
||||||
fleetview te clasifica como un ejecutor más y te mezcla con la flota en lugar de pinnearte
|
|
||||||
arriba separado por su propio bloque (★). El pin lo produce el campo `.role` del `goal.json` de
|
|
||||||
tu sesión (`apps/fleetview/cli.go::sortMembers`); nadie lo escribe por ti salvo que el launcher
|
|
||||||
de flota te haya arrancado con `--role orchestrator`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Resuelve tu PID por tu sessionId (el del goal de esta sesión) y marca el role.
|
|
||||||
SID="<tu-sessionId>" # el que aparece en el GOAL-TRACKER del prompt / tu goal.json
|
|
||||||
PID=$(grep -l "$SID" ~/.claude/sessions/*.json | head -1 | xargs -n1 basename | sed 's/\.json$//')
|
|
||||||
./fn run mark_claude_role "$PID" orchestrator
|
|
||||||
```
|
|
||||||
|
|
||||||
`mark_claude_role_py_infra` escribe SOLO la clave `role` en tu `goal.json` preservando el resto
|
|
||||||
(goal, phase, dod, dod_contract). Es idempotente. Tras marcarte, responde con una sola línea de
|
|
||||||
confirmación y queda a la espera de la tarea grande:
|
|
||||||
|
|
||||||
```
|
|
||||||
MODO ORQUESTADOR activo (role=orchestrator, pinneado arriba). 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 (flota tmux / kitty) | Lanza un sub-agente via el **Agent tool** (no interactivo) |
|
|
||||||
| Visibilidad | El humano **ve y habla** con cada secundario | 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→…→MEJORAR hasta converger, PR draft |
|
|
||||||
| Regla de referencia | esta página + `.claude/rules/orchestration.md` | `.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. Fan-out autónomo y barato sin mirar → Agent tool o
|
|
||||||
`/autopilot`; flota de Claudes interactivos que el humano supervisa → 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; frontend
|
|
||||||
vs backend; documentación vs código. Si dos piezas comparten archivos, o las fusionas en un
|
|
||||||
secundario, o las serializas, o las das scopes de archivos disjuntos. Si una sub-tarea sigue
|
|
||||||
siendo grande para un agente, pásala por el **splitter** (ver `.claude/rules/orchestration.md`).
|
|
||||||
|
|
||||||
### 2. Lanzar cada secundario
|
|
||||||
|
|
||||||
**Regla dura: cada secundario se lanza SIEMPRE como terminal visible — window de la flota tmux si
|
|
||||||
hay perfil fleet (`$FLEET_SOCKET`, lo normal), o kitty fuera de él. NUNCA como sub-agente del Agent
|
|
||||||
tool (ver paso 8).** Empieza por el bloque de flota tmux cuando estás en un perfil fleet; kitty es
|
|
||||||
el fallback para secundarios que deban vivir fuera de la flota.
|
|
||||||
|
|
||||||
Siempre con `--dangerously-skip-permissions` (memoria `lanzar-agentes-skip-permissions`): los
|
|
||||||
secundarios trabajan autónomos y desatendidos; los prompts de permiso en cada Bash los atascarían.
|
|
||||||
|
|
||||||
#### En la flota tmux (PREFERIDO en perfil fleet)
|
|
||||||
|
|
||||||
Si estás dentro de un perfil FleetView (`$FLEET_SOCKET` seteada), **NO lances kitties sueltas**:
|
|
||||||
lanza cada ejecutor como una **window de la flota tmux** con `spawn_fleet_agent`, para que viva en
|
|
||||||
la flota, se vea en la TUI `fleetview` y sea conmutable con `/fleet focus`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./fn run spawn_fleet_agent --socket "$FLEET_SOCKET" --session "$FLEET_SESSION" \
|
|
||||||
--cwd <dir-aislado> --prompt-file /tmp/orq_<slug>.md --title "<subtarea>" \
|
|
||||||
--parent "$MI_SESSION_ID"
|
|
||||||
# devuelve el window_id; despues escribe el DoD-contrato del ejecutor:
|
|
||||||
./fn run set_dod_contract <sessionId-del-ejecutor> "<DoD golden+edge+error>" pending
|
|
||||||
```
|
|
||||||
|
|
||||||
- `spawn_fleet_agent_bash_infra` crea la window tmux + arranca claude con el prompt autocontenido
|
|
||||||
(o `--skill <name>`), y con `--role executor|orchestrator` marca su `goal.json`. El aislamiento
|
|
||||||
git (sub-repo / worktree / scope) sigue imponiéndose en el prompt.
|
|
||||||
- **`--parent <mi-sessionId>` (recomendado):** escribe `parent_orchestrator` en el `goal.json` del
|
|
||||||
ejecutor atribuyéndotelo a ti. Es lo que habilita el **push activo** del watcher (te avisa en TU
|
|
||||||
pane cuando ese ejecutor termina). Sin `--parent` el aviso no se rutea. Opcional y
|
|
||||||
retro-compatible. Ver `.claude/rules/orchestration.md`.
|
|
||||||
|
|
||||||
#### Fuera de la flota (kitty fallback)
|
|
||||||
|
|
||||||
```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 (`setsid nohup kitty … zsh -ic 'claude --dangerously-skip-permissions … ; exec
|
|
||||||
zsh'`) que sobrevive al cierre de la terminal padre y deja una shell viva al terminar el claude;
|
|
||||||
devuelve el log de arranque (`/tmp/orq_<slug>_kitty.log`). Usa kitty solo fuera de un perfil fleet.
|
|
||||||
|
|
||||||
### 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 06/06/2026). Por eso **cada secundario trabaja en un espacio aislado**, y el orquestador
|
|
||||||
elige cuál y se lo **impone** en el prompt:
|
|
||||||
|
|
||||||
| Opción | Cómo | Cuándo |
|
|
||||||
|---|---|---|
|
|
||||||
| **(a) Sub-repo Gitea propio** | El secundario trabaja dentro de `apps/<x>/`, `analysis/<x>/`, `projects/<p>/...` — cada uno con su `.git` independiente (regla `apps_subrepo.md`) | Sub-tareas en apps/analyses/projects distintos. 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 | 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>`, **nunca** `git add -A`) | Último recurso, scopes garantizados disjuntos y sin `git checkout` 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 `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
|
|
||||||
terminal vea el estado sin abrir el report.
|
|
||||||
7. **DoD-contrato** — el criterio de aceptación **fijo y verificable** (golden + edge + error path
|
|
||||||
con evidencia ejecutable, `dod_quality.md`), redactado por ti. Va en el prompt Y se escribe en el
|
|
||||||
`goal.json` del secundario con `set_dod_contract` en cuanto conozcas su `sessionId`. Es el blanco
|
|
||||||
estable contra el que el verificador juzgará el cierre. Sin `dod_contract`, el agente es
|
|
||||||
`MAL_LANZADO`. Ver `.claude/rules/orchestration.md`.
|
|
||||||
|
|
||||||
Mira `/tmp/unibus_agent_*.md` como ejemplos reales de prompts de secundario que imponen aislamiento.
|
|
||||||
|
|
||||||
### 5. Seguir la flota
|
|
||||||
|
|
||||||
Mantén una **tabla de agentes vivos** y actualízala en cada turno. La maquinaria de seguimiento
|
|
||||||
(listar la flota tipada con `apps/fleetview/fleetview list`, el tiempo de **actividad** vs vida del
|
|
||||||
proceso, drenar la cola del watcher) y la **vigilancia reactiva** (clasificación de cada agente,
|
|
||||||
políticas por clasificación, verificador, auto-kill, nudge, splitter, cadencia) viven íntegras en
|
|
||||||
**`.claude/rules/orchestration.md`**. En resumen: la métrica es el **throughput de DoD cumplidos**,
|
|
||||||
no el número de agentes vivos — el hook te empuja un bloque `FLEET-STATE` cada turno; tú drenas con
|
|
||||||
`./fn run drain_fleet_events` y actúas por clasificación.
|
|
||||||
|
|
||||||
**Vía preferida — tools MCP `fleet_*`:** si la sesión tiene el MCP `orchestrator` conectado (lo
|
|
||||||
normal: está en `.mcp.json`), usa sus 6 tools — `mcp__orchestrator__fleet_list` / `fleet_drain` /
|
|
||||||
`fleet_classify` / `fleet_set_dod` / `fleet_kill` / `fleet_spawn` — en lugar de los `./fn run`
|
|
||||||
equivalentes: permisos pre-aprobados y salida estructurada, y `fleet_list` expone `role`/`dod_*`
|
|
||||||
directamente. El `./fn run` (y el binario `fleetview` para el listado) es el fallback CLI. Mapa
|
|
||||||
completo op→tool en `.claude/rules/orchestration.md`.
|
|
||||||
|
|
||||||
### 6. Parar un ejecutor — NUNCA `pkill`/`killall claude` (canónica)
|
|
||||||
|
|
||||||
Un `pkill claude` o `killall claude` **te mata a ti mismo** (el orquestador) junto con la flota.
|
|
||||||
Para parar un ejecutor:
|
|
||||||
|
|
||||||
- **`kill_fleet_agent` (preferido)** tras verificar `met`: SIGTERM al claude + cierra su window tmux,
|
|
||||||
con guards anti-orquestador y anti-self. Es el auto-kill que libera el slot idle (ver
|
|
||||||
`.claude/rules/orchestration.md`).
|
|
||||||
- **Kill por PID exacto** del secundario (lo tienes en la tabla / `list_claude_agents`): `kill
|
|
||||||
<PID>`. Verifica que NO es tu `SELF`.
|
|
||||||
- **`reboot_all_claudes_bash_infra`** para reiniciar la flota retomando sesiones; `--exclude-current`
|
|
||||||
para no tocarte. 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 terminal, o tú le mandas otro prompt / nudge).
|
|
||||||
2. **Mergea si procede** desde el **working tree principal** (ahí suele estar `master`):
|
|
||||||
`git -C ~/fn_registry merge --no-ff <rama>` para apps con TBD, o el flujo del 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. Cómo lanzar un agente: SIEMPRE terminal del fleet, NUNCA Agent tool (canónica)
|
|
||||||
|
|
||||||
**Todo agente de trabajo va como terminal visible del fleet, NUNCA como sub-agente headless del Agent tool.** Un sub-agente headless corre invisible: no sale en `fleetview`, no es conmutable con `/fleet focus` ni se puede retomar. Jerarquía al lanzar un agente:
|
|
||||||
|
|
||||||
1. **En perfil fleet** (`$FLEET_SOCKET`, lo normal) → `spawn_fleet_agent` (window de la flota tmux).
|
|
||||||
2. **Fuera de un perfil fleet** → kitty con `launch_claude_agent_kitty`.
|
|
||||||
3. **Agent tool (sub-agente headless)** → **PROHIBIDO para lanzar un agente de trabajo.** SOLO para
|
|
||||||
utilidades internas read-only tuyas que devuelven un resultado y mueren: el **verificador**
|
|
||||||
adversarial de un cierre, el **splitter** (`Plan`), o una búsqueda puntual (`Explore`).
|
|
||||||
|
|
||||||
Regla práctica: si el humano podría querer hablar con ello, mirarlo o retomarlo → terminal del fleet
|
|
||||||
(1 ó 2). Si es consulta efímera que TÚ haces para decidir y nadie más ve → Agent tool (3). Ante la
|
|
||||||
duda, terminal del fleet.
|
|
||||||
|
|
||||||
## Reglas duras del modo
|
|
||||||
|
|
||||||
- **Responde CONCISO — velocidad de iteración sobre detalle.** Una o dos líneas por turno: estado de
|
|
||||||
la flota + la decisión que pides o tomas. Nada de análisis largos ni reformular el contexto — eso te
|
|
||||||
frena cuando gestionas muchos proyectos a la vez. Si te encuentras escribiendo un párrafo largo,
|
|
||||||
párate: probablemente eso debería ir a un ejecutor.
|
|
||||||
- **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? (Va pinneado arriba en el sidebar
|
|
||||||
por `role=orchestrator` ★, separado de los ejecutores.)
|
|
||||||
- **Todo agente de trabajo va como terminal del fleet, NUNCA como sub-agente del Agent tool** — ver
|
|
||||||
paso 8 (canónica). El Agent tool queda solo para utilidades internas read-only tuyas.
|
|
||||||
- **Cada secundario, su aislamiento.** Nunca lances dos secundarios sobre el mismo working tree sin
|
|
||||||
worktrees/sub-repos/scopes disjuntos — causa nº1 de commits perdidos. Su prompt lleva SIEMPRE las
|
|
||||||
reglas de aislamiento (dir, qué NO tocar, rama, cómo commitear). Nunca `git add -A` salvo dir
|
|
||||||
exclusivamente suyo (worktree/sub-repo).
|
|
||||||
- **Tope de fan-out: máximo 6 ejecutores `role=executor` activos a la vez** por orquestador. Al
|
|
||||||
alcanzarlo, encola el resto hasta que un slot se libere (ejecutor `met` + `kill_fleet_agent`).
|
|
||||||
Detalle y justificación en `.claude/rules/orchestration.md`.
|
|
||||||
- **Nunca `pkill`/`killall claude`** — ver paso 6 (canónica). Kill dirigido (`kill_fleet_agent`), 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 dirigido / por PID exacto / `reboot_all_claudes --exclude-current` (paso 6) |
|
|
||||||
| 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 un agente de trabajo con el Agent tool | Corre invisible (paso 8) | `spawn_fleet_agent` o kitty; Agent tool SOLO para utilidades read-only |
|
|
||||||
| 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` |
|
|
||||||
|
|
||||||
## 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 (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 + DoD-contrato):
|
|
||||||
# /tmp/orq_health.md → trabaja en apps/kanban (sub-repo propio), rama issue/health, push, report.
|
|
||||||
# /tmp/orq_capdoc.md → trabaja SOLO en /tmp/orq_capdoc (worktree), rama orq/cap-deploy, push, report.
|
|
||||||
|
|
||||||
# 4. Lanzar ambos (window de la flota si hay $FLEET_SOCKET; aquí kitty fallback). Tras conocer su
|
|
||||||
# sessionId, escribe su DoD-contrato con set_dod_contract.
|
|
||||||
./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 cada turno: drena FLEET-STATE, verifica DICE_TERMINADO, nudge a ESTANCADO, lee reports/ (maquinaria en orchestration.md).
|
|
||||||
|
|
||||||
# 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.
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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 terminal 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 dirigido / por PID exacto, nunca `pkill`
|
|
||||||
(paso 6).
|
|
||||||
|
|
||||||
## Relación con otras reglas
|
|
||||||
|
|
||||||
- `.claude/rules/orchestration.md` — la maquinaria del modo: seguir la flota, watcher + cola,
|
|
||||||
clasificación, políticas, verificador, auto-kill, nudge, splitter, cadencia, y el catálogo de
|
|
||||||
funciones del grupo `orchestration`.
|
|
||||||
- `.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.
|
|
||||||
- `.claude/rules/reports.md` + `.claude/rules/dod_quality.md` — qué entrega cada secundario.
|
|
||||||
- `.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`.
|
|
||||||
@@ -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 |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 20 | [artefactos.md](artefactos.md) | Termino paraguas para apps, analysis, vaults, projects y playgrounds (todo lo que no es codigo reutilizable) |
|
||||||
@@ -39,7 +39,3 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
|||||||
| 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 |
|
| 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. |
|
| 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. |
|
| 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. |
|
|
||||||
| 38 | [orchestration.md](orchestration.md) | Maquinaria del modo `/orquestador`: seguir la flota (fleetview, tiempo de actividad), cola del watcher (events.jsonl, push activo, FLEET-STATE), clasificacion (`classify_fleet_termination`), politicas por clasificacion, verificador adversarial de cierres, auto-kill (`kill_fleet_agent`), nudge, splitter, cadencia + catalogo de funciones del grupo `orchestration`. Tope de fan-out=6. Flow 0012. |
|
|
||||||
|
|||||||
@@ -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.
|
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
|
### 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.
|
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
|
## 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:
|
Tipos de artefacto:
|
||||||
|
|
||||||
@@ -11,7 +11,6 @@ Tipos de artefacto:
|
|||||||
| **vault** | `projects/<p>/vaults/<v>` (symlink) | tabla `vaults` | no (datos fuera del repo) |
|
| **vault** | `projects/<p>/vaults/<v>` (symlink) | tabla `vaults` | no (datos fuera del repo) |
|
||||||
| **project** | `projects/<p>/` | tabla `projects` | no (vive dentro de fn_registry) |
|
| **project** | `projects/<p>/` | tabla `projects` | no (vive dentro de fn_registry) |
|
||||||
| **playground** | `<artefacto_padre>/playground/` | NO se indexa | no (vive dentro del padre) |
|
| **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:
|
Caracteristicas comunes de los artefactos:
|
||||||
- NO son codigo reutilizable. La reutilizacion vive en `functions/`.
|
- 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).
|
- `pc_locations` los unifica via `entity_type` (app, analysis, project, vault).
|
||||||
- Pueden importar funciones del registry; el registry NUNCA importa de un artefacto.
|
- 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
|
### Cuando usar el termino
|
||||||
|
|
||||||
Usa "artefacto" cuando hablas de varios tipos a la vez o cuando la afirmacion aplica a todos:
|
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)
|
### 6. Sub-repo Gitea (TBD obligatorio)
|
||||||
|
|
||||||
Cada app C++ es su propio repo en `dataforge/<name>` con branch `master`. Esto significa:
|
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.
|
- 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`.
|
- 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`.
|
- Sync entre PCs y push/pull se gestionan con `/full-git-push` y `/full-git-pull`.
|
||||||
|
|||||||
@@ -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.
|
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
|
### 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.
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
## Maquinaria del modo orquestador: vigilancia reactiva de la flota
|
|
||||||
|
|
||||||
Esta regla recoge la **maquinaria estable** del modo `/orquestador` (`.claude/commands/orquestador.md`):
|
|
||||||
cómo se sigue la flota, cómo se consume la cola del watcher, cómo se clasifica cada agente y qué
|
|
||||||
política se aplica a cada clasificación, el verificador adversarial de cierres, el auto-kill, el
|
|
||||||
nudge, el splitter, la cadencia, y el catálogo de funciones del registry del grupo `orchestration`.
|
|
||||||
|
|
||||||
El comando `/orquestador` se queda con la doctrina y el flujo de cada turno; el detalle operativo
|
|
||||||
vive aquí para que el prompt del comando sea corto y la maquinaria no se diluya. El cerebro reactivo
|
|
||||||
de esta regla corresponde al flow 0012.
|
|
||||||
|
|
||||||
### Seguir la flota — listado y tiempo
|
|
||||||
|
|
||||||
La fuente de verdad del mapeo PID→sessionId→cwd son los archivos `~/.claude/sessions/<PID>.json`
|
|
||||||
(memoria `claude-session-pid-mapping`). Para listar la flota de Claudes vivos:
|
|
||||||
|
|
||||||
```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>`).
|
|
||||||
|
|
||||||
**Flota tipada (goal/phase/window/age) — usa el binario `fleetview`, NO `fn run`.** La flota con
|
|
||||||
`goal`, `phase`, `status`, `tmux_window` y `age`/`idle_seconds` la da el CLI de la app fleetview:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
apps/fleetview/fleetview list --json # flota tipada: session_id, goal, phase, status, tmux_window, age, idle_seconds
|
|
||||||
apps/fleetview/fleetview list # tabla legible (incluye columna AGE)
|
|
||||||
```
|
|
||||||
|
|
||||||
Nota: **NO** uses `./fn run list_claude_fleet` — `list_claude_fleet_go_infra` es una función Go con
|
|
||||||
tests, así que `fn run` la despacha como `go test` (corre la suite, no imprime la flota). La vía
|
|
||||||
ejecutable es el binario `apps/fleetview/fleetview` (el atajo `/fleet` del humano envuelve este mismo
|
|
||||||
CLI). El JSON de `fleetview list` **ya incluye** `role`/`dod_contract`/`dod_status` (además de
|
|
||||||
`tmux_window`): el binario los serializa directamente (`""` cuando el `goal.json` no los declara,
|
|
||||||
ver `apps/fleetview/cli.go`). El tool MCP `fleet_list` (ver abajo) además rellena los que el binario
|
|
||||||
deje vacíos leyéndolos del sidecar `~/.claude/goals/<session_id>.json`, así que con el MCP nunca te
|
|
||||||
faltan. Ya no hace falta leer el sidecar a mano salvo que uses el binario crudo y el campo venga vacío.
|
|
||||||
|
|
||||||
**Tiempo — usa el de ACTIVIDAD, no el del proceso.** Para "cuánto lleva cada agente" usa la columna
|
|
||||||
`AGE` de `fleetview list` (o `age`/`idle_seconds` en `--json`): es el tiempo desde su última
|
|
||||||
actividad (proxy de cuánto lleva sin avanzar / en su estado), lo útil para detectar estancados. El
|
|
||||||
`etime` de `list_claude_agents` es la **vida del proceso** (cuánto lleva la terminal abierta, p.ej.
|
|
||||||
8h) — NO es el tiempo de la tarea; nunca lo reportes como progreso.
|
|
||||||
|
|
||||||
### Vía preferida: tools MCP `fleet_*` (`orchestrator_mcp`)
|
|
||||||
|
|
||||||
El MCP `orchestrator` (registrado en `.mcp.json` como `orchestrator`, binario
|
|
||||||
`apps/orchestrator_mcp/orchestrator_mcp`) expone la maquinaria de la flota como **6 tools** que
|
|
||||||
envuelven las mismas funciones del registry. **En una sesión con `orchestrator_mcp` conectado,
|
|
||||||
prefiere los tools `mcp__orchestrator__fleet_*` sobre `./fn run`**: tienen permisos pre-aprobados,
|
|
||||||
devuelven salida estructurada y se registran en la telemetría como cualquier MCP (regla
|
|
||||||
`registry_calls.md`). El `./fn run` (o el binario `fleetview` para el listado) sigue siendo el
|
|
||||||
**fallback CLI** cuando el MCP no está conectado. Mapa de cada operación de la flota a su tool:
|
|
||||||
|
|
||||||
| Operación de la flota | Tool MCP (preferido) | Fallback `./fn run` / binario |
|
|
||||||
|---|---|---|
|
|
||||||
| Listar la flota tipada (session_id, goal, phase, status, **role, dod_contract, dod_status**, tmux_window, age, idle_seconds) | `mcp__orchestrator__fleet_list` | `apps/fleetview/fleetview list --json` (NO `./fn run list_claude_fleet`) |
|
|
||||||
| Drenar la cola de transiciones del watcher (agrupada por clasificación + urgentes) | `mcp__orchestrator__fleet_drain` (`advance` true consume, false hace peek) | `./fn run drain_fleet_events` |
|
|
||||||
| Clasificar el estado de terminación de UN agente (RECLAMA/MAL_LANZADO/DICE_TERMINADO/ESTANCADO/TRABAJANDO) | `mcp__orchestrator__fleet_classify` | (Go con tests; lo consume el watcher, no se invoca a mano) |
|
|
||||||
| Escribir el DoD-contrato fijo (`dod_contract`/`dod_status`) en el `goal.json` de un agente | `mcp__orchestrator__fleet_set_dod` | `./fn run set_dod_contract` |
|
|
||||||
| Cerrar dirigido UN ejecutor (auto-kill: SIGTERM + kill-window, con guards) | `mcp__orchestrator__fleet_kill` (`dry_run` para ver el plan) | `./fn run kill_fleet_agent` |
|
|
||||||
| Lanzar un ejecutor como window de la flota tmux (con `parent` para el push) | `mcp__orchestrator__fleet_spawn` | `./fn run spawn_fleet_agent` |
|
|
||||||
|
|
||||||
Ventaja extra de `fleet_list`: expone `role`/`dod_contract`/`dod_status` directamente (y rellena los
|
|
||||||
vacíos desde el sidecar `goal.json`), así que la regla "No te vigiles a ti mismo" se resuelve sin leer
|
|
||||||
el sidecar a mano — filtra por el `role` que ya trae cada fila.
|
|
||||||
|
|
||||||
Mantén una **tabla de seguimiento**, una fila por secundario, y actualízala en cada turno:
|
|
||||||
|
|
||||||
| 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`).
|
|
||||||
|
|
||||||
### El cerebro reactivo: vigilar la salud por el DoD
|
|
||||||
|
|
||||||
Seguir la flota no es solo "¿quién vive?". Es **vigilar la salud por el DoD**: cada agente termina lo
|
|
||||||
que empieza, o sabes por qué no. La métrica es el **throughput de DoD cumplidos**, no el número de
|
|
||||||
agentes vivos — 30 agentes que no cierran nada no sirven. La fuente es la cola del **watcher embebido
|
|
||||||
en fleetview** (`~/.claude/fleet/events.jsonl`): una línea por **transición** de estado de un agente
|
|
||||||
(edge-triggered, sin ruido de nivel). El orquestador la drena cada vez que actúa y aplica una política
|
|
||||||
por clasificación.
|
|
||||||
|
|
||||||
#### DoD-contrato fijo al lanzar (regla dura)
|
|
||||||
|
|
||||||
Ningún secundario arranca sin **DoD-contrato**: el criterio de aceptación FIJO contra el que se evalúa
|
|
||||||
su terminación. Es distinto del campo `dod` del statusline (texto corto identificativo de la
|
|
||||||
terminal). **Desde 2026-06-21 ese `dod` ya NO se regenera con un LLM en cada turno**: el hook
|
|
||||||
`goal_refine.sh` que lo reescribía con haiku por prompt quedó desactivado (amplificaba el rate-limit
|
|
||||||
compartido). El objetivo+DoD inicial los fija `goal_autogen.sh` **una sola vez** por terminal; a partir
|
|
||||||
de ahí son fijos y el usuario los ajusta a mano con `objetivo: ...` / `dod: ...`. El criterio que
|
|
||||||
clasifica la flota es `dod_contract` + `dod_status` (lo escribe `set_dod_contract`, sin LLM), no ese
|
|
||||||
`dod` móvil. Tras lanzar y conocer el `sessionId`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./fn run set_dod_contract <sessionId> "Golden: <caso feliz+evidencia>. Edge: <2 bordes>. Error: <1 fallo manejado>." pending
|
|
||||||
```
|
|
||||||
|
|
||||||
El contrato sigue `dod_quality.md` (golden + edge + error con evidencia ejecutable), no un checkbox
|
|
||||||
vago. Sin él, el agente es `MAL_LANZADO`.
|
|
||||||
|
|
||||||
#### Push automático: el bloque `FLEET-STATE`
|
|
||||||
|
|
||||||
No hace falta acordarse de drenar para enterarse de un cambio. El hook `UserPromptSubmit`
|
|
||||||
`hook_fleet_state_inject.sh` (registrado en `.claude/settings.local.json`) inyecta en CADA turno del
|
|
||||||
orquestador —solo cuando la sesión es `role=orchestrator`— una línea recordatorio del rol
|
|
||||||
(`MODO ORQUESTADOR activo (role=orchestrator).`, que reancla el modo aunque su prompt se haya
|
|
||||||
diluido del contexto) seguida de un bloque resumen de las transiciones pendientes del watcher:
|
|
||||||
|
|
||||||
```
|
|
||||||
FLEET-STATE: terminados=[<sid>:<goal>…] reclaman=[…] estancados=[…] (drain con ./fn run drain_fleet_events para consumir)
|
|
||||||
```
|
|
||||||
|
|
||||||
Si no hay cambios emite `FLEET-STATE: sin cambios`; si el watcher está caído o el `events.jsonl` no
|
|
||||||
existe, degrada limpio sin romper el turno (la línea de rol se sigue emitiendo). El bloque es solo un
|
|
||||||
**aviso** (hace peek, no avanza el cursor): para consumir las transiciones y aplicar la política por
|
|
||||||
clasificación sigues drenando (abajo). El resumen lo produce `summarize_fleet_transitions_py_infra`
|
|
||||||
sobre el feed del watcher.
|
|
||||||
|
|
||||||
Gotcha conocido: el bloque `FLEET-STATE` (peek pasivo) lista transiciones de TODA la flota, incluidas
|
|
||||||
las de otros orquestadores y sus ejecutores. Si hay más de un orquestador activo, filtra por tu propia
|
|
||||||
familia de agentes (los que tú lanzaste) — igual que en "No te vigiles a ti mismo" más abajo. El **push
|
|
||||||
activo** (siguiente apartado) sí está ya ruteado por familia.
|
|
||||||
|
|
||||||
#### Push activo del watcher — send-keys dirigido (routing por `parent_orchestrator`)
|
|
||||||
|
|
||||||
Además del aviso pasivo en cada turno, el **watcher de fleetview** empuja activamente: cuando un
|
|
||||||
ejecutor transita a `DICE_TERMINADO`, hace `tmux send-keys` **directamente al pane del orquestador que
|
|
||||||
lo lanzó**, para que el cierre no espere a tu siguiente turno. El ruteo se resuelve por la clave
|
|
||||||
`parent_orchestrator` del `goal.json` del ejecutor — la que escribe `spawn_fleet_agent --parent
|
|
||||||
<tu-sessionId>`. Por eso **lanza siempre tus ejecutores con `--parent`**: sin esa clave el watcher no
|
|
||||||
sabe a qué pane mandar el aviso y el cierre queda solo en el peek pasivo (toda la flota). Con
|
|
||||||
`--parent`, cada familia de agentes avisa a su propio orquestador y desaparece el ruido cruzado entre
|
|
||||||
orquestadores.
|
|
||||||
|
|
||||||
#### Indicador "idle nuevo sin ver" en la TUI fleetview
|
|
||||||
|
|
||||||
La TUI `fleetview` marca de forma distinguible los ejecutores que **acaban de quedar idle y que aún no
|
|
||||||
has atendido** (idle nuevo sin ver), para que el humano y el orquestador localicen de un vistazo qué
|
|
||||||
agentes reclaman acción frente a los que ya están en seguimiento. Es la señal visual hermana del push
|
|
||||||
del watcher: el push te lo trae a la terminal, el indicador lo resalta en la lista. Úsalo como
|
|
||||||
disparador para drenar la cola y aplicar la política por clasificación (verificar `DICE_TERMINADO`,
|
|
||||||
nudge a `ESTANCADO`).
|
|
||||||
|
|
||||||
### Drenar la cola
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./fn run drain_fleet_events # consume nuevos (avanza cursor), agrupa por clasificación, marca urgentes
|
|
||||||
./fn run drain_fleet_events --advance false # peek sin consumir (inspección)
|
|
||||||
```
|
|
||||||
|
|
||||||
Devuelve `{total_new, events, by_classification, urgent, cursor}`. La clasificación de cada agente la
|
|
||||||
produce `classify_fleet_termination` (pura) desde su estado (status + phase + dod_contract +
|
|
||||||
dod_status + segundos ociosos).
|
|
||||||
|
|
||||||
**No te vigiles a ti mismo.** Al procesar la cola, **ignora** los eventos de tu propia sesión y de
|
|
||||||
cualquier agente con `role=orchestrator`. El `role` ya viene en cada fila de `fleet_list` (y de
|
|
||||||
`fleetview list --json`), así que filtras directamente por ese campo. Solo si usas el binario crudo y
|
|
||||||
la fila trae `role` vacío, cae al sidecar del goal de cada `session_id`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Preferido: filtrar por el role que ya trae fleet_list / fleetview list --json.
|
|
||||||
apps/fleetview/fleetview list --json | jq -r '.[] | select((.role // "executor") != "orchestrator") | .session_id'
|
|
||||||
# Fallback solo si el binario dejó role vacío en alguna fila:
|
|
||||||
jq -r '.role // "executor"' ~/.claude/goals/<session_id>.json # "orchestrator" => ignóralo
|
|
||||||
```
|
|
||||||
|
|
||||||
El orquestador no tiene `dod_contract` y aparecería como `MAL_LANZADO` — es ruido, no un ejecutor que
|
|
||||||
vigilar. Solo actúas sobre los **ejecutores** (`role=executor` o sin role).
|
|
||||||
|
|
||||||
### Políticas por clasificación
|
|
||||||
|
|
||||||
| Transición a… | Qué hace el orquestador |
|
|
||||||
|---|---|
|
|
||||||
| `RECLAMA` (urgent) | **Escalar a la persona**: resumen corto de QUÉ decisión se necesita + `/fleet focus <sid>` para llevarla al agente. Si no está presente, `PushNotification`. NUNCA decidir tú por ella en un RECLAMA. |
|
|
||||||
| `DICE_TERMINADO` | Lanzar **verificador independiente** (abajo). No confiar en el autodeclarado. Si `met` → cerrar con `kill_fleet_agent` (auto-kill, libera el slot idle). |
|
|
||||||
| `ESTANCADO` | **Nudge** al agente (abajo). Solo idle; jamás waiting. |
|
|
||||||
| `MAL_LANZADO` | Escribir `dod_contract` retroactivo (`set_dod_contract`) o re-lanzar con DoD. |
|
|
||||||
| `TRABAJANDO` | No molestar. |
|
|
||||||
| `GONE` | Limpiar de la tabla de seguimiento (terminó o murió; si tenía DoD sin cumplir, anótalo). |
|
|
||||||
|
|
||||||
### Verificador — cierre de `DICE_TERMINADO` (cero auto-aprobación)
|
|
||||||
|
|
||||||
Cuando un agente se autodeclara terminado, **no se confía**: lanzas un **verificador independiente**
|
|
||||||
del ejecutor (Agent efímero), que compara el **report** del ejecutor (en `reports/`, con evidencia
|
|
||||||
ejecutable) contra su `dod_contract`:
|
|
||||||
|
|
||||||
```
|
|
||||||
Agent(subagent_type="general-purpose", prompt:
|
|
||||||
"Verifica de forma ADVERSARIAL si el trabajo cumple su DoD-contrato. NO ejecutaste tú la tarea.
|
|
||||||
DoD-contrato: <contract>
|
|
||||||
Report del ejecutor: <ruta del reports/NNNN-*.md>
|
|
||||||
Comprueba CADA cláusula (golden + edge + error) contra la evidencia citada en el report; re-ejecuta
|
|
||||||
los comandos de verificación si puedes. Devuelve {verdict: met|failed, gaps: [...], evidence: [...]}.
|
|
||||||
Por defecto failed si la evidencia no respalda una cláusula.")
|
|
||||||
```
|
|
||||||
|
|
||||||
El verificador (y el splitter y las búsquedas con `Explore`) son la **única** excepción autorizada al
|
|
||||||
Agent tool dentro del modo: utilidades internas read-only del propio orquestador, que devuelven un
|
|
||||||
resultado y mueren sin que el humano las gestione como agentes de la flota. Jamás se usa el Agent tool
|
|
||||||
para ejecutar una sub-tarea (ver paso 8 del comando).
|
|
||||||
|
|
||||||
- `met` → el orquestador marca `set_dod_contract <sid> "<contract>" met`, informa a la persona y
|
|
||||||
**cierra el ejecutor para liberar el slot idle** con `kill_fleet_agent` (regla de auto-kill, abajo).
|
|
||||||
- `failed` → **nudge** al ejecutor con el gap concreto (no cerrar). `set_dod_contract <sid>
|
|
||||||
"<contract>" failed` (vuelve a pending tras el nudge si reabre trabajo).
|
|
||||||
|
|
||||||
### Auto-kill — cerrar el ejecutor tras verificar `met` (libera el slot idle)
|
|
||||||
|
|
||||||
Un ejecutor verificado `met` **no se deja vivo en reposo**: se cierra de inmediato para que no se
|
|
||||||
acumule en la flota ocupando un slot idle. En cuanto el verificador devuelve `met` y has marcado
|
|
||||||
`set_dod_contract <sid> "<contract>" met`, ciérralo:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./fn run kill_fleet_agent <sessionId> --socket "$FLEET_SOCKET"
|
|
||||||
```
|
|
||||||
|
|
||||||
`kill_fleet_agent_bash_infra` manda **SIGTERM** al proceso `claude` del ejecutor (cierre limpio,
|
|
||||||
recuperable luego con `claude --resume <sessionId>`) y cierra su window tmux (`kill-window`). Trae
|
|
||||||
**guards** que lo hacen seguro de invocar programáticamente:
|
|
||||||
|
|
||||||
- **No mata a un `role=orchestrator`** (lo lee del `goal.json`): nunca decapitas la flota por error.
|
|
||||||
- **No se mata a sí mismo**: rechaza el target si es la sesión que invoca (equivalente dirigido de la
|
|
||||||
regla "nunca `pkill claude`", paso 6 del comando).
|
|
||||||
- Acepta el target por `sessionId` (exacto o prefijo) o por PID. Usa `--dry-run` para ver el plan sin
|
|
||||||
tocar nada.
|
|
||||||
|
|
||||||
Esto cierra el ciclo del modo: lanzas con `--parent` → el watcher te avisa del `DICE_TERMINADO` →
|
|
||||||
verificas → `kill_fleet_agent` libera el slot. No uses `pkill`/`killall` ni `kill` a pelo para esto:
|
|
||||||
`kill_fleet_agent` resuelve la window y aplica los guards.
|
|
||||||
|
|
||||||
### Nudge — `ESTANCADO`
|
|
||||||
|
|
||||||
Agente idle con `dod_contract` sin cumplir y sin actividad > umbral (10 min). Empújalo a cerrar SU DoD
|
|
||||||
inyectando en su pane tmux:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tmux -L "${FLEET_SOCKET:-fleet}" send-keys -t <window_id> \
|
|
||||||
"Sigues idle con tu DoD-contrato sin cerrar. Falta: <gap>. Cierra el golden+edge+error con evidencia, o reporta el bloqueo concreto." Enter
|
|
||||||
```
|
|
||||||
|
|
||||||
El `window_id` es el campo `tmux_window` (p.ej. `@20`) de `apps/fleetview/fleetview list --json`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
apps/fleetview/fleetview list --json | jq -r '.[] | select(.session_id|startswith("<sid>")) | .tmux_window'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Solo a idle/ESTANCADO. JAMÁS a un agente en `waiting`/`preguntando`** — esos te reclaman a TI, no un
|
|
||||||
empujón del bot.
|
|
||||||
|
|
||||||
### Splitter — tarea demasiado grande
|
|
||||||
|
|
||||||
Si una sub-tarea sigue siendo grande para un solo agente, antes de lanzarla pásala por un **splitter**
|
|
||||||
(Agent efímero) que devuelve un plan de sub-tareas atómicas, cada una con su `dod_contract` y sus
|
|
||||||
dependencias:
|
|
||||||
|
|
||||||
```
|
|
||||||
Agent(subagent_type="Plan", prompt:
|
|
||||||
"Descompón esta tarea en sub-tareas ATÓMICAS, cada una cerrable por UN agente en una sesión, con
|
|
||||||
su propio DoD-contrato (golden+edge+error) y dependencias (cuáles son paralelas y cuáles
|
|
||||||
secuenciales). Máximo 6 sub-tareas. Tarea: <...>. Devuelve [{tarea, dod_contract, deps:[...]}].")
|
|
||||||
```
|
|
||||||
|
|
||||||
El orquestador lanza un ejecutor por sub-tarea respetando las dependencias (paralelas a la vez,
|
|
||||||
secuenciales encadenadas), **siempre dentro del tope de fan-out** (ver "Tope de fan-out" abajo).
|
|
||||||
|
|
||||||
### Tope de fan-out (regla dura)
|
|
||||||
|
|
||||||
**Máximo 6 ejecutores `role=executor` activos simultáneos por orquestador.** Si se alcanza el tope,
|
|
||||||
el orquestador NO lanza más: **encola** las sub-tareas restantes y las despacha a medida que un slot
|
|
||||||
se libera — un slot se libera cuando un ejecutor se verifica `met` y se cierra con `kill_fleet_agent`
|
|
||||||
(auto-kill). El conteo es de la **familia propia** (ejecutores con tu `parent_orchestrator`), no de
|
|
||||||
toda la flota; resuélvelo con el routing por `parent_orchestrator`, igual que el push activo.
|
|
||||||
|
|
||||||
Por qué un número duro y no "los que hagan falta": ya hubo el caso de **30 agentes que no cerraban
|
|
||||||
nada** y, al competir todos por el mismo rate-limit compartido, hubo que desactivar `goal_refine`
|
|
||||||
(el hook que reescribía el `dod` con un LLM por prompt). Más ejecutores no es más throughput: el
|
|
||||||
cuello de botella es el rate-limit compartido y los DoD que nadie cierra, no el número de procesos.
|
|
||||||
|
|
||||||
### Cadencia
|
|
||||||
|
|
||||||
El orquestador no hace polling caro: drena la cola **cuando actúa** (cuando la persona le habla) y,
|
|
||||||
para vigilancia desatendida, con un heartbeat largo (`ScheduleWakeup` 20-30 min) o cuando el watcher
|
|
||||||
empuja un urgente. Lo urgente (`RECLAMA`) sube al instante; el resto (cierres, estancados) se procesa
|
|
||||||
en lote.
|
|
||||||
|
|
||||||
## Funciones del registry del 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 |
|
|
||||||
| `set_dod_contract_py_infra` | Escribir el DoD-contrato fijo (`dod_contract`/`dod_status`) en el `goal.json` de un secundario al lanzarlo |
|
|
||||||
| `drain_fleet_events_py_infra` | Consumir la cola de transiciones del watcher (`~/.claude/fleet/events.jsonl`), agrupada por clasificación + urgentes |
|
|
||||||
| `summarize_fleet_transitions_py_infra` | Resumir las transiciones del feed en una línea (`terminados/reclaman/estancados`); alimenta el bloque `FLEET-STATE` que el hook `UserPromptSubmit` inyecta cada turno |
|
|
||||||
| `classify_fleet_termination_go_infra` | Clasificar el estado de terminación de un agente (RECLAMA/MAL_LANZADO/DICE_TERMINADO/ESTANCADO/TRABAJANDO) — lo usa el watcher |
|
|
||||||
| `list_claude_fleet_go_infra` | Fleet tipado con goal/phase/`role` + `dod_contract`/`dod_status` + `tmux_window` (alimenta `/fleet`, el watcher y el tool `fleet_list`). **Invócala por el tool `mcp__orchestrator__fleet_list` (preferido) o el binario `apps/fleetview/fleetview list --json`**, NUNCA por `./fn run` (la despacha como `go test`). El JSON del CLI **ya expone** `role`/`dod_contract`/`dod_status` (`""` si el `goal.json` no los declara); el tool MCP además rellena los vacíos desde `~/.claude/goals/<session_id>.json` |
|
|
||||||
| `spawn_fleet_agent_bash_infra` | Lanzar un ejecutor (o el orquestador) como window de la flota tmux — preferido sobre kitty cuando hay perfil fleet. `--parent <tu-sessionId>` atribuye el ejecutor a ti y habilita el push activo del watcher |
|
|
||||||
| `mark_claude_role_py_infra` | Marcar `role` (orchestrator/executor) en el goal.json de un Claude resolviendo PID→sessionId |
|
|
||||||
| `mark_claude_parent_py_infra` | Marcar `parent_orchestrator` (sessionId del orquestador que lo lanzó) en el goal.json de un ejecutor resolviendo PID→sessionId. Lo invoca `spawn_fleet_agent --parent`; habilita el routing del watcher al pane del orquestador padre |
|
|
||||||
| `kill_fleet_agent_bash_infra` | Cierre dirigido de UN ejecutor: SIGTERM al claude + kill-window de su window tmux. Guards anti-orquestador y anti-self. Lo usa el orquestador para liberar el slot idle tras verificar `met` (auto-kill) |
|
|
||||||
| `notify_desktop_go_infra` | Notificación de escritorio del fleet (`notify-send --app-name=fleetview`, degradación silenciosa si no hay `notify-send`). La usa el orquestador/watcher para avisar a la persona de un `RECLAMA` u otro evento urgente cuando no está mirando la terminal |
|
|
||||||
|
|
||||||
**Cómo invocarlas.** Las Bash y Python del grupo se lanzan con `./fn run <id> [args]` (verificado:
|
|
||||||
`list_claude_agents`, `drain_fleet_events`, `reboot_all_claudes`, `set_dod_contract`,
|
|
||||||
`mark_claude_role`, `mark_claude_parent`, `kill_fleet_agent`, `launch_claude_agent_kitty`,
|
|
||||||
`spawn_fleet_agent`). Las **Go con tests** NO: `./fn run` las despacha como `go test`. Por eso
|
|
||||||
`list_claude_fleet_go_infra` se usa por el binario `apps/fleetview/fleetview list --json`, y
|
|
||||||
`classify_fleet_termination_go_infra` la consume el watcher embebido en fleetview (no se invoca a
|
|
||||||
mano).
|
|
||||||
|
|
||||||
## Relación con otras reglas
|
|
||||||
|
|
||||||
- `.claude/commands/orquestador.md` — la doctrina y el flujo de cada turno del modo; esta regla es su
|
|
||||||
maquinaria operativa.
|
|
||||||
- `.claude/rules/autonomous_loop.md` — `fn-orquestador` (Agent tool, sandbox no-interactivo). Es lo
|
|
||||||
que el modo orquestador **no** es.
|
|
||||||
- `.claude/rules/apps_subrepo.md` — apps/analyses/projects son sub-repos Gitea (`apps/*` gitignored):
|
|
||||||
el aislamiento natural y el gotcha de `git init` antes de limpiar un worktree con una app nueva.
|
|
||||||
- `.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`.
|
|
||||||
- Memorias: `lanzar-agentes-skip-permissions`, `multi-agent-git-race-same-repo`,
|
|
||||||
`claude-session-pid-mapping`, `prefiere-kitty-terminal`.
|
|
||||||
@@ -28,23 +28,6 @@ projects/{nombre}/
|
|||||||
- `vault.yaml` lista los vaults con nombre, descripcion, path absoluto y tags
|
- `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
|
- 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
|
- `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
|
- Apps y analyses sueltos (sin proyecto) siguen en `apps/` y `analysis/` en la raiz
|
||||||
|
|
||||||
### Raiz vs proyecto
|
### 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,77 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Hook UserPromptSubmit: inyecta el estado de la flota al Claude orquestador.
|
|
||||||
#
|
|
||||||
# En el modo /orquestador, el Claude principal gestiona una flota de agentes y
|
|
||||||
# necesita enterarse de forma reactiva cuando uno cambia de estado: termina
|
|
||||||
# (DICE_TERMINADO), reclama una decision (RECLAMA) o se estanca (ESTANCADO).
|
|
||||||
# El watcher de fleetview escribe esas transiciones a la cola JSONL
|
|
||||||
# ~/.claude/fleet/events.jsonl. Este hook hace un peek de esa cola en cada turno
|
|
||||||
# y emite un bloque "FLEET-STATE:" para que el orquestador vea los cambios
|
|
||||||
# pendientes sin tener que drenar la cola a mano.
|
|
||||||
#
|
|
||||||
# Entrada (stdin JSON del hook UserPromptSubmit): { session_id, cwd, ... }
|
|
||||||
# El stdout de este script se inyecta como additionalContext en el turno.
|
|
||||||
#
|
|
||||||
# Solo el orquestador recibe el feed: se identifica leyendo el campo `role` de
|
|
||||||
# ~/.claude/goals/<session_id>.json (lo marca `mark_claude_role`). Cualquier
|
|
||||||
# sesion que no sea role=orchestrator termina en silencio (sin stdout).
|
|
||||||
#
|
|
||||||
# El peek usa advance=False: NO mueve el cursor de la cola. El orquestador sigue
|
|
||||||
# viendo los mismos eventos pendientes cada turno hasta que los consume
|
|
||||||
# explicitamente con `./fn run drain_fleet_events` (que si avanza el cursor).
|
|
||||||
#
|
|
||||||
# Degradacion limpia: si falta jq/python/venv, si la cola no existe, o si el
|
|
||||||
# watcher esta caido, el hook nunca rompe el turno (siempre exit 0).
|
|
||||||
set -u
|
|
||||||
|
|
||||||
command -v jq >/dev/null 2>&1 || exit 0
|
|
||||||
|
|
||||||
INPUT=$(cat)
|
|
||||||
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // ""' 2>/dev/null)
|
|
||||||
[ -z "$SESSION_ID" ] && exit 0
|
|
||||||
|
|
||||||
GOAL_FILE="$HOME/.claude/goals/${SESSION_ID}.json"
|
|
||||||
ROLE=""
|
|
||||||
[ -f "$GOAL_FILE" ] && ROLE=$(jq -r '.role // ""' "$GOAL_FILE" 2>/dev/null)
|
|
||||||
|
|
||||||
# Solo el orquestador recibe el feed de la flota. Resto: silencio total.
|
|
||||||
[ "$ROLE" != "orchestrator" ] && exit 0
|
|
||||||
|
|
||||||
# Reanclar el rol en cada turno: el modo /orquestador no debe depender solo de
|
|
||||||
# que su prompt (.claude/commands/orquestador.md) siga en contexto. Este
|
|
||||||
# recordatorio se reinyecta aunque el watcher este caido o falte el venv (la
|
|
||||||
# guarda de abajo saldria con exit 0 sin emitir FLEET-STATE). Se emite SOLO para
|
|
||||||
# role=orchestrator: las sesiones sin goal.json o sin ese rol ya salieron arriba
|
|
||||||
# con exit 0 y stdout vacio, asi que el path limpio queda intacto.
|
|
||||||
printf '%s\n' "MODO ORQUESTADOR activo (role=orchestrator)."
|
|
||||||
|
|
||||||
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$HOME/fn_registry}"
|
|
||||||
PY="$PROJECT_DIR/python/.venv/bin/python3"
|
|
||||||
{ [ -x "$PY" ] && [ -d "$PROJECT_DIR/python/functions" ]; } || exit 0
|
|
||||||
|
|
||||||
OUT=$(FN_PROJECT_DIR="$PROJECT_DIR" timeout 8 "$PY" - <<'PYEOF' 2>/dev/null
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
root = os.environ.get("FN_PROJECT_DIR", os.path.expanduser("~/fn_registry"))
|
|
||||||
sys.path.insert(0, os.path.join(root, "python", "functions"))
|
|
||||||
events = os.path.join(os.path.expanduser("~"), ".claude", "fleet", "events.jsonl")
|
|
||||||
|
|
||||||
try:
|
|
||||||
from infra.drain_fleet_events import drain_fleet_events
|
|
||||||
from infra.summarize_fleet_transitions import summarize_fleet_transitions
|
|
||||||
|
|
||||||
if not os.path.exists(events):
|
|
||||||
# Watcher nunca arranco o cola borrada: diagnostico explicito.
|
|
||||||
print("FLEET-STATE: cola del watcher no disponible (events.jsonl ausente)")
|
|
||||||
else:
|
|
||||||
drained = drain_fleet_events(advance=False) # peek: NO mueve el cursor
|
|
||||||
print(summarize_fleet_transitions(drained.get("by_classification", {})))
|
|
||||||
except Exception:
|
|
||||||
# Funciones no indexadas, cola corrupta, etc.: degradar sin romper el turno.
|
|
||||||
pass
|
|
||||||
PYEOF
|
|
||||||
)
|
|
||||||
|
|
||||||
[ -n "$OUT" ] && printf '%s\n' "$OUT"
|
|
||||||
exit 0
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(CGO_ENABLED=1 go test *)",
|
|
||||||
"Bash(sqlite3 *)",
|
|
||||||
"Read(//home/enmanuel/.claude/**)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_fleet_state_inject.sh"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-10
@@ -46,13 +46,6 @@ projects/*/
|
|||||||
vaults/*/
|
vaults/*/
|
||||||
!vaults/vault.yaml
|
!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 / pnpm
|
||||||
**/node_modules/
|
**/node_modules/
|
||||||
|
|
||||||
@@ -74,9 +67,8 @@ worktrees/
|
|||||||
# Temp — workspace efimero para pruebas rapidas (APIs, scripts, analisis)
|
# Temp — workspace efimero para pruebas rapidas (APIs, scripts, analisis)
|
||||||
temp/
|
temp/
|
||||||
|
|
||||||
# C++ build artifacts (build/, build-tests/, build-windows/, etc.)
|
# C++ build artifacts
|
||||||
cpp/build*/
|
cpp/build/
|
||||||
/build/
|
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -88,6 +80,7 @@ Thumbs.db
|
|||||||
broken_paths.txt
|
broken_paths.txt
|
||||||
imgui.ini
|
imgui.ini
|
||||||
prompts/
|
prompts/
|
||||||
|
kotlin/functions/ui/
|
||||||
|
|
||||||
# Module versioning auto-generated headers (written by `fn index`, issue 0097)
|
# Module versioning auto-generated headers (written by `fn index`, issue 0097)
|
||||||
**/version_generated.h
|
**/version_generated.h
|
||||||
|
|||||||
@@ -1,29 +1,22 @@
|
|||||||
[submodule "cpp/vendor/imgui"]
|
[submodule "cpp/vendor/imgui"]
|
||||||
path = cpp/vendor/imgui
|
path = cpp/vendor/imgui
|
||||||
url = https://github.com/ocornut/imgui.git
|
url = https://github.com/ocornut/imgui.git
|
||||||
shallow = true
|
|
||||||
branch = docking
|
branch = docking
|
||||||
[submodule "cpp/vendor/implot"]
|
[submodule "cpp/vendor/implot"]
|
||||||
path = cpp/vendor/implot
|
path = cpp/vendor/implot
|
||||||
url = https://github.com/epezent/implot.git
|
url = https://github.com/epezent/implot.git
|
||||||
shallow = true
|
|
||||||
[submodule "cpp/vendor/tracy"]
|
[submodule "cpp/vendor/tracy"]
|
||||||
path = cpp/vendor/tracy
|
path = cpp/vendor/tracy
|
||||||
url = https://github.com/wolfpld/tracy.git
|
url = https://github.com/wolfpld/tracy.git
|
||||||
shallow = true
|
|
||||||
[submodule "cpp/vendor/glfw"]
|
[submodule "cpp/vendor/glfw"]
|
||||||
path = cpp/vendor/glfw
|
path = cpp/vendor/glfw
|
||||||
url = https://github.com/glfw/glfw.git
|
url = https://github.com/glfw/glfw.git
|
||||||
shallow = true
|
|
||||||
[submodule "cpp/vendor/implot3d"]
|
[submodule "cpp/vendor/implot3d"]
|
||||||
path = cpp/vendor/implot3d
|
path = cpp/vendor/implot3d
|
||||||
url = https://github.com/brenocq/implot3d.git
|
url = https://github.com/brenocq/implot3d.git
|
||||||
shallow = true
|
|
||||||
[submodule "cpp/vendor/sdl3"]
|
[submodule "cpp/vendor/sdl3"]
|
||||||
path = cpp/vendor/sdl3
|
path = cpp/vendor/sdl3
|
||||||
url = https://github.com/libsdl-org/SDL.git
|
url = https://github.com/libsdl-org/SDL.git
|
||||||
shallow = true
|
|
||||||
[submodule "emsdk"]
|
[submodule "emsdk"]
|
||||||
path = emsdk
|
path = emsdk
|
||||||
url = https://github.com/emscripten-core/emsdk.git
|
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.
@@ -4,13 +4,9 @@
|
|||||||
"command": "./apps/registry_mcp/registry_mcp",
|
"command": "./apps/registry_mcp/registry_mcp",
|
||||||
"args": ["--enable-run", "--enable-write"]
|
"args": ["--enable-run", "--enable-write"]
|
||||||
},
|
},
|
||||||
"orchestrator": {
|
|
||||||
"command": "./apps/orchestrator_mcp/orchestrator_mcp",
|
|
||||||
"args": []
|
|
||||||
},
|
|
||||||
"jupyter": {
|
"jupyter": {
|
||||||
"command": "bash",
|
"command": "bash",
|
||||||
"args": ["-c", "exec bash \"$(git rev-parse --show-toplevel)/bash/functions/infra/jupyter_mcp_serve.sh\""]
|
"args": ["/home/enmanuel/fn_registry/bash/functions/infra/jupyter_mcp_serve.sh"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
---
|
|
||||||
name: start_mitm_capture
|
|
||||||
kind: function
|
|
||||||
lang: bash
|
|
||||||
domain: cybersecurity
|
|
||||||
version: "1.0.0"
|
|
||||||
purity: impure
|
|
||||||
signature: "start_mitm_capture([--port N] [--out DIR] [--rotate-min N] [--addon PATH] [--mitmdump BIN] [--log PATH]) -> string"
|
|
||||||
description: "Arranca mitmdump en modo headless en segundo plano como proxy de interceptación liviano, con rotación de capturas cada N minutos vía el addon rotate_capture_flows.py del registry. El proceso sobrevive al cierre de la shell (setsid). Emite JSON con PID, puerto, directorio de salida y ruta del log."
|
|
||||||
tags: [bash, cybersecurity, mitmproxy, proxy, capture, web-proxy, network, interception, mitmdump]
|
|
||||||
uses_functions: []
|
|
||||||
uses_types: []
|
|
||||||
returns: []
|
|
||||||
returns_optional: false
|
|
||||||
error_type: "error_go_core"
|
|
||||||
imports: []
|
|
||||||
params:
|
|
||||||
- name: "--port N"
|
|
||||||
desc: "Puerto TCP donde mitmdump escucha conexiones del cliente proxy. Default: 8080."
|
|
||||||
- name: "--out DIR"
|
|
||||||
desc: "Directorio donde se guardan los archivos .mitm rotados. Se crea automáticamente si no existe. Default: $HOME/captures."
|
|
||||||
- name: "--rotate-min N"
|
|
||||||
desc: "Minutos de duración de cada archivo de captura antes de rotar. Default: 20."
|
|
||||||
- name: "--addon PATH"
|
|
||||||
desc: "Ruta al addon Python de rotación (rotate_capture_flows.py). Default: derivado desde FN_REGISTRY_ROOT o desde la ubicación del propio script (3 niveles arriba)."
|
|
||||||
- name: "--mitmdump BIN"
|
|
||||||
desc: "Ruta al binario mitmdump. Default: autodetectado con command -v mitmdump, luego $HOME/.local/bin/mitmdump. Si no se encuentra, falla con instrucción de instalación."
|
|
||||||
- name: "--log PATH"
|
|
||||||
desc: "Archivo de log del proceso mitmdump. Default: <out>/mitmdump.log."
|
|
||||||
output: "JSON en stdout: {\"pid\": <pid>, \"port\": <port>, \"out_dir\": \"<dir>\", \"rotate_min\": <n>, \"log\": \"<log>\"}. Exit 1 en error (binario no encontrado, addon ausente, proceso muerto al arrancar)."
|
|
||||||
tested: false
|
|
||||||
tests: []
|
|
||||||
test_file_path: ""
|
|
||||||
file_path: "bash/functions/cybersecurity/start_mitm_capture.sh"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ejemplo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
source bash/functions/cybersecurity/start_mitm_capture.sh
|
|
||||||
|
|
||||||
# Arranque con defaults (puerto 8080, capturas en ~/captures, rotación cada 20 min)
|
|
||||||
start_mitm_capture --port 8080 --out /home/enmanuel/captures --rotate-min 20
|
|
||||||
|
|
||||||
# Salida esperada:
|
|
||||||
# {"pid": 123456, "port": 8080, "out_dir": "/home/enmanuel/captures", "rotate_min": 20, "log": "/home/enmanuel/captures/mitmdump.log"}
|
|
||||||
|
|
||||||
# Puerto alternativo y rotación más frecuente
|
|
||||||
start_mitm_capture --port 9090 --out /tmp/mitm_session --rotate-min 5
|
|
||||||
|
|
||||||
# Pasando addon y binario explícitos
|
|
||||||
start_mitm_capture \
|
|
||||||
--port 8080 \
|
|
||||||
--out /home/enmanuel/captures \
|
|
||||||
--addon /home/enmanuel/fn_registry/python/functions/cybersecurity/rotate_capture_flows.py \
|
|
||||||
--mitmdump /home/enmanuel/.local/bin/mitmdump
|
|
||||||
|
|
||||||
# Leer el PID para poder parar el proceso más adelante
|
|
||||||
info=$(start_mitm_capture --port 8080 --out /home/enmanuel/captures)
|
|
||||||
pid=$(echo "$info" | python3 -c "import sys,json; print(json.load(sys.stdin)['pid'])")
|
|
||||||
echo "Proxy corriendo con PID $pid"
|
|
||||||
# Para parar: kill "$pid"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cuando usarla
|
|
||||||
|
|
||||||
Cuando necesites un proxy de interceptación pasivo siempre activo en segundo plano que capture y rote el tráfico HTTP/HTTPS de forma continua sin supervisión manual. Úsala antes de lanzar pruebas de integración, sesiones de auditoría web o scraping supervisado donde quieras replay o análisis posterior de las capturas `.mitm`.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **HTTPS requiere instalar el CA de mitmproxy en el cliente.** El certificado raíz está en `~/.mitmproxy/mitmproxy-ca-cert.pem` tras el primer arranque. En navegadores: importar como CA de confianza. En curl/httpx: `--cacert ~/.mitmproxy/mitmproxy-ca-cert.pem`. En Chrome/Chromium headless: `--ignore-certificate-errors` (solo para pruebas).
|
|
||||||
- **El proceso queda en background.** Para pararlo: `kill <pid>` (el PID se devuelve en el JSON) o `port_kill 8080` si tienes la función del registry disponible.
|
|
||||||
- **El addon rotate_capture_flows.py debe existir.** La función lo busca en la raíz del registry derivada automáticamente; si el registry está en una ubicación no estándar, pasa `--addon` explícitamente o setea `FN_REGISTRY_ROOT`.
|
|
||||||
- **setsid desacopla el proceso de la shell.** Si cierras la terminal, mitmdump sigue corriendo. Necesitas matar el PID manualmente o via systemd/supervisor si quieres ciclo de vida gestionado.
|
|
||||||
- **El log crece sin límite.** El log de mitmdump no rota; solo rotan los archivos `.mitm` de capturas. Monitoriza el tamaño de `<out>/mitmdump.log` en sesiones largas.
|
|
||||||
- **Solo funciona con mitmdump >= 9.x** (API de addons `--set` con parámetros por nombre). Versiones anteriores usan sintaxis distinta.
|
|
||||||
|
|
||||||
## Notas
|
|
||||||
|
|
||||||
La derivación de la raíz del registry cuando `FN_REGISTRY_ROOT` no está seteado asciende 3 niveles desde `bash/functions/cybersecurity/` hasta la raíz usando `dirname "${BASH_SOURCE[0]}"`. Esto funciona siempre que el script se sourcee desde su ubicación original en el registry.
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# start_mitm_capture — Arranca mitmdump en segundo plano como proxy de interceptación
|
|
||||||
# con rotación de capturas cada N minutos vía addon Python del registry.
|
|
||||||
|
|
||||||
start_mitm_capture() {
|
|
||||||
local port=8080
|
|
||||||
local out_dir="$HOME/captures"
|
|
||||||
local rotate_min=20
|
|
||||||
local addon_path=""
|
|
||||||
local mitmdump_bin=""
|
|
||||||
local log_path=""
|
|
||||||
|
|
||||||
# Parseo de argumentos
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--port) port="$2"; shift 2 ;;
|
|
||||||
--out) out_dir="$2"; shift 2 ;;
|
|
||||||
--rotate-min) rotate_min="$2"; shift 2 ;;
|
|
||||||
--addon) addon_path="$2"; shift 2 ;;
|
|
||||||
--mitmdump) mitmdump_bin="$2"; shift 2 ;;
|
|
||||||
--log) log_path="$2"; shift 2 ;;
|
|
||||||
*)
|
|
||||||
echo "ERROR: argumento desconocido: $1" >&2
|
|
||||||
echo "Uso: start_mitm_capture [--port N] [--out DIR] [--rotate-min N] [--addon PATH] [--mitmdump BIN] [--log PATH]" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# Derivar raíz del registry cuando FN_REGISTRY_ROOT no está seteado
|
|
||||||
local registry_root
|
|
||||||
if [[ -n "${FN_REGISTRY_ROOT:-}" ]]; then
|
|
||||||
registry_root="$FN_REGISTRY_ROOT"
|
|
||||||
else
|
|
||||||
# bash/functions/cybersecurity/ -> 3 niveles arriba = raíz del registry
|
|
||||||
registry_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Default addon usando la raíz derivada
|
|
||||||
if [[ -z "$addon_path" ]]; then
|
|
||||||
addon_path="${registry_root}/python/functions/cybersecurity/rotate_capture_flows.py"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Default log path (depende de out_dir, se resuelve después de crear el dir)
|
|
||||||
# Se asigna más abajo si sigue vacío
|
|
||||||
|
|
||||||
# Crear directorio de capturas si no existe
|
|
||||||
if [[ ! -d "$out_dir" ]]; then
|
|
||||||
mkdir -p "$out_dir" || {
|
|
||||||
echo "ERROR: no se pudo crear el directorio de capturas: $out_dir" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Asignar log por defecto ahora que out_dir existe
|
|
||||||
if [[ -z "$log_path" ]]; then
|
|
||||||
log_path="${out_dir}/mitmdump.log"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Resolver binario mitmdump
|
|
||||||
if [[ -z "$mitmdump_bin" ]]; then
|
|
||||||
if command -v mitmdump &>/dev/null; then
|
|
||||||
mitmdump_bin="$(command -v mitmdump)"
|
|
||||||
elif [[ -x "$HOME/.local/bin/mitmdump" ]]; then
|
|
||||||
mitmdump_bin="$HOME/.local/bin/mitmdump"
|
|
||||||
else
|
|
||||||
echo "ERROR: mitmdump no encontrado; instala con: uv tool install mitmproxy" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if [[ ! -x "$mitmdump_bin" ]]; then
|
|
||||||
echo "ERROR: el binario indicado no existe o no es ejecutable: $mitmdump_bin" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verificar que el addon existe
|
|
||||||
if [[ ! -f "$addon_path" ]]; then
|
|
||||||
echo "ERROR: addon no encontrado: $addon_path" >&2
|
|
||||||
echo " Asegúrate de que FN_REGISTRY_ROOT apunta a la raíz del registry" >&2
|
|
||||||
echo " o pasa --addon con la ruta correcta a rotate_capture_flows.py" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Arrancar mitmdump en background con setsid (sobrevive al cierre de la shell).
|
|
||||||
# El redirect </dev/null desacopla stdin: sin él, el proceso retiene el pipe
|
|
||||||
# heredado y la shell sale con exit 144 (SIGTERM) al cerrarse. disown lo saca
|
|
||||||
# de la tabla de jobs para que la shell no le envíe señales al terminar.
|
|
||||||
setsid "$mitmdump_bin" \
|
|
||||||
-s "$addon_path" \
|
|
||||||
--set "rotate_min=${rotate_min}" \
|
|
||||||
--set "capture_dir=${out_dir}" \
|
|
||||||
--listen-port "$port" \
|
|
||||||
</dev/null >> "$log_path" 2>&1 &
|
|
||||||
local pid=$!
|
|
||||||
disown "$pid" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Esperar ~1s y verificar que el proceso sigue vivo
|
|
||||||
sleep 1
|
|
||||||
if ! kill -0 "$pid" 2>/dev/null; then
|
|
||||||
echo "ERROR: mitmdump murió al arrancar. Últimas líneas del log:" >&2
|
|
||||||
tail -20 "$log_path" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Emitir JSON con información del proceso arrancado
|
|
||||||
printf '{"pid": %d, "port": %d, "out_dir": "%s", "rotate_min": %d, "log": "%s"}\n' \
|
|
||||||
"$pid" "$port" "$out_dir" "$rotate_min" "$log_path"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Ejecutar si se llama directamente (fn run / bash <file>)
|
|
||||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
||||||
start_mitm_capture "$@"
|
|
||||||
fi
|
|
||||||
@@ -3,17 +3,17 @@ name: adb_wsl
|
|||||||
kind: function
|
kind: function
|
||||||
lang: bash
|
lang: bash
|
||||||
domain: infra
|
domain: infra
|
||||||
version: "1.1.0"
|
version: "1.0.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "source adb_wsl.sh [ADB=<path>] [ANDROID_HOME=<sdk_root>]"
|
signature: "source adb_wsl.sh [ADB=<path>] [ANDROID_SDK_WIN=<sdk_root>]"
|
||||||
description: "Wrapper sourceable para resolver e invocar adb. Linux-first: usa el adb nativo del Android SDK ($ANDROID_HOME) o del PATH; fallback a adb.exe solo si detecta WSL2. Expone adb_run, adb_devices, adb_pick_serial, adb_s, adb_wait_boot."
|
description: "Wrapper sourceable para usar adb.exe Windows desde WSL2. Resuelve binario, convierte paths, espera boot del emulador."
|
||||||
tags: ["android", "adb", "linux", "emulator", "wsl"]
|
tags: ["android", "adb", "wsl", "windows"]
|
||||||
params:
|
params:
|
||||||
- name: ADB
|
- name: ADB
|
||||||
desc: "Env var opcional. Path absoluto al binario adb (override explicito). Si no se fija, se resuelve Linux-first: $ANDROID_HOME/platform-tools/adb, luego adb del PATH, luego adb.exe si WSL2."
|
desc: "Env var opcional. Path absoluto a adb.exe. Si no se fija, se construye desde ANDROID_SDK_WIN o el default /mnt/c/Users/lucas/AppData/Local/Android/Sdk."
|
||||||
- name: ANDROID_HOME
|
- name: ANDROID_SDK_WIN
|
||||||
desc: "Env var opcional. Raiz del Android SDK nativo. Si esta presente, se usa $ANDROID_HOME/platform-tools/adb. Tambien se acepta ANDROID_SDK_ROOT."
|
desc: "Env var opcional. Raiz del Android SDK montado en WSL. Default: /mnt/c/Users/lucas/AppData/Local/Android/Sdk."
|
||||||
output: "Source-able shell helpers: adb_run, adb_devices, adb_pick_serial, adb_s, adb_wait_boot, adb_wsl_to_win. Resuelve y fija la env var ADB al binario adb disponible."
|
output: "Source-able shell helpers: adb_run, adb_devices, adb_wsl_to_win, adb_wait_boot. Define ADB env var apuntando a Windows adb.exe via ANDROID_SDK_WIN."
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
@@ -26,33 +26,24 @@ test_file_path: ""
|
|||||||
file_path: "bash/functions/infra/adb_wsl.sh"
|
file_path: "bash/functions/infra/adb_wsl.sh"
|
||||||
---
|
---
|
||||||
|
|
||||||
## Cuando usarla
|
## Uso
|
||||||
|
|
||||||
Sourcéala como capa base de cualquier script que hable con un device o emulador Android via adb. Es la dependencia comun de todo el toolbelt android del registry (`android_screenshot`, `android_input_*`, `android_logcat`, `android_app_*`, `android_push/pull`). En Linux nativo resuelve el adb del SDK automaticamente; no hace falta configurar nada si `ANDROID_HOME` esta exportado (o `adb` esta en el PATH).
|
|
||||||
|
|
||||||
## Ejemplo
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux nativo: con el SDK instalado y ANDROID_HOME exportado, resuelve solo.
|
# Sourcear (usa SDK default)
|
||||||
source ~/android-sdk/env.sh
|
|
||||||
source bash/functions/infra/adb_wsl.sh
|
source bash/functions/infra/adb_wsl.sh
|
||||||
adb_devices
|
|
||||||
# List of devices attached
|
|
||||||
# emulator-5554 device
|
|
||||||
|
|
||||||
# Fijar binario adb explicito (override)
|
# Sourcear con SDK custom
|
||||||
ADB=/opt/android/platform-tools/adb source bash/functions/infra/adb_wsl.sh
|
ANDROID_SDK_WIN=/mnt/d/Android/Sdk source bash/functions/infra/adb_wsl.sh
|
||||||
|
|
||||||
# Smoke test
|
# Sourcear con binario fijo
|
||||||
bash bash/functions/infra/adb_wsl.sh --self-test
|
ADB=/mnt/c/my/tools/adb.exe source bash/functions/infra/adb_wsl.sh
|
||||||
# Android Debug Bridge version 1.0.41
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Funciones expuestas
|
## Funciones expuestas
|
||||||
|
|
||||||
### `adb_run "<args...>"`
|
### `adb_run "<args...>"`
|
||||||
|
|
||||||
Ejecuta `$ADB` con los argumentos dados. Retorna el exit code de adb.
|
Ejecuta `$ADB` con los argumentos dados. Retorna el exit code de `adb.exe`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
adb_run shell ls /sdcard/
|
adb_run shell ls /sdcard/
|
||||||
@@ -63,34 +54,45 @@ adb_run install app.apk
|
|||||||
|
|
||||||
Alias de `adb_run devices`. Lista dispositivos/emuladores conectados.
|
Alias de `adb_run devices`. Lista dispositivos/emuladores conectados.
|
||||||
|
|
||||||
### `adb_pick_serial [--serial <S>] [...]`
|
|
||||||
|
|
||||||
Resuelve el serial a usar (multi-device). Lee `--serial X` de los args y setea los globals `ADB_PICK_SERIAL` y `ADB_PICK_REST`. Si no se pasa, autoselecciona el primer device/emulador conectado.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
adb_pick_serial "$@" || { echo "no device" >&2; exit 3; }
|
adb_devices
|
||||||
serial="$ADB_PICK_SERIAL"; set -- "${ADB_PICK_REST[@]}"
|
# List of devices attached
|
||||||
|
# emulator-5554 device
|
||||||
```
|
```
|
||||||
|
|
||||||
### `adb_s <serial> <args...>`
|
|
||||||
|
|
||||||
Atajo de `adb_run -s <serial> <args...>` para multi-device.
|
|
||||||
|
|
||||||
### `adb_wait_boot [timeout_s]`
|
|
||||||
|
|
||||||
Espera a que el emulador/dispositivo complete el boot (`sys.boot_completed = 1`). Polling cada 3s. Retorna `0` si bootó, `1` si timeout (default 120s).
|
|
||||||
|
|
||||||
### `adb_wsl_to_win <path_wsl>`
|
### `adb_wsl_to_win <path_wsl>`
|
||||||
|
|
||||||
Legacy WSL: convierte path WSL→Windows con `wslpath -w`. En Linux nativo (sin `wslpath`) devuelve el path tal cual.
|
Convierte un path WSL a formato Windows con `wslpath -w`. Si `wslpath` no está disponible retorna el path sin convertir.
|
||||||
|
|
||||||
## Gotchas
|
```bash
|
||||||
|
win_path=$(adb_wsl_to_win /home/lucas/proyecto/app.apk)
|
||||||
|
# C:\Users\lucas\AppData\Local\... (o la ruta Windows equivalente)
|
||||||
|
adb_run install "$win_path"
|
||||||
|
```
|
||||||
|
|
||||||
- **Linux-first.** El default ya NO es Windows. Resolucion: `$ADB` → `$ANDROID_HOME/platform-tools/adb` → `adb` del PATH → (solo si `/proc/version` indica WSL2) `adb.exe`. En un PC Linux con el SDK instalado funciona sin configurar nada.
|
### `adb_wait_boot [timeout_s]`
|
||||||
- **Necesita el SDK o adb en PATH.** Si no encuentra adb aborta con mensaje a stderr. Instala con `fn run install_android_sdk_bash_infra` y exporta `ANDROID_HOME` (o `source ~/android-sdk/env.sh`).
|
|
||||||
- **`ADB` se resuelve una sola vez al sourcing.** Cambiar el SDK despues requiere re-sourcear.
|
|
||||||
- **Sourcéala con bash, no zsh.** Los consumidores usan `${BASH_SOURCE[0]}` para localizar este archivo; ejecutarlos con `bash <file>` (no `zsh`/`source` desde zsh) resuelve el path correctamente.
|
|
||||||
|
|
||||||
## Capability growth log
|
Espera a que el emulador/dispositivo complete el boot (`sys.boot_completed = 1`). Útil tras lanzar un AVD en CI.
|
||||||
|
|
||||||
- v1.1.0 (2026-06-03) — Linux-first: la resolucion de adb ahora prioriza el adb nativo del SDK (`$ANDROID_HOME/platform-tools/adb`) y del PATH; el adb.exe de Windows queda como fallback legacy solo bajo WSL2. Se elimina el default hardcodeado `/mnt/c/Users/lucas/...`. Todo el toolbelt android (~20 funciones) pasa a funcionar en Linux nativo sin preexportar `ADB`.
|
```bash
|
||||||
|
adb_wait_boot # timeout 120s
|
||||||
|
adb_wait_boot 60 # timeout 60s
|
||||||
|
```
|
||||||
|
|
||||||
|
Retorna `0` si el boot se completó, `1` si expiró el timeout.
|
||||||
|
|
||||||
|
## Smoke test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash bash/functions/infra/adb_wsl.sh --self-test
|
||||||
|
# OK
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- El script es **source-able**: define funciones en el shell actual, no crea subshell.
|
||||||
|
- `ADB` se resuelve una sola vez al sourcing. Si el binario no existe en disco, la carga falla con mensaje en stderr y `return 1` / `exit 1`.
|
||||||
|
- `adb_wait_boot` hace polling cada 3 segundos. Ajustar `interval` si el emulador es especialmente lento.
|
||||||
|
- En WSL2 `wslpath` siempre está disponible; el fallback existe para entornos Linux puros que accidentalmente sourceen el archivo.
|
||||||
|
- Si el emulador requiere `-s <serial>`, pasar el flag directamente a `adb_run`: `adb_run -s emulator-5554 shell ...`.
|
||||||
|
---
|
||||||
|
|||||||
@@ -1,35 +1,20 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# adb_wsl — Wrapper sourceable para resolver e invocar adb.
|
# adb_wsl — Wrapper sourceable para usar adb.exe Windows desde WSL2.
|
||||||
# Linux-first: usa el adb nativo del Android SDK o del PATH. Conserva un
|
|
||||||
# fallback a adb.exe SOLO cuando se detecta WSL2 (legacy). El nombre del
|
|
||||||
# archivo se mantiene por compatibilidad con sus consumidores del registry.
|
|
||||||
# Uso: source bash/functions/infra/adb_wsl.sh
|
# Uso: source bash/functions/infra/adb_wsl.sh
|
||||||
# Smoke test: bash bash/functions/infra/adb_wsl.sh --self-test
|
# Smoke test: bash bash/functions/infra/adb_wsl.sh --self-test
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Resolver ADB (Linux-first; fallback WSL legacy)
|
# Resolver ADB
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Prioridad de resolucion:
|
# El caller puede fijar ADB antes de sourcing para apuntar a otro binario.
|
||||||
# 1. $ADB preexportada por el caller (override explicito).
|
|
||||||
# 2. adb nativo del Android SDK ($ANDROID_HOME / $ANDROID_SDK_ROOT).
|
|
||||||
# 3. adb del PATH.
|
|
||||||
# 4. (legacy) adb.exe de Windows, solo si corremos dentro de WSL2.
|
|
||||||
if [[ -z "${ADB:-}" ]]; then
|
if [[ -z "${ADB:-}" ]]; then
|
||||||
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
|
_sdk_root="${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}"
|
||||||
if [[ -n "$_sdk" && -x "$_sdk/platform-tools/adb" ]]; then
|
ADB="${_sdk_root}/platform-tools/adb.exe"
|
||||||
ADB="$_sdk/platform-tools/adb"
|
unset _sdk_root
|
||||||
elif command -v adb &>/dev/null; then
|
|
||||||
ADB="$(command -v adb)"
|
|
||||||
elif grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then
|
|
||||||
_sdk_win="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}"
|
|
||||||
ADB="${_sdk_win}/platform-tools/adb.exe"
|
|
||||||
unset _sdk_win
|
|
||||||
fi
|
|
||||||
unset _sdk
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "${ADB:-}" ]] || ! command -v "$ADB" &>/dev/null; then
|
if [[ ! -f "$ADB" ]]; then
|
||||||
echo "adb_wsl: adb no encontrado. Instala el SDK (fn run install_android_sdk_bash_infra), exporta ANDROID_HOME, o fija ADB= antes de sourcear." >&2
|
echo "adb_wsl: ADB no encontrado en '$ADB'. Fija ADB= o ANDROID_SDK_WIN= antes de sourcear." >&2
|
||||||
# Solo abortamos si el script se ejecuta directamente; si se sourcea,
|
# Solo abortamos si el script se ejecuta directamente; si se sourcea,
|
||||||
# permitimos continuar para que el caller maneje el error.
|
# permitimos continuar para que el caller maneje el error.
|
||||||
return 1 2>/dev/null || exit 1
|
return 1 2>/dev/null || exit 1
|
||||||
@@ -37,8 +22,8 @@ fi
|
|||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# adb_run "<args...>"
|
# adb_run "<args...>"
|
||||||
# Ejecuta adb (el binario resuelto en $ADB) con los argumentos dados.
|
# Ejecuta el ADB Windows con los argumentos dados.
|
||||||
# Retorna el exit code de adb.
|
# Retorna el exit code de adb.exe.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
adb_run() {
|
adb_run() {
|
||||||
"$ADB" "$@"
|
"$ADB" "$@"
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ name: android_emulator_list
|
|||||||
kind: function
|
kind: function
|
||||||
lang: bash
|
lang: bash
|
||||||
domain: infra
|
domain: infra
|
||||||
version: "1.1.0"
|
version: "1.0.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "android_emulator_list([--json])"
|
signature: "android_emulator_list([--json])"
|
||||||
description: "Lista los AVDs disponibles. Linux-first: usa el emulator nativo del Android SDK ($ANDROID_HOME); fallback a emulator.exe solo bajo WSL2."
|
description: "Lista los AVDs disponibles invocando emulator.exe Windows desde WSL2."
|
||||||
tags: [android, emulator, linux, avd, wsl]
|
tags: [android, emulator, wsl]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
@@ -17,41 +17,35 @@ imports: []
|
|||||||
params:
|
params:
|
||||||
- name: "--json"
|
- name: "--json"
|
||||||
desc: "Optional flag, outputs JSON array instead of newline-separated names"
|
desc: "Optional flag, outputs JSON array instead of newline-separated names"
|
||||||
output: "Lista de AVDs disponibles en el SDK. Una por linea, o JSON array con --json."
|
output: "Lista de AVDs disponibles en el SDK Windows. Una por linea, o JSON array con --json."
|
||||||
tested: false
|
tested: false
|
||||||
tests: []
|
tests: []
|
||||||
test_file_path: ""
|
test_file_path: ""
|
||||||
file_path: "bash/functions/infra/android_emulator_list.sh"
|
file_path: "bash/functions/infra/android_emulator_list.sh"
|
||||||
notes: "Resuelve el binario emulator Linux-first ($ANDROID_HOME/emulator/emulator -> emulator del PATH -> emulator.exe si WSL2). Override con EMULATOR=. Exit 0 si lista (incluso vacia). Exit 1 solo si el binario no existe."
|
notes: "Lee env var EMULATOR o ANDROID_SDK_WIN. Default Windows path: /mnt/c/Users/lucas/AppData/Local/Android/Sdk/emulator/emulator.exe. Exit 0 si lista (incluso vacia). Exit 1 solo si el binario no existe o no es ejecutable."
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ejemplo
|
## Ejemplo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source ~/android-sdk/env.sh # exporta ANDROID_HOME
|
|
||||||
|
|
||||||
# Listar AVDs (una por linea)
|
# Listar AVDs (una por linea)
|
||||||
android_emulator_list
|
android_emulator_list
|
||||||
# Pixel_API34
|
|
||||||
|
|
||||||
# Listar AVDs en formato JSON
|
# Listar AVDs en formato JSON
|
||||||
android_emulator_list --json
|
android_emulator_list --json
|
||||||
# ["Pixel_API34"]
|
# ["Pixel_7_API_34","Pixel_4_API_30"]
|
||||||
|
|
||||||
# Sobreescribir ruta del emulador
|
# Sobreescribir ruta del emulador
|
||||||
EMULATOR="/opt/android/emulator/emulator" android_emulator_list
|
EMULATOR="/custom/path/emulator.exe" android_emulator_list
|
||||||
|
|
||||||
|
# Sobreescribir SDK base
|
||||||
|
ANDROID_SDK_WIN="/mnt/d/Android/Sdk" android_emulator_list
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cuando usarla
|
## Notas
|
||||||
|
|
||||||
Antes de arrancar un emulador, para validar que el AVD existe (lo hace `deploy_capacitor_to_emulator` y `run_kotlin_app_tests` internamente). Útil también para listar qué AVDs hay creados en la máquina.
|
El script es ejecutable directamente (`chmod +x`) o invocable con `bash android_emulator_list.sh`.
|
||||||
|
|
||||||
## Gotchas
|
`emulator.exe -list-avds` imprime warnings a stderr que se descartan con `2>/dev/null`. La captura con `mapfile` filtra ademas lineas vacias para producir una lista limpia.
|
||||||
|
|
||||||
- **Linux-first.** El default ya no es Windows. Resuelve `$ANDROID_HOME/emulator/emulator`, luego `emulator` del PATH, y solo bajo WSL2 cae a `emulator.exe`.
|
La variable `EMULATOR` tiene prioridad sobre `ANDROID_SDK_WIN`. Si ninguna esta definida se usa el path Windows por defecto de Lucas.
|
||||||
- `emulator -list-avds` imprime warnings a stderr que se descartan con `2>/dev/null`. La captura con `mapfile` filtra líneas vacías.
|
|
||||||
- Override del binario con `EMULATOR=`; override del SDK con `ANDROID_HOME=`.
|
|
||||||
|
|
||||||
## Capability growth log
|
|
||||||
|
|
||||||
- v1.1.0 (2026-06-03) — Linux-first: resuelve el emulator nativo del SDK (`$ANDROID_HOME`) y del PATH antes que `emulator.exe`; se elimina el default hardcodeado `/mnt/c/Users/lucas/...`.
|
|
||||||
|
|||||||
@@ -1,23 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# android_emulator_list — Lista los AVDs disponibles. Linux-first: usa el
|
# android_emulator_list — Lista los AVDs disponibles invocando emulator.exe Windows desde WSL2.
|
||||||
# emulator nativo del Android SDK; fallback a emulator.exe solo bajo WSL2.
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Resolve emulator binary (Linux-first; WSL fallback)
|
# Resolve emulator binary
|
||||||
if [[ -z "${EMULATOR:-}" ]]; then
|
EMULATOR="${EMULATOR:-${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}/emulator/emulator.exe}"
|
||||||
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
|
|
||||||
if [[ -n "$_sdk" && -x "$_sdk/emulator/emulator" ]]; then
|
|
||||||
EMULATOR="$_sdk/emulator/emulator"
|
|
||||||
elif command -v emulator &>/dev/null; then
|
|
||||||
EMULATOR="$(command -v emulator)"
|
|
||||||
elif grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then
|
|
||||||
EMULATOR="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}/emulator/emulator.exe"
|
|
||||||
fi
|
|
||||||
unset _sdk
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "${EMULATOR:-}" ]] || ! command -v "$EMULATOR" &>/dev/null; then
|
if [[ ! -x "$EMULATOR" ]]; then
|
||||||
echo "error: emulator no encontrado. Instala el SDK (fn run install_android_sdk_bash_infra) + el paquete 'emulator', exporta ANDROID_HOME, o fija EMULATOR=." >&2
|
echo "error: emulator binary not found or not executable: $EMULATOR" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ name: android_emulator_start
|
|||||||
kind: function
|
kind: function
|
||||||
lang: bash
|
lang: bash
|
||||||
domain: infra
|
domain: infra
|
||||||
version: "1.1.0"
|
version: "1.0.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "android_emulator_start(avd_name: string, timeout_s: int) -> string"
|
signature: "android_emulator_start(avd_name: string, timeout_s: int) -> string"
|
||||||
description: "Arranca un AVD Android en background y espera a que termine de bootear. Linux-first: resuelve el emulator/adb nativos del SDK; fallback a binarios .exe solo bajo WSL2. Idempotente: si ya hay un emulador corriendo, imprime 'already running' y su serial sin lanzar otro."
|
description: "Arranca un AVD en background y espera a que termine de bootear. Idempotente: si ya hay emulador corriendo no lanza otro."
|
||||||
tags: [android, emulator, linux, avd, wsl]
|
tags: [android, emulator, wsl]
|
||||||
params:
|
params:
|
||||||
- name: avd_name
|
- name: avd_name
|
||||||
desc: "Nombre del AVD a arrancar (visible con android_emulator_list o `emulator -list-avds`)"
|
desc: "Nombre del AVD a arrancar (visible con android_emulator_list o `emulator.exe -list-avds`)"
|
||||||
- name: timeout_s
|
- name: timeout_s
|
||||||
desc: "Timeout total en segundos para esperar el boot completo. Opcional, default 180"
|
desc: "Timeout total en segundos para esperar el boot completo. Opcional, default 180"
|
||||||
output: "Serial del device emulado (ej. emulator-5554) en stdout. Exit 0 = boot completo, exit 1 = timeout o emulador murio."
|
output: "Serial del device emulado (ej. emulator-5554) en stdout. Exit 0 = boot completo, exit 1 = timeout o emulador murio."
|
||||||
@@ -29,31 +29,21 @@ file_path: "bash/functions/infra/android_emulator_start.sh"
|
|||||||
## Ejemplo
|
## Ejemplo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source ~/android-sdk/env.sh # exporta ANDROID_HOME -> resuelve emulator/adb nativos
|
|
||||||
source bash/functions/infra/android_emulator_start.sh
|
source bash/functions/infra/android_emulator_start.sh
|
||||||
|
|
||||||
# Arrancar AVD con timeout por defecto (180s)
|
# Arrancar AVD con timeout por defecto (180s)
|
||||||
serial=$(android_emulator_start "Pixel_API34")
|
serial=$(android_emulator_start "Pixel_6_API_34")
|
||||||
echo "Emulador listo: $serial" # emulator-5554
|
echo "Emulador listo: $serial" # emulator-5554
|
||||||
|
|
||||||
# Con timeout personalizado
|
# Con timeout personalizado
|
||||||
serial=$(android_emulator_start "Pixel_API34" 300)
|
serial=$(android_emulator_start "Pixel_6_API_34" 300)
|
||||||
```
|
```
|
||||||
|
|
||||||
Para ver la ventana del emulador en un escritorio Linux, exporta `DISPLAY` (y `XAUTHORITY`) antes de invocar.
|
## Notas
|
||||||
|
|
||||||
## Cuando usarla
|
- Sourcea `adb_wsl.sh` del mismo directorio si existe (provee `ADB`, `adb_run`, `adb_wait_boot`). Si no, usa implementacion inline.
|
||||||
|
- Resuelve `EMULATOR` y `ADB` desde `ANDROID_SDK_WIN` (default `/mnt/c/Users/lucas/AppData/Local/Android/Sdk`) o desde las variables de entorno `EMULATOR=` / `ADB=` si ya están fijadas.
|
||||||
Cuando un script necesita un emulador booteado antes de instalar un APK o correr tests instrumentados (`gradle_instrumented_test`, `run_kotlin_app_tests`). Es idempotente, así que se puede llamar al principio de cualquier pipeline sin comprobar antes si ya hay uno arriba.
|
- Idempotente: si `adb devices` ya muestra un `emulator-*`, imprime "already running" + el serial y sale con exit 0 sin lanzar un segundo proceso.
|
||||||
|
- Log del emulador en `/tmp/emulator_<avd>.log`. PID en `/tmp/emulator_<avd>.pid`.
|
||||||
## Gotchas
|
- El timeout total se reparte: primera mitad para `adb wait-for-device`, segunda mitad para esperar `sys.boot_completed=1`.
|
||||||
|
- Diseñado para WSL2 con Android SDK instalado en Windows. En Linux nativo basta cambiar las rutas de los binarios via `EMULATOR=` y `ADB=`.
|
||||||
- **Linux-first.** Resuelve `EMULATOR`/`ADB` desde `$ANDROID_HOME/{emulator/emulator, platform-tools/adb}` o del PATH; `emulator.exe`/`adb.exe` solo como fallback bajo WSL2. Override manual con `EMULATOR=`/`ADB=`.
|
|
||||||
- **Necesita `DISPLAY` para ventana.** Sin un servidor X accesible el emulador puede fallar al abrir ventana. Para headless/CI añade `-no-window` (editar la función o lanzar el emulador aparte).
|
|
||||||
- **Aceleración KVM.** Requiere acceso a `/dev/kvm` (grupo `kvm` o ACL). Sin ella el boot es lentísimo o falla.
|
|
||||||
- Log del emulador en `/tmp/emulator_<avd>.log`, PID en `/tmp/emulator_<avd>.pid`.
|
|
||||||
- El timeout total se reparte: primera mitad para `adb wait-for-device`, segunda para `sys.boot_completed=1`.
|
|
||||||
|
|
||||||
## Capability growth log
|
|
||||||
|
|
||||||
- v1.1.0 (2026-06-03) — Linux-first: resuelve emulator/adb nativos del SDK (`$ANDROID_HOME`) antes que los `.exe` de Windows (ahora solo fallback WSL2); se elimina el default hardcodeado `/mnt/c/Users/lucas/...`. fix: `timeout <n> adb_run wait-for-device` fallaba siempre porque `timeout` no puede ejecutar la función shell `adb_run`; ahora invoca el binario `"$ADB"` directamente.
|
|
||||||
|
|||||||
@@ -11,17 +11,11 @@ if [[ -f "$_ADB_WSL_SH" ]]; then
|
|||||||
# shellcheck source=adb_wsl.sh
|
# shellcheck source=adb_wsl.sh
|
||||||
source "$_ADB_WSL_SH"
|
source "$_ADB_WSL_SH"
|
||||||
else
|
else
|
||||||
# Fallback inline: resolver ADB (Linux-first; WSL fallback)
|
# Fallback inline: resolver ADB
|
||||||
if [[ -z "${ADB:-}" ]]; then
|
if [[ -z "${ADB:-}" ]]; then
|
||||||
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
|
_sdk_root="${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}"
|
||||||
if [[ -n "$_sdk" && -x "$_sdk/platform-tools/adb" ]]; then
|
ADB="${_sdk_root}/platform-tools/adb.exe"
|
||||||
ADB="$_sdk/platform-tools/adb"
|
unset _sdk_root
|
||||||
elif command -v adb &>/dev/null; then
|
|
||||||
ADB="$(command -v adb)"
|
|
||||||
else
|
|
||||||
ADB="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}/platform-tools/adb.exe"
|
|
||||||
fi
|
|
||||||
unset _sdk
|
|
||||||
fi
|
fi
|
||||||
adb_run() { "$ADB" "$@"; }
|
adb_run() { "$ADB" "$@"; }
|
||||||
adb_wait_boot() {
|
adb_wait_boot() {
|
||||||
@@ -39,18 +33,12 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Resolver EMULATOR (Linux-first; WSL fallback)
|
# Resolver EMULATOR
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
if [[ -z "${EMULATOR:-}" ]]; then
|
if [[ -z "${EMULATOR:-}" ]]; then
|
||||||
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
|
_sdk_root="${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}"
|
||||||
if [[ -n "$_sdk" && -x "$_sdk/emulator/emulator" ]]; then
|
EMULATOR="${_sdk_root}/emulator/emulator.exe"
|
||||||
EMULATOR="$_sdk/emulator/emulator"
|
unset _sdk_root
|
||||||
elif command -v emulator &>/dev/null; then
|
|
||||||
EMULATOR="$(command -v emulator)"
|
|
||||||
elif grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then
|
|
||||||
EMULATOR="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}/emulator/emulator.exe"
|
|
||||||
fi
|
|
||||||
unset _sdk
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -61,12 +49,12 @@ android_emulator_start() {
|
|||||||
local timeout_s="${2:-180}"
|
local timeout_s="${2:-180}"
|
||||||
|
|
||||||
# Validaciones de entorno
|
# Validaciones de entorno
|
||||||
if [[ -z "${EMULATOR:-}" ]] || ! command -v "$EMULATOR" &>/dev/null; then
|
if [[ ! -f "$EMULATOR" ]]; then
|
||||||
echo "android_emulator_start: emulator no encontrado. Instala el SDK + paquete 'emulator', exporta ANDROID_HOME, o fija EMULATOR=." >&2
|
echo "android_emulator_start: emulator.exe no encontrado en '$EMULATOR'. Fija EMULATOR= o ANDROID_SDK_WIN=." >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if [[ -z "${ADB:-}" ]] || ! command -v "$ADB" &>/dev/null; then
|
if [[ ! -f "$ADB" ]]; then
|
||||||
echo "android_emulator_start: adb no encontrado. Instala platform-tools, exporta ANDROID_HOME, o fija ADB=." >&2
|
echo "android_emulator_start: adb.exe no encontrado en '$ADB'. Fija ADB= o ANDROID_SDK_WIN=." >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -86,12 +74,9 @@ android_emulator_start() {
|
|||||||
local emu_pid=$!
|
local emu_pid=$!
|
||||||
echo "$emu_pid" > "$pid_file"
|
echo "$emu_pid" > "$pid_file"
|
||||||
|
|
||||||
# Esperar a que el dispositivo aparezca en adb.
|
# Esperar a que el dispositivo aparezca en adb
|
||||||
# Usamos el binario "$ADB" directamente (no la funcion adb_run): `timeout`
|
|
||||||
# ejecuta un comando externo y no puede ver funciones del shell, asi que
|
|
||||||
# `timeout ... adb_run` fallaba siempre con "command not found".
|
|
||||||
local wait_timeout=$(( timeout_s / 2 ))
|
local wait_timeout=$(( timeout_s / 2 ))
|
||||||
if ! timeout "$wait_timeout" "$ADB" wait-for-device 2>/dev/null; then
|
if ! timeout "$wait_timeout" adb_run wait-for-device 2>/dev/null; then
|
||||||
echo "android_emulator_start: timeout esperando que el dispositivo aparezca en adb (${wait_timeout}s)." >&2
|
echo "android_emulator_start: timeout esperando que el dispositivo aparezca en adb (${wait_timeout}s)." >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
---
|
|
||||||
name: audit_doctor_snapshot
|
|
||||||
kind: function
|
|
||||||
lang: bash
|
|
||||||
domain: infra
|
|
||||||
version: "1.0.0"
|
|
||||||
purity: impure
|
|
||||||
signature: "audit_doctor_snapshot(doctor_subcommand: string, snapshot_base_dir: string) -> void"
|
|
||||||
description: "Ejecuta un subcomando de fn doctor --json, guarda un snapshot JSON fechado en <base>/<sub>/<stamp>.json, lo compara con la corrida anterior (latest.json) y emite a stdout un resumen legible: count actual, count previo, IDs nuevos y resueltos. Pieza de observabilidad Nivel 1 para DAGs de auditoría periódica."
|
|
||||||
tags: [audit, registry, infra, doctor, snapshot, diff, dag]
|
|
||||||
uses_functions: []
|
|
||||||
uses_types: []
|
|
||||||
returns: []
|
|
||||||
returns_optional: false
|
|
||||||
error_type: "error_go_core"
|
|
||||||
imports: []
|
|
||||||
params:
|
|
||||||
- name: doctor_subcommand
|
|
||||||
desc: "Subcomando de fn doctor a ejecutar (unused, capabilities, artefacts, copied-code, uses-functions, cpp-apps, services, sync, etc.)."
|
|
||||||
- name: snapshot_base_dir
|
|
||||||
desc: "Directorio base donde se crea la carpeta <base>/<subcommand>/ con los snapshots fechados y latest.json."
|
|
||||||
output: "Resumen a stdout: '[audit:<sub>] count=N prev=M +X new -Y resolved'. Si hay IDs nuevos/resueltos, líneas adicionales NEW:/RESOLVED: con hasta 8 IDs. Snapshots JSON en disco."
|
|
||||||
tested: false
|
|
||||||
tests: []
|
|
||||||
test_file_path: ""
|
|
||||||
file_path: "bash/functions/infra/audit_doctor_snapshot.sh"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ejemplo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Primera corrida — establece baseline
|
|
||||||
FN_REGISTRY_ROOT=/home/enmanuel/fn_registry \
|
|
||||||
FN_BIN=/home/enmanuel/fn_registry/fn \
|
|
||||||
bash bash/functions/infra/audit_doctor_snapshot.sh \
|
|
||||||
unused \
|
|
||||||
/home/enmanuel/fn_registry/apps/dag_engine/local_files/audits/daily
|
|
||||||
# => [audit:unused] count=12 prev=- baseline (sin corrida previa)
|
|
||||||
|
|
||||||
# Segunda corrida — compara contra latest.json
|
|
||||||
FN_REGISTRY_ROOT=/home/enmanuel/fn_registry \
|
|
||||||
FN_BIN=/home/enmanuel/fn_registry/fn \
|
|
||||||
bash bash/functions/infra/audit_doctor_snapshot.sh \
|
|
||||||
unused \
|
|
||||||
/home/enmanuel/fn_registry/apps/dag_engine/local_files/audits/daily
|
|
||||||
# => [audit:unused] count=12 prev=12 +0 new -0 resolved
|
|
||||||
|
|
||||||
# Con otro subcomando (directorio independiente automático)
|
|
||||||
audit_doctor_snapshot artefacts /tmp/audits/weekly
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cuando usarla
|
|
||||||
|
|
||||||
Úsala en un DAG/cron que ejecuta `fn doctor` periódicamente y quieres **persistir el resultado y ver qué cambió desde la última corrida**: funciones huérfanas que aparecieron, artefactos rotos nuevos, capabilities sin doc, etc. Es la pieza "snapshot + diff" del Nivel 1 de observabilidad de auditorías — el DAG llama esta función en vez de descartar el output de `fn doctor`.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **Depende de `FN_BIN` o `FN_REGISTRY_ROOT`** en el entorno. Si ninguno está seteado, asume `$HOME/fn_registry/fn`. En DAGs, asegúrate de exportar `FN_REGISTRY_ROOT` antes de invocar.
|
|
||||||
- **`latest.json` se sobreescribe cada corrida** — es el snapshot de referencia para el diff siguiente. No es un historial acumulado; el historial está en los archivos fechados `<stamp>.json`.
|
|
||||||
- **Si cambias de subcomando, el subdirectorio es distinto** (`<base>/unused/` vs `<base>/artefacts/`), así que no hay contaminación entre subcomandos aunque compartan el mismo `base_dir`.
|
|
||||||
- **Si `fn doctor <sub>` falla (rc != 0)**, la función propaga ese exit code. Esto es intencional: doctor roto = problema real que el DAG debe reportar. Los hallazgos normales (funciones huérfanas, artefactos con drift) tienen rc=0 en `fn doctor`.
|
|
||||||
- **jq es dependencia requerida**. Está disponible en el ecosistema del registry pero si el entorno no lo tiene, los conteos y diffs de IDs caen a `?`/textual respectivamente.
|
|
||||||
- **Retención automática**: snapshots fechados con más de 30 días se borran con `find -mtime +30`. `latest.json` nunca se borra.
|
|
||||||
- **Estructura del JSON de `fn doctor`**: el diff de IDs busca campos `.ID` o `.id` en los elementos. Si el subcomando produce una estructura distinta (objeto anidado sin esos campos), el diff cae a comparación textual, que sigue siendo útil.
|
|
||||||
|
|
||||||
## Notas
|
|
||||||
|
|
||||||
Diseñada para ser invocada desde steps del dag_engine (`daily-registry-audit`, `weekly-deep-scan`) como reemplazo del descarte silencioso del output de `fn doctor --json`. La salida stdout es legible por humanos y parseable por el orquestador del DAG para decidir si crear proposals.
|
|
||||||
|
|
||||||
Binario `fn` resuelto en orden: `$FN_BIN` → `${FN_REGISTRY_ROOT}/fn` → `$HOME/fn_registry/fn`.
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# audit_doctor_snapshot — ejecuta un subcomando de fn doctor, guarda snapshot JSON
|
|
||||||
# fechado, compara con la corrida anterior y emite resumen legible de cambios.
|
|
||||||
#
|
|
||||||
# Uso: audit_doctor_snapshot <doctor_subcommand> <snapshot_base_dir>
|
|
||||||
#
|
|
||||||
# Ejemplo:
|
|
||||||
# audit_doctor_snapshot unused /home/enmanuel/fn_registry/apps/dag_engine/local_files/audits/daily
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
audit_doctor_snapshot() {
|
|
||||||
local sub="${1:-}"
|
|
||||||
local base="${2:-}"
|
|
||||||
|
|
||||||
# --- validacion de argumentos ---
|
|
||||||
if [[ -z "$sub" || -z "$base" ]]; then
|
|
||||||
echo "usage: audit_doctor_snapshot <subcommand> <base_dir>" >&2
|
|
||||||
return 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- resolver binario fn ---
|
|
||||||
local fn_bin="${FN_BIN:-${FN_REGISTRY_ROOT:-$HOME/fn_registry}/fn}"
|
|
||||||
if [[ ! -x "$fn_bin" ]]; then
|
|
||||||
echo "audit_doctor_snapshot: binario fn no encontrado o no ejecutable: $fn_bin" >&2
|
|
||||||
return 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- preparar directorio ---
|
|
||||||
local dir="$base/$sub"
|
|
||||||
mkdir -p "$dir"
|
|
||||||
|
|
||||||
# --- ejecutar fn doctor ---
|
|
||||||
local stderr_tmp
|
|
||||||
stderr_tmp="$(mktemp /tmp/audit_doctor_snapshot_stderr.XXXXXX)"
|
|
||||||
local json rc
|
|
||||||
json="$("$fn_bin" doctor "$sub" --json 2>"$stderr_tmp")" || rc=$?
|
|
||||||
rc="${rc:-0}"
|
|
||||||
|
|
||||||
if [[ "$rc" -ne 0 ]]; then
|
|
||||||
cat "$stderr_tmp" >&2
|
|
||||||
echo "audit_doctor_snapshot: 'fn doctor $sub' fallo (rc=$rc)" >&2
|
|
||||||
rm -f "$stderr_tmp"
|
|
||||||
return "$rc"
|
|
||||||
fi
|
|
||||||
rm -f "$stderr_tmp"
|
|
||||||
|
|
||||||
# --- normalizar con jq (diff estable) ---
|
|
||||||
local stamp
|
|
||||||
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
|
||||||
local curr="$dir/${stamp}.json"
|
|
||||||
local nojson=0
|
|
||||||
|
|
||||||
if ! echo "$json" | jq -S . > "$curr" 2>/dev/null; then
|
|
||||||
# salida no es JSON valido -> guardar crudo
|
|
||||||
printf '%s' "$json" > "$curr"
|
|
||||||
nojson=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- snapshot anterior ---
|
|
||||||
local prev="$dir/latest.json"
|
|
||||||
|
|
||||||
# --- contar hallazgos actuales ---
|
|
||||||
local count="?"
|
|
||||||
if [[ "$nojson" -eq 0 ]]; then
|
|
||||||
if jq -e 'type == "array"' "$curr" >/dev/null 2>&1; then
|
|
||||||
count="$(jq 'length' "$curr")"
|
|
||||||
elif jq -e 'type == "object"' "$curr" >/dev/null 2>&1; then
|
|
||||||
count="$(jq 'keys | length' "$curr")"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- contar hallazgos previos ---
|
|
||||||
local prevcount="-"
|
|
||||||
if [[ -f "$prev" ]]; then
|
|
||||||
if jq -e 'type == "array"' "$prev" >/dev/null 2>&1; then
|
|
||||||
prevcount="$(jq 'length' "$prev")"
|
|
||||||
elif jq -e 'type == "object"' "$prev" >/dev/null 2>&1; then
|
|
||||||
prevcount="$(jq 'keys | length' "$prev")"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- diff de identidad ---
|
|
||||||
local new_count=0
|
|
||||||
local resolved_count=0
|
|
||||||
local new_ids=()
|
|
||||||
local resolved_ids=()
|
|
||||||
local diff_label=""
|
|
||||||
|
|
||||||
if [[ ! -f "$prev" ]]; then
|
|
||||||
diff_label="baseline (sin corrida previa)"
|
|
||||||
elif [[ "$nojson" -eq 1 ]]; then
|
|
||||||
if ! diff -q "$prev" "$curr" >/dev/null 2>&1; then
|
|
||||||
diff_label="changed (textual)"
|
|
||||||
else
|
|
||||||
diff_label="+0 new -0 resolved"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# extraer IDs estables: .ID o .id
|
|
||||||
local curr_ids prev_ids
|
|
||||||
curr_ids="$(jq -r 'if type=="array" then .[].ID // .[].id // empty else to_entries[].value.ID // to_entries[].value.id // empty end' "$curr" 2>/dev/null | sort -u)"
|
|
||||||
prev_ids="$(jq -r 'if type=="array" then .[].ID // .[].id // empty else to_entries[].value.ID // to_entries[].value.id // empty end' "$prev" 2>/dev/null | sort -u)"
|
|
||||||
|
|
||||||
if [[ -n "$curr_ids" || -n "$prev_ids" ]]; then
|
|
||||||
# NEW: en curr pero no en prev
|
|
||||||
local new_raw resolved_raw
|
|
||||||
new_raw="$(comm -23 <(echo "$curr_ids") <(echo "$prev_ids") 2>/dev/null || true)"
|
|
||||||
resolved_raw="$(comm -13 <(echo "$curr_ids") <(echo "$prev_ids") 2>/dev/null || true)"
|
|
||||||
|
|
||||||
if [[ -n "$new_raw" ]]; then
|
|
||||||
mapfile -t new_ids <<< "$new_raw"
|
|
||||||
fi
|
|
||||||
if [[ -n "$resolved_raw" ]]; then
|
|
||||||
mapfile -t resolved_ids <<< "$resolved_raw"
|
|
||||||
fi
|
|
||||||
|
|
||||||
new_count="${#new_ids[@]}"
|
|
||||||
resolved_count="${#resolved_ids[@]}"
|
|
||||||
diff_label="+${new_count} new -${resolved_count} resolved"
|
|
||||||
else
|
|
||||||
# sin campo .ID/.id — fallback textual
|
|
||||||
if ! diff -q "$prev" "$curr" >/dev/null 2>&1; then
|
|
||||||
diff_label="changed (textual)"
|
|
||||||
else
|
|
||||||
diff_label="+0 new -0 resolved"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- resumen a stdout ---
|
|
||||||
echo "[audit:$sub] count=$count prev=$prevcount $diff_label"
|
|
||||||
|
|
||||||
# listar nuevos (max 8)
|
|
||||||
if [[ "${#new_ids[@]}" -gt 0 ]]; then
|
|
||||||
local listed=("${new_ids[@]:0:8}")
|
|
||||||
local extra=$(( ${#new_ids[@]} - 8 ))
|
|
||||||
local line
|
|
||||||
line="$(IFS=', '; echo "${listed[*]}")"
|
|
||||||
if [[ "$extra" -gt 0 ]]; then
|
|
||||||
line="${line} (+${extra} más)"
|
|
||||||
fi
|
|
||||||
echo " NEW: $line"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# listar resueltos (max 8)
|
|
||||||
if [[ "${#resolved_ids[@]}" -gt 0 ]]; then
|
|
||||||
local listed_r=("${resolved_ids[@]:0:8}")
|
|
||||||
local extra_r=$(( ${#resolved_ids[@]} - 8 ))
|
|
||||||
local line_r
|
|
||||||
line_r="$(IFS=', '; echo "${listed_r[*]}")"
|
|
||||||
if [[ "$extra_r" -gt 0 ]]; then
|
|
||||||
line_r="${line_r} (+${extra_r} más)"
|
|
||||||
fi
|
|
||||||
echo " RESOLVED: $line_r"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- actualizar puntero latest ---
|
|
||||||
cp "$curr" "$prev"
|
|
||||||
|
|
||||||
# --- retención: borrar snapshots fechados > 30 días ---
|
|
||||||
find "$dir" -maxdepth 1 -name '*.json' ! -name 'latest.json' -mtime +30 -delete 2>/dev/null || true
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Permitir ejecución directa
|
|
||||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
||||||
audit_doctor_snapshot "$@"
|
|
||||||
fi
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
---
|
|
||||||
name: check_service_health_via_ssh
|
|
||||||
kind: function
|
|
||||||
lang: bash
|
|
||||||
domain: infra
|
|
||||||
version: "1.0.0"
|
|
||||||
purity: impure
|
|
||||||
signature: "check_service_health_via_ssh(ssh_host: string, local_url: string, [--token-from-env <remote_env_path> <ENV_VAR>], [--token <literal>], [--expect-status <code>], [--connect-timeout <s>], [--curl-timeout <s>]) -> json"
|
|
||||||
description: "Comprueba la salud de un service HTTP que solo escucha en loopback (127.0.0.1) de un host remoto, entrando por SSH y haciendo curl con bearer token opcional. El token se resuelve dentro del host remoto (leyendo una variable de un .env remoto via grep, o pasado literal) y NUNCA se imprime ni se hardcodea. Emite JSON con http_code y healthy. Reemplaza el patron inline 'ssh host -> grep token .env -> curl -H Authorization: Bearer' repetido en monitorizacion."
|
|
||||||
tags: [ssh, systemd, health, curl, remote, service, bearer, loopback, monitoring, infra]
|
|
||||||
uses_functions: []
|
|
||||||
uses_types: []
|
|
||||||
returns: []
|
|
||||||
returns_optional: false
|
|
||||||
error_type: "error_go_core"
|
|
||||||
imports: []
|
|
||||||
params:
|
|
||||||
- name: ssh_host
|
|
||||||
desc: "alias SSH del host remoto definido en ~/.ssh/config (ej: om, organic-machine). Resuelve user/puerto/identityfile del config."
|
|
||||||
- name: local_url
|
|
||||||
desc: "URL del endpoint que el service expone en loopback del host remoto (ej: http://127.0.0.1:8487/agent). No es accesible desde fuera del host."
|
|
||||||
- name: --token-from-env
|
|
||||||
desc: "dos valores: <remote_env_path> <ENV_VAR>. Lee el bearer del .env remoto con grep '^ENV_VAR=' (ej: /home/ubuntu/app/.env AGENTS_API_KEY). El token se resuelve dentro del host, no viaja en argv local."
|
|
||||||
- name: --token
|
|
||||||
desc: "bearer literal (alternativa a --token-from-env). Util para tokens ya en variables de entorno locales; preferir --token-from-env para secretos en disco remoto."
|
|
||||||
- name: --expect-status
|
|
||||||
desc: "codigo HTTP exacto que marca healthy (ej: 200). Si se omite, cualquier 2xx cuenta como healthy."
|
|
||||||
- name: --connect-timeout
|
|
||||||
desc: "timeout de conexion SSH en segundos (default 5)."
|
|
||||||
- name: --curl-timeout
|
|
||||||
desc: "timeout maximo del curl remoto en segundos (default 10)."
|
|
||||||
output: "JSON a stdout: {\"status\":\"ok|error\",\"host\":\"...\",\"url\":\"...\",\"http_code\":NNN,\"healthy\":true|false}. status=error si el SSH fallo sin obtener codigo. healthy=true si http_code coincide con expect-status (o es 2xx por defecto). Exit 0 si healthy, 1 si no, 2 en error de uso."
|
|
||||||
tested: true
|
|
||||||
tests: ["service healthy con token desde env remoto", "service no healthy con http_code 503", "salida JSON nunca filtra el token", "sin token 2xx por defecto es healthy", "falta argumento obligatorio devuelve error de uso", "falta argumento sale con codigo distinto de 0"]
|
|
||||||
test_file_path: "bash/functions/infra/check_service_health_via_ssh_test.sh"
|
|
||||||
file_path: "bash/functions/infra/check_service_health_via_ssh.sh"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ejemplo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
source bash/functions/infra/check_service_health_via_ssh.sh
|
|
||||||
|
|
||||||
# 1) Service en loopback del host 'om' con bearer leido de un .env remoto.
|
|
||||||
# Reemplaza el patron inline de monitorizacion del agents_and_robots.
|
|
||||||
result=$(check_service_health_via_ssh om "http://127.0.0.1:8487/agent" \
|
|
||||||
--token-from-env /home/ubuntu/CodeProyects/agents_and_robots/.env AGENTS_API_KEY \
|
|
||||||
--expect-status 200)
|
|
||||||
echo "$result"
|
|
||||||
# {"status":"ok","host":"om","url":"http://127.0.0.1:8487/agent","http_code":200,"healthy":true}
|
|
||||||
|
|
||||||
# 2) Sin token (endpoint publico del host pero solo accesible por loopback).
|
|
||||||
check_service_health_via_ssh organic-machine "http://127.0.0.1:8080/healthz"
|
|
||||||
# {"status":"ok","host":"organic-machine","url":"http://127.0.0.1:8080/healthz","http_code":200,"healthy":true}
|
|
||||||
|
|
||||||
# 3) Uso como gate en un script de monitorizacion (exit code).
|
|
||||||
if check_service_health_via_ssh om "http://127.0.0.1:8487/agent" \
|
|
||||||
--token-from-env /home/ubuntu/CodeProyects/agents_and_robots/.env AGENTS_API_KEY >/dev/null; then
|
|
||||||
echo "service vivo"
|
|
||||||
else
|
|
||||||
echo "service caido — alertar"
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cuando usarla
|
|
||||||
|
|
||||||
Usala cuando necesites comprobar si un service HTTP de un host remoto esta sano y ese
|
|
||||||
service **solo escucha en loopback** (127.0.0.1) del host, por lo que no puedes
|
|
||||||
curl-earlo directamente desde tu maquina. Tipico de APIs internas detras de un reverse
|
|
||||||
proxy, daemons con bearer auth, o services systemd que exponen un `/health` privado.
|
|
||||||
Antes de reiniciar un service, en un cron de monitorizacion, o como `e2e_check` de un
|
|
||||||
deploy.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- Requiere **SSH por key auth** al host (usa `-o BatchMode=yes`): si el host pide
|
|
||||||
password, falla en vez de colgarse. El alias debe estar en `~/.ssh/config`.
|
|
||||||
- El service objetivo **debe escuchar en loopback del host remoto** — la URL se
|
|
||||||
resuelve *dentro* del host. `http://127.0.0.1:PORT` apunta al host remoto, no a tu PC.
|
|
||||||
- **No requiere sudo**: solo lee un `.env` (grep) y hace curl como el usuario SSH.
|
|
||||||
El usuario SSH debe tener permiso de lectura sobre el `.env` remoto.
|
|
||||||
- El **token nunca se imprime ni se hardcodea**: con `--token-from-env` se resuelve
|
|
||||||
dentro del host y solo se usa en el header `Authorization`. Con `--token <literal>`
|
|
||||||
el secreto queda en el argv del comando ssh local — preferir `--token-from-env`
|
|
||||||
para secretos persistidos en disco.
|
|
||||||
- `grep` del `.env` toma la **primera** linea que matchea `^<ENV_VAR>=` y recorta
|
|
||||||
comillas/espacios. Si la var aparece varias veces o usa interpolacion, revisa el match.
|
|
||||||
- `curl -sf` no sigue redirects: un 3xx cuenta como no-2xx (healthy=false salvo
|
|
||||||
`--expect-status` explicito).
|
|
||||||
- Requiere `curl` instalado en el **host remoto** (no en el local).
|
|
||||||
- El JSON de salida se emite siempre (incluso en fallo); el caller decide por el
|
|
||||||
`exit code` (0 healthy, 1 no healthy, 2 error de uso) o por el campo `healthy`.
|
|
||||||
|
|
||||||
## Notas
|
|
||||||
|
|
||||||
- Testeable sin red: el runner SSH es inyectable via `CHECK_HEALTH_SSH_BIN` (un stub
|
|
||||||
que emite el `http_code` deseado), por eso los tests no abren conexiones reales.
|
|
||||||
- El snippet remoto normaliza la salida de curl a un unico `http_code` aunque
|
|
||||||
`curl -sf` devuelva error (emite `<curl_rc>:<http_code>` y la funcion extrae el codigo).
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# check_service_health_via_ssh — Comprueba la salud de un service HTTP que solo
|
|
||||||
# escucha en loopback de un host remoto, entrando por SSH y haciendo curl con
|
|
||||||
# bearer token opcional (leido de un .env remoto o pasado literal).
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
check_service_health_via_ssh() {
|
|
||||||
local ssh_host="" local_url=""
|
|
||||||
local remote_env_path="" env_var=""
|
|
||||||
local token_literal=""
|
|
||||||
local expect_status="" # vacio = aceptar cualquier 2xx
|
|
||||||
local connect_timeout=5
|
|
||||||
local curl_timeout=10
|
|
||||||
|
|
||||||
# --- parseo de args (posicionales + flags) ---
|
|
||||||
local positional=()
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--token-from-env)
|
|
||||||
remote_env_path="${2:-}"
|
|
||||||
env_var="${3:-}"
|
|
||||||
if [[ -z "$remote_env_path" || -z "$env_var" ]]; then
|
|
||||||
echo "check_service_health_via_ssh: --token-from-env requiere <remote_env_path> <ENV_VAR>" >&2
|
|
||||||
return 2
|
|
||||||
fi
|
|
||||||
shift 3
|
|
||||||
;;
|
|
||||||
--token)
|
|
||||||
token_literal="${2:-}"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--expect-status)
|
|
||||||
expect_status="${2:-}"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--connect-timeout)
|
|
||||||
connect_timeout="${2:-5}"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--curl-timeout)
|
|
||||||
curl_timeout="${2:-10}"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--)
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
-*)
|
|
||||||
echo "check_service_health_via_ssh: flag desconocida '$1'" >&2
|
|
||||||
return 2
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
positional+=("$1")
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
ssh_host="${positional[0]:-}"
|
|
||||||
local_url="${positional[1]:-}"
|
|
||||||
|
|
||||||
if [[ -z "$ssh_host" || -z "$local_url" ]]; then
|
|
||||||
echo "check_service_health_via_ssh: uso: check_service_health_via_ssh <ssh_host> <local_url> [--token-from-env <remote_env_path> <ENV_VAR>] [--token <literal>] [--expect-status 200]" >&2
|
|
||||||
return 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- construir el snippet remoto que se ejecuta dentro del host via SSH ---
|
|
||||||
# El token NUNCA se imprime: se resuelve dentro del host remoto y se usa
|
|
||||||
# directamente en el header Authorization. El snippet emite SOLO el http_code.
|
|
||||||
#
|
|
||||||
# Casos de token:
|
|
||||||
# 1) --token-from-env: lee el valor de <ENV_VAR>= del .env remoto.
|
|
||||||
# 2) --token <literal>: el literal se inyecta en el snippet (cuidado: queda
|
|
||||||
# en argv del comando ssh local; preferir --token-from-env para secretos).
|
|
||||||
# 3) sin token: curl sin header Authorization.
|
|
||||||
local remote_script
|
|
||||||
if [[ -n "$remote_env_path" ]]; then
|
|
||||||
# grep el valor del .env remoto, recortando posibles comillas y espacios.
|
|
||||||
remote_script=$(cat <<REMOTE
|
|
||||||
set -e
|
|
||||||
TOKEN=\$(grep -E '^[[:space:]]*${env_var}[[:space:]]*=' '${remote_env_path}' 2>/dev/null | head -n1 | cut -d= -f2- | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*\$//' -e 's/^["'\'']//' -e 's/["'\'']\$//')
|
|
||||||
if [ -z "\$TOKEN" ]; then
|
|
||||||
echo "000"
|
|
||||||
exit 7
|
|
||||||
fi
|
|
||||||
curl -sf -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} -H "Authorization: Bearer \$TOKEN" '${local_url}' || echo "\$?:\$(curl -s -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} -H "Authorization: Bearer \$TOKEN" '${local_url}' 2>/dev/null)"
|
|
||||||
REMOTE
|
|
||||||
)
|
|
||||||
elif [[ -n "$token_literal" ]]; then
|
|
||||||
remote_script=$(cat <<REMOTE
|
|
||||||
set -e
|
|
||||||
TOKEN='${token_literal}'
|
|
||||||
curl -sf -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} -H "Authorization: Bearer \$TOKEN" '${local_url}' || echo "\$?:\$(curl -s -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} -H "Authorization: Bearer \$TOKEN" '${local_url}' 2>/dev/null)"
|
|
||||||
REMOTE
|
|
||||||
)
|
|
||||||
else
|
|
||||||
remote_script=$(cat <<REMOTE
|
|
||||||
set -e
|
|
||||||
curl -sf -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} '${local_url}' || echo "\$?:\$(curl -s -o /dev/null -w '%{http_code}' --max-time ${curl_timeout} '${local_url}' 2>/dev/null)"
|
|
||||||
REMOTE
|
|
||||||
)
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- ejecutar via SSH (o via runner inyectado en tests) ---
|
|
||||||
# CHECK_HEALTH_SSH_BIN permite a los tests sustituir el comando ssh por un
|
|
||||||
# stub que devuelve un http_code fijo, sin tocar la red.
|
|
||||||
local ssh_bin="${CHECK_HEALTH_SSH_BIN:-ssh}"
|
|
||||||
local raw rc=0
|
|
||||||
raw=$("$ssh_bin" -o BatchMode=yes -o ConnectTimeout="$connect_timeout" "$ssh_host" "$remote_script" 2>/dev/null) || rc=$?
|
|
||||||
|
|
||||||
# El snippet remoto, cuando curl -sf falla, emite "<curl_rc>:<http_code>".
|
|
||||||
# Cuando curl tiene exito, emite solo "<http_code>". Normalizamos a http_code.
|
|
||||||
local http_code
|
|
||||||
if [[ "$raw" == *:* ]]; then
|
|
||||||
http_code="${raw##*:}"
|
|
||||||
else
|
|
||||||
http_code="$raw"
|
|
||||||
fi
|
|
||||||
# sanitizar: solo digitos; cualquier otra cosa => 000
|
|
||||||
if [[ ! "$http_code" =~ ^[0-9]+$ ]]; then
|
|
||||||
http_code="000"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Si el SSH en si fallo (conexion, host caido) y no hay codigo util.
|
|
||||||
local status="ok"
|
|
||||||
if [[ "$rc" -ne 0 && "$http_code" == "000" ]]; then
|
|
||||||
status="error"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- decidir healthy ---
|
|
||||||
local healthy="false"
|
|
||||||
if [[ -n "$expect_status" ]]; then
|
|
||||||
[[ "$http_code" == "$expect_status" ]] && healthy="true"
|
|
||||||
else
|
|
||||||
# default: cualquier 2xx
|
|
||||||
[[ "$http_code" =~ ^2[0-9][0-9]$ ]] && healthy="true"
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '{"status":"%s","host":"%s","url":"%s","http_code":%s,"healthy":%s}\n' \
|
|
||||||
"$status" "$ssh_host" "$local_url" "$http_code" "$healthy"
|
|
||||||
|
|
||||||
[[ "$healthy" == "true" ]] && return 0 || return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
|
||||||
check_service_health_via_ssh "$@"
|
|
||||||
fi
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Tests para check_service_health_via_ssh
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
source "$SCRIPT_DIR/check_service_health_via_ssh.sh"
|
|
||||||
|
|
||||||
PASS=0
|
|
||||||
FAIL=0
|
|
||||||
|
|
||||||
assert_contains() {
|
|
||||||
local test_name="$1" needle="$2" haystack="$3"
|
|
||||||
if echo "$haystack" | grep -qF "$needle"; then
|
|
||||||
echo "PASS: $test_name"
|
|
||||||
PASS=$((PASS+1))
|
|
||||||
else
|
|
||||||
echo "FAIL: $test_name — expected to contain '$needle'"
|
|
||||||
echo " got: $haystack"
|
|
||||||
FAIL=$((FAIL+1))
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_not_contains() {
|
|
||||||
local test_name="$1" needle="$2" haystack="$3"
|
|
||||||
if ! echo "$haystack" | grep -qF "$needle"; then
|
|
||||||
echo "PASS: $test_name"
|
|
||||||
PASS=$((PASS+1))
|
|
||||||
else
|
|
||||||
echo "FAIL: $test_name — expected NOT to contain '$needle'"
|
|
||||||
echo " got: $haystack"
|
|
||||||
FAIL=$((FAIL+1))
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- stub SSH: en vez de conectarse, lee el .env remoto fake (si el snippet lo
|
|
||||||
# referencia) y emite el http_code de la env var STUB_HTTP_CODE. Simula tanto el
|
|
||||||
# caso "curl exito" (solo http_code) como "curl fallo" (<rc>:<http_code>). ---
|
|
||||||
STUB=$(mktemp)
|
|
||||||
chmod +x "$STUB"
|
|
||||||
cat > "$STUB" <<'STUBEOF'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Stub de ssh para tests. Ignora flags -o ... y el host; el ultimo arg es el
|
|
||||||
# script remoto. Emite el codigo segun STUB_HTTP_CODE / STUB_CURL_RC.
|
|
||||||
code="${STUB_HTTP_CODE:-200}"
|
|
||||||
rc="${STUB_CURL_RC:-0}"
|
|
||||||
# Si el script remoto referencia un .env y STUB_TOKEN_EMPTY=1, simular token vacio.
|
|
||||||
if [[ "${STUB_TOKEN_EMPTY:-0}" == "1" ]]; then
|
|
||||||
echo "000"
|
|
||||||
exit 7
|
|
||||||
fi
|
|
||||||
if [[ "$rc" == "0" ]]; then
|
|
||||||
echo "$code"
|
|
||||||
else
|
|
||||||
echo "${rc}:${code}"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
STUBEOF
|
|
||||||
chmod +x "$STUB"
|
|
||||||
|
|
||||||
FAKE_ENV=$(mktemp)
|
|
||||||
cat > "$FAKE_ENV" <<'ENVEOF'
|
|
||||||
SOME_OTHER=foo
|
|
||||||
AGENTS_API_KEY=supersecret-token-123
|
|
||||||
ANOTHER=bar
|
|
||||||
ENVEOF
|
|
||||||
|
|
||||||
trap 'rm -f "$STUB" "$FAKE_ENV"' EXIT
|
|
||||||
|
|
||||||
# --- Test: service healthy con token desde .env remoto (200 esperado) ---
|
|
||||||
result=$(CHECK_HEALTH_SSH_BIN="$STUB" STUB_HTTP_CODE=200 \
|
|
||||||
check_service_health_via_ssh om "http://127.0.0.1:8487/agent" \
|
|
||||||
--token-from-env "$FAKE_ENV" AGENTS_API_KEY --expect-status 200) || true
|
|
||||||
assert_contains "service healthy con token desde env remoto" '"healthy":true' "$result"
|
|
||||||
assert_contains "service healthy con token desde env remoto" '"http_code":200' "$result"
|
|
||||||
assert_contains "service healthy con token desde env remoto" '"status":"ok"' "$result"
|
|
||||||
assert_not_contains "service healthy con token desde env remoto" 'supersecret' "$result"
|
|
||||||
|
|
||||||
# --- Test: service no healthy cuando http_code no coincide con expect-status ---
|
|
||||||
result=$(CHECK_HEALTH_SSH_BIN="$STUB" STUB_HTTP_CODE=503 STUB_CURL_RC=22 \
|
|
||||||
check_service_health_via_ssh om "http://127.0.0.1:8487/agent" \
|
|
||||||
--token-from-env "$FAKE_ENV" AGENTS_API_KEY --expect-status 200) || true
|
|
||||||
assert_contains "service no healthy con http_code 503" '"healthy":false' "$result"
|
|
||||||
assert_contains "service no healthy con http_code 503" '"http_code":503' "$result"
|
|
||||||
|
|
||||||
# --- Test: salida JSON nunca filtra el token ---
|
|
||||||
result=$(CHECK_HEALTH_SSH_BIN="$STUB" STUB_HTTP_CODE=200 \
|
|
||||||
check_service_health_via_ssh om "http://127.0.0.1:9000/health" \
|
|
||||||
--token literal-secret-xyz) || true
|
|
||||||
assert_not_contains "salida JSON nunca filtra el token" 'literal-secret-xyz' "$result"
|
|
||||||
assert_contains "salida JSON nunca filtra el token" '"healthy":true' "$result"
|
|
||||||
|
|
||||||
# --- Test: sin token y 2xx por defecto cuenta como healthy ---
|
|
||||||
result=$(CHECK_HEALTH_SSH_BIN="$STUB" STUB_HTTP_CODE=204 \
|
|
||||||
check_service_health_via_ssh om "http://127.0.0.1:8080/ping") || true
|
|
||||||
assert_contains "sin token 2xx por defecto es healthy" '"healthy":true' "$result"
|
|
||||||
assert_contains "sin token 2xx por defecto es healthy" '"http_code":204' "$result"
|
|
||||||
|
|
||||||
# --- Test: falta argumento obligatorio devuelve error de uso ---
|
|
||||||
set +e
|
|
||||||
err=$(check_service_health_via_ssh om 2>&1)
|
|
||||||
ec=$?
|
|
||||||
set -e
|
|
||||||
assert_contains "falta argumento obligatorio devuelve error de uso" 'uso:' "$err"
|
|
||||||
if [[ "$ec" -ne 0 ]]; then
|
|
||||||
echo "PASS: falta argumento sale con codigo distinto de 0"
|
|
||||||
PASS=$((PASS+1))
|
|
||||||
else
|
|
||||||
echo "FAIL: falta argumento deberia salir != 0 (got $ec)"
|
|
||||||
FAIL=$((FAIL+1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "---"
|
|
||||||
echo "Results: $PASS passed, $FAIL failed"
|
|
||||||
[[ $FAIL -eq 0 ]] || exit 1
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user