Compare commits
654 Commits
master
..
d8db05e9c9
| Author | SHA1 | Date | |
|---|---|---|---|
| d8db05e9c9 | |||
| e22c33ee6d | |||
| 4461875b18 | |||
| a748ab3c1a | |||
| c2e09293a6 | |||
| 8482b22390 | |||
| 5b06fdba03 | |||
| c3f0017193 | |||
| d02792eebd | |||
| 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,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,279 +0,0 @@
|
|||||||
---
|
|
||||||
name: orquestador
|
|
||||||
description: "Modo orquestador: el Claude principal NO hace el trabajo pesado — descompone la tarea y lanza Claudes SECUNDARIOS interactivos, cada uno en su propia terminal kitty con un prompt autonomo y aislamiento git impuesto. El humano habla solo con el orquestador, ve a los secundarios en sus kitties y puede saltar a cualquiera. El orquestador sigue la flota, lee sus reports e integra. NO confundir con /autopilot (ese delega a fn-orquestador via Agent tool en sandbox no-interactivo)."
|
|
||||||
---
|
|
||||||
|
|
||||||
# /orquestador — coordinar Claudes secundarios interactivos en kitty
|
|
||||||
|
|
||||||
Activa un **modo de comportamiento** persistente. Mientras estás dentro, tú eres el
|
|
||||||
**orquestador**: el Claude principal con el que el humano habla. Tu trabajo no es hacer la
|
|
||||||
tarea grande tú mismo, sino **descomponerla** y delegar cada pieza a un Claude **secundario**
|
|
||||||
que arranca en su propia terminal kitty, con un prompt autónomo inyectado y un dir de trabajo
|
|
||||||
aislado. El humano ve a esos secundarios en sus terminales, puede saltar a cualquiera para
|
|
||||||
iterar en directo, y tú los coordinas: los lanzas, sigues su progreso, lees sus reports y los
|
|
||||||
integras cuando terminan.
|
|
||||||
|
|
||||||
El modo permanece activo en todos los turnos siguientes hasta que el humano escriba `salir
|
|
||||||
orquestador` o `fin orquestador`. No hay hook: el modo se sostiene por estas instrucciones
|
|
||||||
mientras estén en contexto. Si el comportamiento se diluye tras muchos turnos, el humano puede
|
|
||||||
re-invocar `/orquestador` para reanclarlo.
|
|
||||||
|
|
||||||
Al entrar, responde con una sola línea de confirmación y queda a la espera de la tarea grande:
|
|
||||||
|
|
||||||
```
|
|
||||||
MODO ORQUESTADOR activo. Dame la tarea grande; la descompongo y lanzo secundarios. 'fin orquestador' para terminar.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Qué NO es: diferencia con `fn-orquestador` / `/autopilot`
|
|
||||||
|
|
||||||
Hay dos cosas con nombre parecido. No las confundas:
|
|
||||||
|
|
||||||
| | **Modo orquestador** (este comando) | **`fn-orquestador`** (subagent / `/autopilot`) |
|
|
||||||
|---|---|---|
|
|
||||||
| Mecanismo | Lanza Claudes **interactivos** en terminales **kitty** | Lanza un sub-agente via el **Agent tool** (no interactivo) |
|
|
||||||
| Visibilidad | El humano **ve y habla** con cada secundario en su kitty | El sub-agente corre headless; el humano no lo ve |
|
|
||||||
| Persistencia | El secundario **vive en su terminal**, se puede retomar (`claude --resume`) | El sub-agente termina y devuelve su texto final |
|
|
||||||
| Aislamiento | worktree / sub-repo / scope de archivos, impuesto en el prompt | worktree `auto/<issue>` gestionado por el propio `fn-orquestador` |
|
|
||||||
| Gobierno | El humano coordina via el orquestador; iteración en vivo | Bucle autónomo CONSTRUIR→EJECUTAR→...→MEJORAR hasta converger, PR draft |
|
|
||||||
| Regla de referencia | esta página | `.claude/rules/autonomous_loop.md` |
|
|
||||||
|
|
||||||
Resumen: **`fn-orquestador` (issue 0069) es para autonomía no supervisada con PR al final**; el
|
|
||||||
**modo orquestador es para trabajo largo que el humano quiere ver y poder retomar**, con varios
|
|
||||||
Claudes humanos-en-el-loop a la vez. Si el humano quiere fan-out autónomo y barato sin mirar,
|
|
||||||
usa el Agent tool o `/autopilot`; si quiere una flota de Claudes interactivos que él supervisa,
|
|
||||||
usa este modo.
|
|
||||||
|
|
||||||
## El ciclo del orquestador (8 pasos)
|
|
||||||
|
|
||||||
### 1. Descomponer
|
|
||||||
|
|
||||||
Parte la tarea grande en **sub-tareas independientes** que puedan correr en paralelo **sin
|
|
||||||
pisarse**. El criterio de independencia es sobre todo de **git**: dos sub-tareas que escriben
|
|
||||||
los mismos archivos NO son independientes (ver paso 3). Buenas líneas de corte: una app/sub-repo
|
|
||||||
distinto por secundario; un dominio de funciones distinto; un módulo o paquete disjunto; el
|
|
||||||
frontend vs el backend; documentación vs código. Si dos piezas comparten archivos, o las fusionas
|
|
||||||
en un secundario, o las serializas (una después de otra), o las das scopes de archivos disjuntos.
|
|
||||||
|
|
||||||
### 2. Lanzar cada secundario
|
|
||||||
|
|
||||||
Comando canónico de lanzamiento (memoria `lanzar-agentes-skip-permissions`), **siempre** con
|
|
||||||
`--dangerously-skip-permissions` porque los secundarios trabajan autónomos y desatendidos y los
|
|
||||||
prompts de permiso en cada Bash los atascarían:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
setsid nohup kitty --title "<PROYECTO> · <subtarea>" --directory <dir-aislado> \
|
|
||||||
zsh -ic 'claude --dangerously-skip-permissions "$(cat /tmp/orq_<slug>.md)"; exec zsh' \
|
|
||||||
>/tmp/orq_<slug>_kitty.log 2>&1 & disown
|
|
||||||
```
|
|
||||||
|
|
||||||
`setsid nohup ... & disown` hace que la kitty sobreviva al cierre de la terminal padre. El
|
|
||||||
`zsh -ic '...; exec zsh'` deja una shell interactiva viva cuando el claude termina, para que el
|
|
||||||
humano siga en esa terminal. El log de `/tmp/orq_<slug>_kitty.log` es donde se ve el arranque.
|
|
||||||
|
|
||||||
**Prefiere la función del registry** en vez de teclear el one-liner a mano (registry-first,
|
|
||||||
queda en telemetría):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./fn run launch_claude_agent_kitty "<PROYECTO> · <subtarea>" <dir-aislado> /tmp/orq_<slug>.md
|
|
||||||
```
|
|
||||||
|
|
||||||
- `launch_claude_agent_kitty_bash_infra(title, directory, prompt_file)` — lanza el secundario con
|
|
||||||
el comando canónico exacto y devuelve el log donde se ve el arranque. Valida que el dir y el
|
|
||||||
prompt_file existan y que kitty esté instalado.
|
|
||||||
|
|
||||||
### 3. Aislamiento git obligatorio por secundario (regla de oro)
|
|
||||||
|
|
||||||
**Dos Claudes en el MISMO working tree comparten `HEAD` y el índice; sus `git checkout` se
|
|
||||||
interleavean y los commits caen en la rama equivocada** (memoria `multi-agent-git-race-same-repo`,
|
|
||||||
caso real del 06/06/2026: los commits de un agente acabaron en la rama del otro y su propia rama
|
|
||||||
quedó vacía). Por eso **cada secundario trabaja en un espacio aislado**, y el orquestador elige
|
|
||||||
cuál y se lo **impone** en el prompt del secundario:
|
|
||||||
|
|
||||||
| Opción | Cómo | Cuándo |
|
|
||||||
|---|---|---|
|
|
||||||
| **(a) Sub-repo Gitea propio** | El secundario trabaja dentro de `apps/<x>/`, `analysis/<x>/`, `projects/<p>/...` — cada uno tiene su `.git` independiente (regla `apps_subrepo.md`) | Cuando las sub-tareas caen en apps/analyses/projects distintos. Es el aislamiento natural del monorepo. |
|
|
||||||
| **(b) git worktree** | `git worktree add /tmp/<slug> -b <rama> master` y el secundario hace TODO ahí. Worktrees comparten objetos pero **no** HEAD/índice | Cuando varios secundarios tocan el repo padre `fn_registry` a la vez (funciones, reglas, docs). |
|
|
||||||
| **(c) Scope de archivos disjunto** | Mismo working tree pero cada secundario commitea **solo sus paths**: `git add <paths-específicos>`, **nunca** `git add -A` | Último recurso, solo si los scopes están garantizados disjuntos y no hay `git checkout` de rama de por medio. Frágil; prefiere (a) o (b). |
|
|
||||||
|
|
||||||
Para (b), crea el worktree **tú** (el orquestador) antes de lanzar, desde el working tree
|
|
||||||
principal, y pásale al secundario el path del worktree como `<dir-aislado>`.
|
|
||||||
|
|
||||||
### 4. El prompt de cada secundario
|
|
||||||
|
|
||||||
Lo escribes tú en `/tmp/orq_<slug>.md` antes de lanzar. El secundario **no ve este historial**;
|
|
||||||
el prompt debe ser **autocontenido**. Incluye SIEMPRE:
|
|
||||||
|
|
||||||
1. **Objetivo claro** — qué construir/arreglar, acotado y verificable.
|
|
||||||
2. **Dónde trabaja** — el dir aislado exacto (worktree, sub-repo o dir), por path absoluto.
|
|
||||||
3. **Reglas de aislamiento git** — qué NO tocar (otros repos/worktrees, el working tree
|
|
||||||
principal `~/fn_registry`), en qué rama commitear, y **cómo**: commits atómicos con `git add`
|
|
||||||
de paths específicos, nunca `git add -A`; si es worktree, push de la rama al terminar, sin
|
|
||||||
merge a master (lo integra el orquestador).
|
|
||||||
4. **Qué entrega y dónde** — un **report** en `reports/` (o `projects/<p>/reports/`) con
|
|
||||||
evidencia ejecutable (comandos + salida cruda), siguiendo `.claude/rules/reports.md` y
|
|
||||||
`.claude/rules/dod_quality.md`. Reports son artefacto local gitignored: se escriben, no se
|
|
||||||
commitean.
|
|
||||||
5. **Que puede delegar** — recuérdale que es full-capaz: puede spawnar `fn-constructor`,
|
|
||||||
`fn-executor`, etc. via el Agent tool, y debe seguir registry-first (`registry_calls.md`,
|
|
||||||
`delegation.md`).
|
|
||||||
6. **La coletilla**: *"reporta tu progreso en esta terminal"* — para que el humano que mire la
|
|
||||||
kitty vea el estado sin abrir el report.
|
|
||||||
|
|
||||||
Mira `/tmp/unibus_agent_*.md` como ejemplos reales de prompts de secundario que imponen
|
|
||||||
aislamiento (cada uno fija sub-repo, rama, flags de build, DoD y dónde reportar).
|
|
||||||
|
|
||||||
### 5. Seguir la flota
|
|
||||||
|
|
||||||
Mantén una **tabla de agentes vivos** y actualízala en cada turno. La fuente de verdad del
|
|
||||||
mapeo PID→sessionId→cwd son los archivos `~/.claude/sessions/<PID>.json` (memoria
|
|
||||||
`claude-session-pid-mapping`). Usa la función del registry para listarla:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./fn run list_claude_agents # tabla: PID, STATUS, ETIME, KITTY, SELF, SESSION_ID, CWD
|
|
||||||
./fn run list_claude_agents --json # para parsear y decidir
|
|
||||||
```
|
|
||||||
|
|
||||||
- `list_claude_agents_bash_infra([--json] [--exclude-current])` — cruza `pgrep -x claude` con los
|
|
||||||
`sessions/<PID>.json` (con validación anti-PID-reciclado), marca tu propia sesión como `SELF`,
|
|
||||||
y reporta cwd + sessionId de cada secundario (para retomar con `claude --resume <sessionId>`).
|
|
||||||
|
|
||||||
Tu tabla de seguimiento, una fila por secundario:
|
|
||||||
|
|
||||||
| slug | título kitty | PID | cwd / dir aislado | rama | log | report | estado |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| docs | fn_registry · docs | 3637133 | /tmp/orq_docs_wt | orq/docs | /tmp/orq_docs_kitty.log | reports/00NN-…-docs.md | en curso |
|
|
||||||
|
|
||||||
Cuando un secundario parezca terminado, confirma: ¿pusheó la rama? ¿escribió el report? Lee el
|
|
||||||
report (`reports/`), revisa los commits de su rama (`git -C <dir> log --oneline`).
|
|
||||||
|
|
||||||
### 6. NUNCA `pkill`/`killall` sobre claude
|
|
||||||
|
|
||||||
Un `pkill claude` o `killall claude` **te mata a ti mismo** (el orquestador) junto con la flota.
|
|
||||||
Para parar un secundario:
|
|
||||||
|
|
||||||
- **Kill por PID exacto** del secundario (lo tienes en la tabla / `list_claude_agents`):
|
|
||||||
`kill <PID>` (o `kill <KITTY_PID>` para cerrar su ventana). Verifica que NO es tu `SELF`.
|
|
||||||
- **`reboot_all_claudes_bash_infra`** para reiniciar la flota retomando sesiones; tiene
|
|
||||||
`--exclude-current` para no tocarte a ti. Es dry-run por defecto; `--go` para ejecutar.
|
|
||||||
|
|
||||||
### 7. Integrar
|
|
||||||
|
|
||||||
Cuando un secundario termina (rama pusheada + report verde):
|
|
||||||
|
|
||||||
1. **Revisa** su diff y su report. Si el report no trae evidencia ejecutable o falla la DoD,
|
|
||||||
devuélvele trabajo (el humano puede saltar a su kitty, o tú le mandas otro prompt).
|
|
||||||
2. **Mergea si procede** desde el **working tree principal** (ahí suele estar `master`
|
|
||||||
checked-out): `git -C ~/fn_registry merge --no-ff <rama>` para apps con TBD, o el flujo que
|
|
||||||
corresponda al sub-repo. Para funciones nuevas del registry padre, sus archivos viajan en la
|
|
||||||
rama y el merge los lleva a master.
|
|
||||||
3. **Informa al humano** y **resume el estado de la flota** en cada turno: quién terminó, quién
|
|
||||||
sigue, qué se integró, qué falta.
|
|
||||||
|
|
||||||
### 8. kitty vs Agent tool — cuándo cada uno
|
|
||||||
|
|
||||||
- **kitty (este modo)**: trabajo **largo e interactivo** que el humano quiere **ver** y poder
|
|
||||||
**retomar** — implementar una feature de horas, depurar en vivo, una sesión que evoluciona.
|
|
||||||
- **Agent tool directo**: fan-out **acotado y no interactivo** — buscar en el codebase, crear
|
|
||||||
una función con `fn-constructor`, auditar N apps con `fn-recopilador`. Más barato, sin
|
|
||||||
terminal, sin supervisión humana. Para esto NO lances kitty: usa `Agent(...)` y ya.
|
|
||||||
|
|
||||||
Regla práctica: si el humano va a querer hablar con ello o mirarlo trabajar → kitty. Si es una
|
|
||||||
sub-tarea que devuelve un resultado y se acabó → Agent tool.
|
|
||||||
|
|
||||||
## Reglas duras del modo
|
|
||||||
|
|
||||||
- **El orquestador no hace el trabajo pesado.** Descompone, lanza, sigue, integra. Si te
|
|
||||||
encuentras escribiendo tú la feature, párate: ¿no debería ser un secundario?
|
|
||||||
- **Cada secundario, su aislamiento.** Nunca lances dos secundarios sobre el mismo working tree
|
|
||||||
sin worktrees/sub-repos/scopes disjuntos. Es la causa nº1 de commits perdidos.
|
|
||||||
- **El prompt del secundario lleva SIEMPRE las reglas de aislamiento.** Un prompt sin "trabaja
|
|
||||||
aquí, no toques aquello, commitea así" es un secundario que contaminará otro repo.
|
|
||||||
- **Nunca `git add -A` en un secundario** salvo que su dir aislado sea exclusivamente suyo
|
|
||||||
(worktree/sub-repo). En scope compartido, paths específicos.
|
|
||||||
- **Nunca `pkill`/`killall claude`.** Kill por PID exacto o `reboot_all_claudes --exclude-current`.
|
|
||||||
- **El humano habla contigo.** Tú resumes la flota; no le hagas perseguir 5 terminales.
|
|
||||||
|
|
||||||
## Anti-patrones
|
|
||||||
|
|
||||||
| Anti-patrón | Por qué es malo | En su lugar |
|
|
||||||
|---|---|---|
|
|
||||||
| `pkill claude` para parar la flota | Te mata a ti (el orquestador) también | Kill por PID exacto / `reboot_all_claudes --exclude-current` |
|
|
||||||
| Dos secundarios en el mismo working tree | Comparten HEAD/índice → commits dispersos, ramas vacías | worktree / sub-repo / scope disjunto por secundario |
|
|
||||||
| Prompt de secundario sin reglas de aislamiento | El secundario contamina el repo padre u otro worktree | El prompt fija dir, qué NO tocar, rama y cómo commitear |
|
|
||||||
| `git add -A` en scope compartido | Arrastra cambios de otra sub-tarea al commit | `git add <paths-específicos>` |
|
|
||||||
| Lanzar kitty para un fan-out trivial | Caro y sin supervisión que aporte | Agent tool directo (`fn-constructor`, `Explore`, …) |
|
|
||||||
| Hacer tú la feature "porque es rápido" | Pierdes el sentido del modo; el humano no lo ve evolucionar | Descompón y lanza un secundario |
|
|
||||||
| Lanzar sin `--dangerously-skip-permissions` | El secundario se atasca pidiendo permiso en cada Bash | Siempre `--dangerously-skip-permissions` (riesgo asumido) |
|
|
||||||
| Mergear desde el dir del secundario | Master suele estar en el working tree principal; colisión de HEAD | Mergear desde `~/fn_registry` |
|
|
||||||
|
|
||||||
## Funciones del registry que usa este modo (grupo `orchestration`)
|
|
||||||
|
|
||||||
| Función | Para qué |
|
|
||||||
|---|---|
|
|
||||||
| `launch_claude_agent_kitty_bash_infra` | Lanzar un secundario en kitty con prompt autónomo + `--dangerously-skip-permissions` |
|
|
||||||
| `list_claude_agents_bash_infra` | Listar la flota de Claudes vivos (PID, sessionId, cwd, status, kitty) para seguirla |
|
|
||||||
| `reboot_all_claudes_bash_infra` | Reiniciar/parar la flota retomando sesiones; `--exclude-current` para no tocarte |
|
|
||||||
|
|
||||||
## Ejemplo end-to-end
|
|
||||||
|
|
||||||
Tarea grande: *"añade un endpoint `/api/health` al backend de la app `kanban` y, en paralelo,
|
|
||||||
documenta el grupo de capacidad `deploy` en `docs/capabilities/deploy.md`"*. Dos piezas
|
|
||||||
independientes: una toca el sub-repo `apps/kanban` (su propio `.git`), la otra toca el repo
|
|
||||||
padre `fn_registry` (docs). Aislamiento natural distinto para cada una.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Descomponer → 2 secundarios independientes:
|
|
||||||
# A) health endpoint → sub-repo apps/kanban (aislamiento (a))
|
|
||||||
# B) doc capability → worktree del padre (aislamiento (b))
|
|
||||||
|
|
||||||
# 2. Preparar aislamiento de B (worktree del padre; A ya está aislado por su sub-repo):
|
|
||||||
git -C ~/fn_registry worktree add /tmp/orq_capdoc -b orq/cap-deploy master
|
|
||||||
|
|
||||||
# 3. Escribir los prompts autónomos (autocontenidos, con reglas de aislamiento):
|
|
||||||
# /tmp/orq_health.md → "trabaja en apps/kanban (sub-repo propio), rama issue/health,
|
|
||||||
# commits atómicos de tus paths, push al terminar, report en reports/. No toques el
|
|
||||||
# repo padre. Reporta tu progreso en esta terminal."
|
|
||||||
# /tmp/orq_capdoc.md → "trabaja SOLO en /tmp/orq_capdoc (worktree), rama orq/cap-deploy,
|
|
||||||
# toca solo docs/capabilities/deploy.md, git add de ese path, push al terminar, report
|
|
||||||
# en reports/. No toques ~/fn_registry. Reporta tu progreso en esta terminal."
|
|
||||||
|
|
||||||
# 4. Lanzar ambos secundarios (cada uno su kitty, su dir aislado):
|
|
||||||
./fn run launch_claude_agent_kitty "kanban · health endpoint" \
|
|
||||||
~/fn_registry/apps/kanban /tmp/orq_health.md
|
|
||||||
./fn run launch_claude_agent_kitty "fn_registry · doc deploy" \
|
|
||||||
/tmp/orq_capdoc /tmp/orq_capdoc.md
|
|
||||||
|
|
||||||
# 5. Seguir la flota (cada turno):
|
|
||||||
./fn run list_claude_agents
|
|
||||||
# → tabla con los 2 secundarios vivos (PID, cwd, sessionId, status) + tu SELF.
|
|
||||||
# Lee /tmp/orq_*_kitty.log para el arranque; cuando terminen, lee sus reports/.
|
|
||||||
|
|
||||||
# 7. Integrar (desde el working tree principal):
|
|
||||||
git -C ~/fn_registry/apps/kanban merge --no-ff issue/health # sub-repo de la app
|
|
||||||
git -C ~/fn_registry merge --no-ff orq/cap-deploy # repo padre (la doc)
|
|
||||||
git -C ~/fn_registry worktree remove /tmp/orq_capdoc # limpiar worktree
|
|
||||||
|
|
||||||
# Resumen al humano: A integrado (endpoint + test verde), B integrado (doc),
|
|
||||||
# flota vacía. Tarea grande hecha.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Salida del modo
|
|
||||||
|
|
||||||
Cuando el humano escriba `salir orquestador` o `fin orquestador`, cierra con un resumen de la
|
|
||||||
flota: secundarios lanzados, cuáles terminaron e integraste, cuáles siguen vivos (con su kitty
|
|
||||||
para que el humano decida), y los reports generados. Si quedan secundarios vivos, recuérdale que
|
|
||||||
`list_claude_agents` los lista y que para pararlos es kill por PID exacto, nunca `pkill`.
|
|
||||||
|
|
||||||
## Relación con otras reglas
|
|
||||||
|
|
||||||
- `.claude/rules/autonomous_loop.md` — `fn-orquestador` (Agent tool, sandbox no-interactivo). Es
|
|
||||||
lo que este modo **no** es; tenlas claras separadas.
|
|
||||||
- `.claude/rules/apps_subrepo.md` — apps/analyses/projects son sub-repos Gitea (`apps/*`
|
|
||||||
gitignored): el aislamiento natural (opción (a)) y el gotcha de `git init` antes de limpiar un
|
|
||||||
worktree con una app nueva dentro.
|
|
||||||
- `.claude/rules/reports.md` + `.claude/rules/dod_quality.md` — qué entrega cada secundario:
|
|
||||||
report con evidencia ejecutable + gaps.
|
|
||||||
- `.claude/rules/delegation.md` + `.claude/rules/registry_calls.md` — los secundarios siguen
|
|
||||||
registry-first y delegan a `fn-constructor` igual que tú.
|
|
||||||
- Memorias: `lanzar-agentes-skip-permissions`, `multi-agent-git-race-same-repo`,
|
|
||||||
`claude-session-pid-mapping`, `prefiere-kitty-terminal`.
|
|
||||||
@@ -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,6 +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. |
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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,28 +1,11 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(CGO_ENABLED=1 go test *)",
|
|
||||||
"Bash(sqlite3 *)",
|
|
||||||
"Read(//home/enmanuel/.claude/**)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"enabledMcpjsonServers": [
|
|
||||||
"registry",
|
|
||||||
"jupyter"
|
|
||||||
],
|
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"PreToolUse": [
|
"PreToolUse": [
|
||||||
{
|
{
|
||||||
"matcher": "Bash",
|
"matcher": "Bash",
|
||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_mcp.sh" },
|
||||||
"type": "command",
|
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_fn_match.sh" }
|
||||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_mcp.sh"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_fn_match.sh"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -30,33 +13,21 @@
|
|||||||
{
|
{
|
||||||
"matcher": "Bash|Edit|Write|MultiEdit|mcp__registry__.*",
|
"matcher": "Bash|Edit|Write|MultiEdit|mcp__registry__.*",
|
||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_call_monitor.sh" }
|
||||||
"type": "command",
|
|
||||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_call_monitor.sh"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"matcher": "Edit|Write|MultiEdit|mcp__registry__fn_create_function",
|
"matcher": "Edit|Write|MultiEdit|mcp__registry__fn_create_function",
|
||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capability_tag_gate.sh" }
|
||||||
"type": "command",
|
|
||||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capability_tag_gate.sh"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"UserPromptSubmit": [
|
"UserPromptSubmit": [
|
||||||
{
|
{
|
||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capabilities_inject.sh" },
|
||||||
"type": "command",
|
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_first_reminder.sh" }
|
||||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capabilities_inject.sh"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_first_reminder.sh"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+3
-10
@@ -46,13 +46,6 @@ projects/*/
|
|||||||
vaults/*/
|
vaults/*/
|
||||||
!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.
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"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,24 @@
|
|||||||
|
# Build output
|
||||||
|
dag_engine
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# Frontend build
|
||||||
|
frontend/dist/
|
||||||
|
frontend/node_modules/
|
||||||
|
|
||||||
|
# Go
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Runtime DB (datos vivos, no se versiona)
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.bak*
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
# 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/` | DAGs activos servidos por `dag_engine.service` (systemd user unit). |
|
||||||
|
|
||||||
|
Por defecto el systemd unit apunta a `apps/dag_engine/dags/`. Para usar otro dir, edita `~/.config/systemd/user/dag_engine.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
ExecStart=/home/lucas/fn_registry/apps/dag_engine/dag_engine server \
|
||||||
|
--port 4200 \
|
||||||
|
--dags-dir /home/lucas/fn_registry/apps/dag_engine/dags \
|
||||||
|
--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/<nombre>.yaml` (ver formato en seccion 3).
|
||||||
|
2. **Validar** sin ejecutar:
|
||||||
|
```bash
|
||||||
|
./apps/dag_engine/dag_engine validate apps/dag_engine/dags/<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/<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:4200`.
|
||||||
|
|
||||||
|
### Disparo manual desde curl o frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:4200/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/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` | 4200 | Puerto HTTP. |
|
||||||
|
| `--dags-dir` | `apps/dag_engine/dags` (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/` | 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:4200/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:4200` 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/`, 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/*.yaml \
|
||||||
|
apps/dag_engine/dags/ 2>/dev/null || \
|
||||||
|
git checkout HEAD -- apps/dag_engine/dags/
|
||||||
|
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,144 @@
|
|||||||
|
---
|
||||||
|
name: dag_engine
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: 0.2.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: 4200
|
||||||
|
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/fn_backup.yaml
|
||||||
|
./dag-engine list apps/dag_engine/dags/
|
||||||
|
|
||||||
|
# Servidor web (production: gestionado por dag_engine.service systemd user unit)
|
||||||
|
./dag-engine server --port 4200 --dags-dir apps/dag_engine/dags/ --scheduler
|
||||||
|
# Browser: http://localhost:4200
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Schema YAML propio (ver `README.md` seccion 3 + ejemplos en `dags/`). Steps tipo `function:` invocan `fn run <id>` y propagan `function_id` a `dag_step_results` para el bucle reactivo. Puerto default 4200.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- v0.2.0 (2026-06-02) — minor: limpieza de la herencia `dagu` (renombrado `DAGU_ENV`→`FN_DAG_ENV`, directorio `dags_migrated/`→`dags/`, eliminado DAGs legacy/ejemplo), historial de ejecuciones reseteado, frontend reescrito con el estilo fn (tema indigo + radius md + `FnProvider` con `@mantine/notifications`, fix de la API `Collapse in`→`expanded` de Mantine 9.2.1), daemon systemd-user sirviendo React + API en el puerto 4200, y reduccion del binario de ~72MB a ~10MB separando los drivers pesados (duckdb/clickhouse/postgres/matrix/keyring) del paquete `functions/infra` a subpaquetes propios. `go.mod` replace ahora relativo (`../..`).
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
return Config{
|
||||||
|
Port: 8090,
|
||||||
|
DagsDir: "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,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 FN_DAG_ENV temp file for inter-step communication.
|
||||||
|
dagEnvFile, err := os.CreateTemp("", "fn_dag_env_*")
|
||||||
|
if err != nil {
|
||||||
|
e.failRun(runID, err)
|
||||||
|
return runID, err
|
||||||
|
}
|
||||||
|
dagEnvPath := dagEnvFile.Name()
|
||||||
|
dagEnvFile.Close()
|
||||||
|
defer os.Remove(dagEnvPath)
|
||||||
|
|
||||||
|
// 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, dagEnvPath, 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, dagEnvPath, stepOutputs)
|
||||||
|
} else {
|
||||||
|
e.runHandlers(ctx, runID, dag, dag.HandlerOn.Success, dagEnvPath, stepOutputs)
|
||||||
|
}
|
||||||
|
e.runHandlers(ctx, runID, dag, dag.HandlerOn.Exit, dagEnvPath, 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, dagEnvPath 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, dagEnvPath, 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 FN_DAG_ENV for inter-step env propagation.
|
||||||
|
readDagEnv(dagEnvPath, 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, dagEnvPath string, outputs map[string]string) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
for _, step := range handlers {
|
||||||
|
e.executeStep(ctx, runID, dag, step, dagEnvPath, 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, dagEnvPath 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 FN_DAG_ENV path.
|
||||||
|
env = append(env, "FN_DAG_ENV="+dagEnvPath)
|
||||||
|
|
||||||
|
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 readDagEnv(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,29 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"@mantine/notifications": "^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,45 @@
|
|||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import { AppShell, Container, Title, Group, Text, ThemeIcon, Badge } 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: 56 }} padding="lg">
|
||||||
|
<AppShell.Header>
|
||||||
|
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<ThemeIcon variant="light" color="indigo" size={34} radius="md">
|
||||||
|
<IconTopologyRing size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Group gap={6} align="center">
|
||||||
|
<Title order={4} fw={600}>
|
||||||
|
dag_engine
|
||||||
|
</Title>
|
||||||
|
<Badge variant="light" color="gray" size="xs" radius="sm">
|
||||||
|
v0.2.0
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed" lh={1}>
|
||||||
|
fn_registry workflow scheduler
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</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,10 @@
|
|||||||
|
import { Badge } from "@mantine/core";
|
||||||
|
import { statusColor } from "../theme";
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: string }) {
|
||||||
|
return (
|
||||||
|
<Badge color={statusColor[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-indigo-4)" />,
|
||||||
|
skipped: <IconCircleMinus size={16} color="var(--mantine-color-gray-6)" />,
|
||||||
|
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 expanded={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,12 @@
|
|||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { FnProvider } from "./FnProvider";
|
||||||
|
import { App } from "./App";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<FnProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</FnProvider>
|
||||||
|
);
|
||||||
@@ -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:4200",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
module dag-engine
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
fn-registry v0.0.0-00010101000000-000000000000
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.44
|
||||||
|
nhooyr.io/websocket v1.8.17
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
|
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/aymerick/douceur v0.2.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.7.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/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
|
github.com/google/flatbuffers v25.1.24+incompatible // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/css v1.0.1 // 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/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
|
github.com/paulmach/orb v0.12.0 // indirect
|
||||||
|
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.25 // indirect
|
||||||
|
github.com/rs/zerolog v1.35.1 // indirect
|
||||||
|
github.com/segmentio/asm v1.2.1 // indirect
|
||||||
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||||
|
github.com/tidwall/gjson v1.19.0 // indirect
|
||||||
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
|
github.com/tidwall/sjson v1.2.5 // indirect
|
||||||
|
github.com/yuin/goldmark v1.8.2 // indirect
|
||||||
|
github.com/zalando/go-keyring v0.2.8 // indirect
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
|
go.mau.fi/util v0.9.9 // 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.51.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
|
||||||
|
golang.org/x/mod v0.36.0 // indirect
|
||||||
|
golang.org/x/net v0.54.0 // indirect
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
|
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect
|
||||||
|
golang.org/x/text v0.37.0 // indirect
|
||||||
|
golang.org/x/tools v0.45.0 // indirect
|
||||||
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
maunium.net/go/mautrix v0.28.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace fn-registry => ../..
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
|
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/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
|
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/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
|
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/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||||
|
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||||
|
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/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
|
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/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
|
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/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
|
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-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
|
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/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
|
||||||
|
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||||
|
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/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||||
|
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||||
|
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/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
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/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
|
||||||
|
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
||||||
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||||
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
|
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/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||||
|
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||||
|
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.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE=
|
||||||
|
go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY=
|
||||||
|
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.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||||
|
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||||
|
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
|
||||||
|
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
|
||||||
|
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.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
|
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.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||||
|
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||||
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE=
|
||||||
|
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE=
|
||||||
|
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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
|
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.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
|
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=
|
||||||
|
maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ=
|
||||||
|
maunium.net/go/mautrix v0.28.0/go.mod h1:/a9A7LGaqb9B3nho4tLd28n0EPcCdwpm2dxkxkLLgh0=
|
||||||
|
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: 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,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
|
|
||||||
@@ -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
|
|
||||||
@@ -32,7 +32,7 @@ discover_git_repos() {
|
|||||||
-not -path "*/node_modules/*" \
|
-not -path "*/node_modules/*" \
|
||||||
-not -path "*/.venv/*" \
|
-not -path "*/.venv/*" \
|
||||||
-not -path "*/cpp/vendor/*" \
|
-not -path "*/cpp/vendor/*" \
|
||||||
-not -path "*/cpp/build*/*" \
|
-not -path "*/cpp/build/*" \
|
||||||
-not -path "*/sources/*" \
|
-not -path "*/sources/*" \
|
||||||
-not -path "*/temp/*" \
|
-not -path "*/temp/*" \
|
||||||
-not -path "*/subrepos/*" \
|
-not -path "*/subrepos/*" \
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
---
|
|
||||||
name: ensure_project_gitignore
|
|
||||||
kind: function
|
|
||||||
lang: bash
|
|
||||||
domain: infra
|
|
||||||
version: "1.0.0"
|
|
||||||
purity: impure
|
|
||||||
signature: "ensure_project_gitignore(project_dir: string) -> void"
|
|
||||||
description: "Garantiza de forma idempotente que el .gitignore de un directorio de project contiene las lineas canonicas que excluyen del repo del project el contenido de sus sub-repos hijos (apps y analyses son repos Gitea independientes) y sus vaults (datos fuera de git). Evita el doble-tracking al hacer push del project."
|
|
||||||
tags: [git, gitignore, projects, infra]
|
|
||||||
params:
|
|
||||||
- name: project_dir
|
|
||||||
desc: "Ruta al directorio del project (p. ej. projects/aurgi). Debe existir; si no, error a stderr y return 1. El .gitignore se escribe/actualiza en <project_dir>/.gitignore."
|
|
||||||
output: "Sin salida en stdout. A stderr informa de la accion realizada: 'created' si creo el .gitignore, 'updated: anadidas N lineas' si anadio lineas faltantes, u 'ok: ya completo' si nada cambiaba. Codigo de salida 0 en exito, 1 si project_dir falta o no existe."
|
|
||||||
uses_functions: []
|
|
||||||
uses_types: []
|
|
||||||
returns: []
|
|
||||||
returns_optional: false
|
|
||||||
error_type: "error_go_core"
|
|
||||||
imports: []
|
|
||||||
tested: false
|
|
||||||
tests: []
|
|
||||||
test_file_path: ""
|
|
||||||
file_path: "bash/functions/infra/ensure_project_gitignore.sh"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ejemplo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
source bash/functions/infra/ensure_project_gitignore.sh
|
|
||||||
|
|
||||||
# Asegura que projects/aurgi/.gitignore excluye el contenido de sus hijos.
|
|
||||||
ensure_project_gitignore projects/aurgi
|
|
||||||
# stderr: ensure_project_gitignore: created projects/aurgi/.gitignore
|
|
||||||
# (o: updated: anadidas 2 lineas / ok: ya completo)
|
|
||||||
```
|
|
||||||
|
|
||||||
Las lineas canonicas que la funcion garantiza son:
|
|
||||||
|
|
||||||
```
|
|
||||||
apps/*/
|
|
||||||
analysis/*/
|
|
||||||
vaults/*
|
|
||||||
!vaults/.gitkeep
|
|
||||||
!vaults/vault.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cuando usarla
|
|
||||||
|
|
||||||
Llamala justo despues de crear un project nuevo (`mkdir -p projects/<nombre>/{apps,analysis,vaults}`) y antes de inicializar su repo Gitea con `ensure_repo_synced`, para que el repo del project nunca trackee el contenido de sus sub-repos hijos. Tambien al adoptar un project existente que aun no tiene estas exclusiones, o como paso de saneamiento cuando `git status` del project muestra contenido de `apps/`/`analysis/` que deberia estar ignorado.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- La funcion modifica el filesystem (escribe en `<project_dir>/.gitignore`): es impura. No commitea ni hace push — solo deja el `.gitignore` correcto.
|
|
||||||
- La comparacion para no duplicar es linea-exacta (`grep -Fxq`). Una linea equivalente pero con espacios extra, comentario adjunto o glob distinto (p. ej. `apps/*` sin la barra final) NO se considera presente y la canonica se anade igualmente; podrian quedar ambas formas. Mantener el `.gitignore` con las lineas canonicas tal cual evita ruido.
|
|
||||||
- Si el `.gitignore` existente no termina en salto de linea, la funcion anade uno antes de apendar para no pegar la primera linea nueva al final de la ultima existente.
|
|
||||||
- Solo gestiona las exclusiones de sub-repos hijos y vaults del nivel-project; no toca otras reglas que el `.gitignore` ya contenga ni las reordena.
|
|
||||||
- Si una linea canonica ya existia con su forma exacta, no se vuelve a anadir (idempotente): re-ejecutar es seguro.
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ensure_project_gitignore — Garantiza de forma idempotente que el .gitignore de
|
|
||||||
# un directorio de project (projects/<nombre>/) contiene las lineas canonicas que
|
|
||||||
# excluyen del repo del project el contenido de sus sub-repos hijos (apps y
|
|
||||||
# analyses son repos Gitea independientes) y sus vaults (datos fuera de git).
|
|
||||||
#
|
|
||||||
# Esto evita que al hacer push del project se trackee por error el contenido de
|
|
||||||
# los hijos (doble-tracking). Ver .claude/rules/apps_subrepo.md y
|
|
||||||
# .claude/rules/projects.md.
|
|
||||||
#
|
|
||||||
# Uso:
|
|
||||||
# ensure_project_gitignore <project_dir>
|
|
||||||
#
|
|
||||||
# Salida:
|
|
||||||
# stdout vacio. A stderr informa de la accion realizada (created / updated / ok).
|
|
||||||
|
|
||||||
ensure_project_gitignore() {
|
|
||||||
local project_dir="$1"
|
|
||||||
|
|
||||||
if [[ -z "$project_dir" ]]; then
|
|
||||||
echo "ensure_project_gitignore: se requiere project_dir" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ ! -d "$project_dir" ]]; then
|
|
||||||
echo "ensure_project_gitignore: directorio '$project_dir' no existe" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
local gitignore="$project_dir/.gitignore"
|
|
||||||
|
|
||||||
# Lineas canonicas que deben estar presentes (orden de referencia).
|
|
||||||
local -a canonical=(
|
|
||||||
"apps/*/"
|
|
||||||
"analysis/*/"
|
|
||||||
"vaults/*"
|
|
||||||
"!vaults/.gitkeep"
|
|
||||||
"!vaults/vault.yaml"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Caso 1: el .gitignore no existe — crearlo con el contenido canonico.
|
|
||||||
if [[ ! -f "$gitignore" ]]; then
|
|
||||||
printf '%s\n' "${canonical[@]}" > "$gitignore"
|
|
||||||
echo "ensure_project_gitignore: created $gitignore" >&2
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Caso 2: existe — anadir solo las lineas que falten (comparacion linea-exacta),
|
|
||||||
# preservando el contenido y el orden existentes.
|
|
||||||
# Si el archivo no termina en newline, anadir uno antes de apendar para no
|
|
||||||
# pegar la primera linea nueva al final de la ultima existente.
|
|
||||||
if [[ -s "$gitignore" && -n "$(tail -c 1 "$gitignore")" ]]; then
|
|
||||||
printf '\n' >> "$gitignore"
|
|
||||||
fi
|
|
||||||
|
|
||||||
local line added=0
|
|
||||||
for line in "${canonical[@]}"; do
|
|
||||||
# grep -F -x: match literal de linea completa, sin interpretar metacaracteres.
|
|
||||||
if ! grep -Fxq -- "$line" "$gitignore"; then
|
|
||||||
printf '%s\n' "$line" >> "$gitignore"
|
|
||||||
added=$((added + 1))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ $added -gt 0 ]]; then
|
|
||||||
echo "ensure_project_gitignore: updated: anadidas $added lineas a $gitignore" >&2
|
|
||||||
else
|
|
||||||
echo "ensure_project_gitignore: ok: ya completo $gitignore" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Si se invoca como script (no source), ejecutar la funcion.
|
|
||||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
||||||
ensure_project_gitignore "$@"
|
|
||||||
fi
|
|
||||||
@@ -3,7 +3,7 @@ name: install_android_sdk
|
|||||||
kind: function
|
kind: function
|
||||||
lang: bash
|
lang: bash
|
||||||
domain: infra
|
domain: infra
|
||||||
version: "1.0.1"
|
version: "1.0.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "install_android_sdk() -> void"
|
signature: "install_android_sdk() -> void"
|
||||||
description: "Descarga e instala Android SDK command-line tools y JDK 17 localmente (sin root/sudo) en $ANDROID_SDK_DIR (default: $HOME/android-sdk). Idempotente: detecta instalacion existente y sale sin hacer nada. Genera env.sh con JAVA_HOME, ANDROID_HOME y PATH listos para hacer source."
|
description: "Descarga e instala Android SDK command-line tools y JDK 17 localmente (sin root/sudo) en $ANDROID_SDK_DIR (default: $HOME/android-sdk). Idempotente: detecta instalacion existente y sale sin hacer nada. Genera env.sh con JAVA_HOME, ANDROID_HOME y PATH listos para hacer source."
|
||||||
@@ -50,17 +50,6 @@ ANDROID_SDK_DIR=/opt/android source install_android_sdk.sh
|
|||||||
source ~/android-sdk/env.sh
|
source ~/android-sdk/env.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cuando usarla
|
|
||||||
|
|
||||||
Cuando necesites un Android SDK funcional en una maquina Linux sin permisos de root: CI, contenedores, o un PC de desarrollo donde quieras un SDK aislado en `$HOME`. Instala la base minima para compilar (cmdline-tools + JDK 17 + platform-tools + API 34 + build-tools). Hazle `source` para tener `sdkmanager`/`avdmanager`/`adb` en el PATH antes de invocar `gradle_run`, `gradle_assemble_debug` o `capacitor_build_apk`.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **No instala `emulator` ni system images.** Solo la base de compilacion. Para correr un AVD: tras hacer `source env.sh`, instala `emulator` y una imagen (`sdkmanager "emulator" "system-images;android-34;google_apis;x86_64"`) y crea el AVD con `avdmanager create avd`.
|
|
||||||
- **Aceleracion KVM:** el emulador necesita acceso a `/dev/kvm`. Verifica con `[ -w /dev/kvm ]`; si no, anade tu usuario al grupo `kvm` (`sudo usermod -aG kvm $USER` + re-login) o concede ACL.
|
|
||||||
- **URL de cmdline-tools clavada** a la build 11076708 (2024). Si Google la retira, actualizar `tools_url` en el `.sh`.
|
|
||||||
- **Idempotente:** re-ejecutar no reinstala; detecta `sdkmanager` existente y sale en 0.
|
|
||||||
|
|
||||||
## Notas
|
## Notas
|
||||||
|
|
||||||
Requiere `curl` y `unzip` (disponibles en la mayoria de distros Linux). No requiere root ni sudo.
|
Requiere `curl` y `unzip` (disponibles en la mayoria de distros Linux). No requiere root ni sudo.
|
||||||
@@ -72,7 +61,3 @@ La reorganizacion del zip es necesaria porque Google distribuye cmdline-tools co
|
|||||||
El archivo `env.sh` generado en `$ANDROID_SDK_DIR/env.sh` contiene las variables de entorno necesarias (`JAVA_HOME`, `ANDROID_HOME`, `ANDROID_SDK_ROOT`, `PATH`) y puede hacerse source desde `.bashrc`, `.zshrc` o desde scripts de CI.
|
El archivo `env.sh` generado en `$ANDROID_SDK_DIR/env.sh` contiene las variables de entorno necesarias (`JAVA_HOME`, `ANDROID_HOME`, `ANDROID_SDK_ROOT`, `PATH`) y puede hacerse source desde `.bashrc`, `.zshrc` o desde scripts de CI.
|
||||||
|
|
||||||
Paquetes instalados: `platform-tools` (adb, fastboot), `platforms;android-34` (API 34), `build-tools;34.0.0`.
|
Paquetes instalados: `platform-tools` (adb, fastboot), `platforms;android-34` (API 34), `build-tools;34.0.0`.
|
||||||
|
|
||||||
## Capability growth log
|
|
||||||
|
|
||||||
- v1.0.1 (2026-06-03) — fix: `yes | sdkmanager --licenses` daba falso negativo bajo `pipefail` (SIGPIPE de `yes`, exit 141) abortando una instalacion exitosa; ahora se desactiva `pipefail` solo en ese pipe. fix: el trap `EXIT` referenciaba `$tmp_dir` (variable `local`) fuera del scope de la funcion → "unbound variable" con `set -u`; ahora es global con expansion defensiva.
|
|
||||||
|
|||||||
@@ -5,14 +5,11 @@ set -euo pipefail
|
|||||||
|
|
||||||
install_android_sdk() {
|
install_android_sdk() {
|
||||||
local sdk_dir="${ANDROID_SDK_DIR:-$HOME/android-sdk}"
|
local sdk_dir="${ANDROID_SDK_DIR:-$HOME/android-sdk}"
|
||||||
# tmp_dir es global a proposito: el trap EXIT se dispara al terminar el
|
local tmp_dir
|
||||||
# script (fuera del scope de la funcion), donde una variable `local` ya no
|
|
||||||
# existiria y `set -u` la marcaria como unbound. La expansion defensiva
|
|
||||||
# ${tmp_dir:-} evita el fallo aunque el trap corra antes de la asignacion.
|
|
||||||
tmp_dir="$(mktemp -d)"
|
tmp_dir="$(mktemp -d)"
|
||||||
|
|
||||||
# Limpia temporales al salir
|
# Limpia temporales al salir
|
||||||
trap 'rm -rf "${tmp_dir:-}"' EXIT
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
# 1. Verifica si ya está instalado
|
# 1. Verifica si ya está instalado
|
||||||
if [[ -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
|
if [[ -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
|
||||||
@@ -106,18 +103,11 @@ install_android_sdk() {
|
|||||||
export PATH="$JAVA_HOME/bin:$sdk_dir/cmdline-tools/latest/bin:$sdk_dir/platform-tools:$PATH"
|
export PATH="$JAVA_HOME/bin:$sdk_dir/cmdline-tools/latest/bin:$sdk_dir/platform-tools:$PATH"
|
||||||
|
|
||||||
# 4. Acepta licencias e instala paquetes necesarios
|
# 4. Acepta licencias e instala paquetes necesarios
|
||||||
# `yes` recibe SIGPIPE (exit 141) cuando sdkmanager termina de leer y cierra
|
|
||||||
# el pipe; bajo `set -o pipefail` eso convierte un exito real en falso
|
|
||||||
# negativo. Desactivamos pipefail solo aqui para que el exit del pipeline
|
|
||||||
# refleje el de sdkmanager (ultimo comando), no el SIGPIPE de `yes`.
|
|
||||||
echo "Aceptando licencias de Android SDK..."
|
echo "Aceptando licencias de Android SDK..."
|
||||||
set +o pipefail
|
if ! yes | "$sdkmanager" --licenses; then
|
||||||
if ! yes | "$sdkmanager" --licenses >/dev/null 2>&1; then
|
|
||||||
set -o pipefail
|
|
||||||
echo "ERROR: fallo al aceptar licencias de Android SDK" >&2
|
echo "ERROR: fallo al aceptar licencias de Android SDK" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
set -o pipefail
|
|
||||||
|
|
||||||
echo "Instalando platform-tools, platforms;android-34, build-tools;34.0.0..."
|
echo "Instalando platform-tools, platforms;android-34, build-tools;34.0.0..."
|
||||||
if ! "$sdkmanager" "platform-tools" "platforms;android-34" "build-tools;34.0.0"; then
|
if ! "$sdkmanager" "platform-tools" "platforms;android-34" "build-tools;34.0.0"; then
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
---
|
|
||||||
name: launch_claude_agent_kitty
|
|
||||||
kind: function
|
|
||||||
lang: bash
|
|
||||||
domain: infra
|
|
||||||
version: "1.0.0"
|
|
||||||
purity: impure
|
|
||||||
signature: "launch_claude_agent_kitty(title: string, directory: string, prompt_file: string) -> string"
|
|
||||||
description: "Lanza un Claude Code secundario interactivo y persistente en su propia terminal kitty, con un prompt autonomo inyectado desde un archivo y --dangerously-skip-permissions. Mecanica del modo orquestador: un Claude principal descompone una tarea y lanza N secundarios, cada uno en su kitty, que el humano ve y puede retomar. La ventana sobrevive al cierre de la terminal padre (setsid nohup ... disown) y deja una shell interactiva viva cuando el claude termina (exec zsh)."
|
|
||||||
tags: [orchestration, agents, claude, kitty, agent, terminal, infra]
|
|
||||||
params:
|
|
||||||
- name: title
|
|
||||||
desc: "Titulo de la ventana kitty. Ej: 'fn_registry · subtarea X'. Tambien se sanitiza (minusculas, no-alfanumerico -> '_') para derivar el slug del archivo de log."
|
|
||||||
- name: directory
|
|
||||||
desc: "Directorio de trabajo AISLADO donde arranca el claude secundario (worktree git, sub-repo, o dir cualquiera). Debe existir; si no -> error exit 2. Usar un dir aislado: dos claudes en el mismo working tree comparten HEAD y dispersan commits."
|
|
||||||
- name: prompt_file
|
|
||||||
desc: "Ruta a un archivo .md con el prompt autonomo a inyectar (ej. /tmp/orq_<slug>.md). Debe existir y ser legible; si no -> error exit 2."
|
|
||||||
output: "Imprime en stdout el title, directory, prompt_file y la ruta del log (/tmp/orq_<slug>_kitty.log) donde se ve el arranque. Exit 0 = lanzamiento disparado; exit 2 = argumentos invalidos; exit 1 = kitty no instalado."
|
|
||||||
uses_functions: []
|
|
||||||
uses_types: []
|
|
||||||
returns: []
|
|
||||||
returns_optional: false
|
|
||||||
error_type: "error_go_core"
|
|
||||||
imports: []
|
|
||||||
tested: false
|
|
||||||
tests: []
|
|
||||||
test_file_path: ""
|
|
||||||
file_path: "bash/functions/infra/launch_claude_agent_kitty.sh"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ejemplo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
source bash/functions/infra/launch_claude_agent_kitty.sh
|
|
||||||
|
|
||||||
# El orquestador prepara un worktree aislado y un archivo de prompt...
|
|
||||||
git worktree add /tmp/orq_docs_wt -b orq/docs
|
|
||||||
cat > /tmp/orq_docs.md <<'PROMPT'
|
|
||||||
Eres un agente secundario. Tu tarea: revisar y mejorar la documentacion del
|
|
||||||
dominio infra del registry. Trabaja SOLO en este worktree. Reporta al terminar.
|
|
||||||
PROMPT
|
|
||||||
|
|
||||||
# ...y lanza un claude secundario en su propia kitty:
|
|
||||||
launch_claude_agent_kitty "fn_registry · docs" /tmp/orq_docs_wt /tmp/orq_docs.md
|
|
||||||
# -> abre una ventana kitty titulada "fn_registry · docs", arranca claude con
|
|
||||||
# el prompt inyectado, y deja /tmp/orq_fn_registry_docs_kitty.log con el arranque.
|
|
||||||
```
|
|
||||||
|
|
||||||
O directo via `fn run`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./fn run launch_claude_agent_kitty "fn_registry · docs" /tmp/orq_docs_wt /tmp/orq_docs.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cuando usarla
|
|
||||||
|
|
||||||
Cuando el orquestador quiere lanzar un Claude secundario **interactivo** en su propia terminal kitty para una sub-tarea que el humano quiere **ver y poder retomar**. A diferencia del `Agent` tool (sub-agente no interactivo, headless, cuyo output vuelve al padre y no deja terminal abierta), aqui cada secundario corre en una ventana visible que persiste: el humano observa el progreso en vivo y, cuando el claude termina, la shell sigue ahi para continuar manualmente o relanzar.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **kitty debe estar instalado.** Si `command -v kitty` falla -> exit 1 con mensaje claro. No hay fallback a otra terminal.
|
|
||||||
- **El `directory` debe ser AISLADO** (worktree git o sub-repo propio). Dos claudes apuntando al mismo working tree **comparten HEAD** y dispersan/cruzan los commits (memoria `multi-agent-git-race-same-repo`). El orquestador debe crear un worktree/clon por agente antes de llamar.
|
|
||||||
- **`--dangerously-skip-permissions` corre sin pedir confirmacion** a ninguna accion (memoria `lanzar-agentes-skip-permissions`). Es a proposito para agentes autonomos desatendidos, pero es un riesgo asumido: el secundario puede tocar el sistema sin gates. No lanzar sobre directorios sensibles.
|
|
||||||
- **El log de `/tmp/orq_<slug>_kitty.log` es donde se ve el arranque** (errores de kitty/claude al iniciar). El `<slug>` deriva del `title` sanitizado; titulos distintos que colapsen al mismo slug sobrescriben el mismo log.
|
|
||||||
- **El PID reportado no es el de kitty.** Con `setsid` el `$!` es el del proceso setsid, no el de la ventana; por eso la funcion reporta el log en vez de un PID. Para encontrar la ventana despues: `pgrep -af kitty | grep <title>`.
|
|
||||||
- **El prompt se inyecta con `"$(cat <prompt_file>)"` evaluado DENTRO de la kitty.** Si editas el `prompt_file` despues de lanzar pero antes de que la kitty arranque, el claude vera la version editada (se lee en el momento del arranque, no del lanzamiento).
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# launch_claude_agent_kitty — Lanza un Claude Code secundario interactivo y
|
|
||||||
# persistente en su propia terminal kitty, con un prompt autonomo inyectado
|
|
||||||
# desde un archivo. Es la mecanica de lanzamiento del "modo orquestador": un
|
|
||||||
# Claude principal descompone una tarea y lanza N secundarios, cada uno en su
|
|
||||||
# kitty, que el humano ve y puede retomar.
|
|
||||||
#
|
|
||||||
# Mecanismo:
|
|
||||||
# - setsid nohup kitty ... & disown -> la ventana sobrevive al cierre de la
|
|
||||||
# terminal padre (igual que reboot_all_claudes con setsid).
|
|
||||||
# - zsh -ic 'claude ...; exec zsh' -> al terminar el claude queda una shell
|
|
||||||
# interactiva viva para que el humano siga en esa terminal.
|
|
||||||
# - --dangerously-skip-permissions -> agente autonomo desatendido (sin
|
|
||||||
# confirmaciones). Riesgo asumido a proposito.
|
|
||||||
# - El prompt se inyecta con "$(cat <prompt_file>)" para no expandir nada en
|
|
||||||
# el shell del orquestador.
|
|
||||||
# - Log de arranque en /tmp/orq_<slug>_kitty.log, donde <slug> deriva del
|
|
||||||
# title (minusculas, no-alfanumerico -> '_').
|
|
||||||
set -euo pipefail
|
|
||||||
IFS=$' \t\n'
|
|
||||||
|
|
||||||
launch_claude_agent_kitty() {
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Ayuda / sin argumentos.
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
if [[ $# -eq 0 || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
|
||||||
cat <<'USAGE'
|
|
||||||
Uso: launch_claude_agent_kitty <title> <directory> <prompt_file>
|
|
||||||
|
|
||||||
Lanza un Claude Code secundario interactivo y persistente en su propia
|
|
||||||
terminal kitty, con el prompt del archivo <prompt_file> inyectado y
|
|
||||||
--dangerously-skip-permissions (agente autonomo desatendido).
|
|
||||||
|
|
||||||
Argumentos (los 3 obligatorios):
|
|
||||||
title Titulo de la ventana kitty. Ej: "fn_registry · subtarea X".
|
|
||||||
directory Directorio de trabajo AISLADO donde arranca el claude
|
|
||||||
secundario (worktree git, sub-repo, o dir cualquiera). Debe
|
|
||||||
existir. Usa un dir aislado: dos claudes en el mismo working
|
|
||||||
tree comparten HEAD y dispersan commits.
|
|
||||||
prompt_file Ruta a un archivo .md con el prompt autonomo a inyectar.
|
|
||||||
Debe existir y ser legible.
|
|
||||||
|
|
||||||
Ejemplo:
|
|
||||||
launch_claude_agent_kitty "fn_registry · docs" /tmp/orq_docs_wt /tmp/orq_docs.md
|
|
||||||
|
|
||||||
El log de arranque va a /tmp/orq_<slug>_kitty.log (slug derivado del title).
|
|
||||||
USAGE
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Validacion de argumentos.
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
if [[ $# -ne 3 ]]; then
|
|
||||||
echo "launch_claude_agent_kitty: se requieren 3 argumentos <title> <directory> <prompt_file> (recibidos: $#). Usa -h." >&2
|
|
||||||
return 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
local title="$1"
|
|
||||||
local directory="$2"
|
|
||||||
local prompt_file="$3"
|
|
||||||
|
|
||||||
if [[ -z "$title" ]]; then
|
|
||||||
echo "launch_claude_agent_kitty: <title> no puede estar vacio." >&2
|
|
||||||
return 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -d "$directory" ]]; then
|
|
||||||
echo "launch_claude_agent_kitty: el directorio de trabajo no existe: '$directory'." >&2
|
|
||||||
return 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -f "$prompt_file" ]]; then
|
|
||||||
echo "launch_claude_agent_kitty: el prompt_file no existe: '$prompt_file'." >&2
|
|
||||||
return 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -r "$prompt_file" ]]; then
|
|
||||||
echo "launch_claude_agent_kitty: el prompt_file no es legible: '$prompt_file'." >&2
|
|
||||||
return 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Comprobar que kitty esta instalado.
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
if ! command -v kitty >/dev/null 2>&1; then
|
|
||||||
echo "launch_claude_agent_kitty: 'kitty' no esta instalado o no esta en el PATH." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Derivar el slug del title para el nombre del log.
|
|
||||||
# minusculas, todo no-alfanumerico -> '_', colapsar/recortar '_'.
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
local slug
|
|
||||||
slug="$(printf '%s' "$title" \
|
|
||||||
| tr '[:upper:]' '[:lower:]' \
|
|
||||||
| tr -c 'a-z0-9' '_' \
|
|
||||||
| sed -E 's/_+/_/g; s/^_//; s/_$//')"
|
|
||||||
[[ -z "$slug" ]] && slug="agent"
|
|
||||||
|
|
||||||
local log="/tmp/orq_${slug}_kitty.log"
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Lanzar la kitty detached. El prompt se inyecta con "$(cat <prompt_file>)"
|
|
||||||
# ya escapado para que se evalue DENTRO de la kitty, no aqui.
|
|
||||||
# exec zsh deja una shell viva cuando el claude termina.
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
local inner
|
|
||||||
inner="claude --dangerously-skip-permissions \"\$(cat $(printf '%q' "$prompt_file"))\"; exec zsh"
|
|
||||||
|
|
||||||
setsid nohup kitty \
|
|
||||||
--title "$title" \
|
|
||||||
--directory "$directory" \
|
|
||||||
zsh -ic "$inner" \
|
|
||||||
>"$log" 2>&1 &
|
|
||||||
disown 2>/dev/null || true
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Reportar. Con setsid el $! es el PID de setsid, no el de kitty; basta
|
|
||||||
# con confirmar el lanzamiento y apuntar al log donde se ve el arranque.
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
echo "launch_claude_agent_kitty: claude secundario lanzado."
|
|
||||||
echo " title: $title"
|
|
||||||
echo " directory: $directory"
|
|
||||||
echo " prompt_file: $prompt_file"
|
|
||||||
echo " log: $log"
|
|
||||||
echo " (sigue el arranque con: tail -f $(printf '%q' "$log"))"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
|
||||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
||||||
launch_claude_agent_kitty "$@"
|
|
||||||
fi
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
---
|
|
||||||
name: launch_fleetclaude
|
|
||||||
kind: function
|
|
||||||
lang: bash
|
|
||||||
domain: infra
|
|
||||||
version: "1.3.2"
|
|
||||||
purity: impure
|
|
||||||
signature: "launch_fleetclaude [--cwd <dir>] [--bin <path>] [--session <name>] [--cols <n>]"
|
|
||||||
description: "Entrypoint de FleetView: abre una ventana kitty con una sesion tmux (socket aislado -L fleet) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks."
|
|
||||||
tags: [claude-fleet, infra, kitty, tmux, claude, fleetview, launcher]
|
|
||||||
params:
|
|
||||||
- name: --cwd
|
|
||||||
desc: "Directorio de trabajo de ambos panes tmux. Opcional. Default: raiz del repo fn_registry, derivada dinamicamente via git rev-parse desde la ubicacion del script (sin hardcodear paths de usuario)."
|
|
||||||
- name: --bin
|
|
||||||
desc: "Ruta al binario de la TUI fleetview que corre en el pane izquierdo. Opcional. Default: <repo>/apps/fleetview/fleetview. Si no es ejecutable, el pane izquierdo muestra un mensaje de como compilarla y deja una shell viva."
|
|
||||||
- name: --session
|
|
||||||
desc: "Nombre de la sesion tmux a crear o reutilizar. Opcional. Default: fleet. La funcion es idempotente sobre este nombre."
|
|
||||||
- name: --cols
|
|
||||||
desc: "Ancho en columnas del pane izquierdo (la TUI). Opcional. Default: 40."
|
|
||||||
output: "Crea/reutiliza una sesion tmux detached con dos panes y lanza una ventana kitty 'FleetView' adjunta a ella, desacoplada del shell padre (setsid). Imprime el estado por stdout. Sin valor de retorno; exit 0 en exito."
|
|
||||||
uses_functions: []
|
|
||||||
uses_types: []
|
|
||||||
returns: []
|
|
||||||
returns_optional: false
|
|
||||||
error_type: "error_go_core"
|
|
||||||
imports: []
|
|
||||||
tested: false
|
|
||||||
tests: []
|
|
||||||
test_file_path: ""
|
|
||||||
file_path: "bash/functions/infra/launch_fleetclaude.sh"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ejemplo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Via fn run (resuelve por nombre o ID):
|
|
||||||
fn run launch_fleetclaude
|
|
||||||
|
|
||||||
# Directo, con cwd explicito:
|
|
||||||
launch_fleetclaude --cwd ~/fn_registry
|
|
||||||
|
|
||||||
# Sesion y ancho de pane personalizados:
|
|
||||||
launch_fleetclaude --session fleet --cols 50
|
|
||||||
```
|
|
||||||
|
|
||||||
Tras invocarlo aparece una ventana kitty titulada `FleetView` con dos panes
|
|
||||||
lado a lado: a la izquierda la TUI `fleetview`, a la derecha una sesion de
|
|
||||||
`claude --dangerously-skip-permissions`. Volver a invocarlo NO duplica la
|
|
||||||
sesion: reusa la existente y solo abre otra kitty adjunta.
|
|
||||||
|
|
||||||
## Cuando usarla
|
|
||||||
|
|
||||||
Usala cuando quieras un unico punto de entrada a la flota de Claudes en vez de
|
|
||||||
N ventanas kitty sueltas: lanzas `fleetclaude` y tienes la TUI de control y un
|
|
||||||
Claude listo para trabajar en la misma ventana. Tipico al empezar la jornada o
|
|
||||||
al retomar el trabajo en el repo `fn_registry`.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **Idempotencia tmux**: si la sesion `<session>` (default `fleet`) ya existe,
|
|
||||||
NO se recrea el layout; solo se abre una kitty nueva adjunta a la misma
|
|
||||||
sesion. Para empezar de cero: `tmux kill-session -t fleet` antes de invocar.
|
|
||||||
- **kitty detached (setsid)**: la ventana se lanza con `setsid ... &` para
|
|
||||||
sobrevivir al cierre de la terminal que la invoco. No bloquea al shell padre.
|
|
||||||
- **`exec` en los panes**: tanto la TUI como `claude` se lanzan con `exec`, asi
|
|
||||||
que al terminar el proceso el pane se cierra en vez de dejar una shell zombie
|
|
||||||
colgando. Excepcion: el fallback cuando `fleetview` no esta compilado deja una
|
|
||||||
shell interactiva a proposito (para que veas el mensaje y puedas compilar).
|
|
||||||
- **Requiere fleetview compilado**: el default `--bin` apunta a
|
|
||||||
`<repo>/apps/fleetview/fleetview`. Si ese binario no existe, el pane izquierdo
|
|
||||||
muestra `cd apps/fleetview && go build -o fleetview .` en lugar de fallar en
|
|
||||||
silencio. Compila la TUI antes para el flujo completo.
|
|
||||||
- **Socket tmux aislado (`-L fleet`)**: toda la sesion vive en un server tmux
|
|
||||||
propio, separado del tmux por defecto del usuario. Asi los atajos `bind -n`
|
|
||||||
NO afectan otras sesiones (ej. una sesion `mobile-1` del movil) y matar el
|
|
||||||
server fleet no toca nada mas: `tmux -L fleet kill-server`.
|
|
||||||
- **Atajos en el socket, NO en kitty.conf**: instala `bind -n` para
|
|
||||||
`alt+flechas` (mover el cursor de la TUI), `alt+enter` (conmutar al Claude
|
|
||||||
seleccionado) y `alt+n` (abrir Claude nuevo). Son bindings de tmux que
|
|
||||||
redirigen la tecla al pane de la TUI (`send-keys -t console.0`), asi funcionan
|
|
||||||
ESTES DONDE ESTES (incluido escribiendo en el pane de Claude). No modifican la
|
|
||||||
configuracion de kitty ni los atajos globales del escritorio.
|
|
||||||
- **Ancho del sidebar via hooks**: `client-resized` y `window-layout-changed`
|
|
||||||
re-fijan el pane 0 (TUI) a `--cols` columnas, porque el `attach` de kitty y el
|
|
||||||
conmutar de Claude redistribuyen el espacio.
|
|
||||||
- **tmux siempre, kitty solo sin TTY**: `tmux` es obligatorio (aborta != 0 si
|
|
||||||
falta). `kitty` solo se necesita en la ruta sin-TTY (atajo de escritorio, cron,
|
|
||||||
script), donde abre una ventana nueva. Invocado desde una terminal interactiva
|
|
||||||
(el caso normal del alias `fleetclaude`), reutiliza la terminal actual con
|
|
||||||
`exec tmux attach` y NO necesita kitty — util en WSL u hosts sin kitty.
|
|
||||||
|
|
||||||
## Capability growth log
|
|
||||||
|
|
||||||
- v1.3.2 (2026-06-17) — targeting de panes por **pane ID** (`%0`/`%1`) en vez de
|
|
||||||
por indice (`console.0`). Antes fallaba con `can't find pane: 0` en hosts cuyo
|
|
||||||
`~/.tmux.conf` define `base-index 1`/`pane-base-index 1` (el socket `-L fleet`
|
|
||||||
hereda esa config). Los pane ID son inmunes al base-index. Bug latente que el
|
|
||||||
fix de kitty (v1.3.1) destapo al dejar de abortar antes de montar la sesion.
|
|
||||||
- v1.3.1 (2026-06-17) — el guard de `kitty` se movio a la rama sin-TTY. La ruta
|
|
||||||
interactiva (`exec tmux attach`) ya no exige kitty, asi que `fleetclaude`
|
|
||||||
funciona en hosts sin kitty (p.ej. WSL) reutilizando la terminal actual.
|
|
||||||
- v1.3.0 (2026-06-17) — renombrada de `launch_kittyclaude` a `launch_fleetclaude`
|
|
||||||
(comando `fleetclaude`). Atajos: `alt+0` (= alt+n, abrir Claude nuevo), `alt+k`
|
|
||||||
(kill con confirmacion), `alt+r` (picker de reanudar sesiones cerradas) y
|
|
||||||
`alt+flecha-izquierda` (volver atras desde el picker). Cierra la window al salir
|
|
||||||
el Claude (`remain-on-exit off`).
|
|
||||||
- v1.2.0 (2026-06-16) — ancho del sidebar por defecto 47 columnas; `ctrl+0` como
|
|
||||||
atajo alterno para abrir Claude nuevo; `mouse on` (clic/rueda enrutados a la
|
|
||||||
TUI) y `extended-keys on` (para que `ctrl+0` llegue distinguible por el
|
|
||||||
protocolo de teclado de kitty).
|
|
||||||
- v1.1.0 (2026-06-16) — socket tmux aislado `-L fleet`; instala atajos
|
|
||||||
`alt+flechas` / `alt+enter` / `alt+n` que controlan la TUI desde cualquier
|
|
||||||
pane; hooks que mantienen fijo el ancho del sidebar tras attach/conmutar.
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# launch_fleetclaude — Entrypoint MVP de FleetView.
|
|
||||||
#
|
|
||||||
# Abre UNA ventana kitty corriendo una sesion tmux de dos panes:
|
|
||||||
# - pane izquierdo: la TUI 'fleetview' (la flota de Claudes centralizada).
|
|
||||||
# - pane derecho: 'claude --dangerously-skip-permissions'.
|
|
||||||
#
|
|
||||||
# Objetivo: dejar de tener N ventanas kitty dispersas y centralizar el control
|
|
||||||
# de los Claudes en una sola ventana.
|
|
||||||
#
|
|
||||||
# Funcion IMPURA: lanza procesos (tmux + kitty) con efectos secundarios.
|
|
||||||
# - Crea/reusa una sesion tmux detached llamada <session> (idempotente).
|
|
||||||
# - Lanza una ventana kitty desacoplada del shell padre (setsid) para que
|
|
||||||
# sobreviva al cierre de la terminal que la invoco.
|
|
||||||
# - No toca atajos de teclado ni kitty.conf.
|
|
||||||
set -euo pipefail
|
|
||||||
IFS=$' \t\n'
|
|
||||||
|
|
||||||
launch_fleetclaude() {
|
|
||||||
local cwd=""
|
|
||||||
local bin=""
|
|
||||||
local session="fleet"
|
|
||||||
local cols=52
|
|
||||||
local T="tmux -L fleet" # socket tmux aislado: no toca el tmux normal del usuario
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Parseo de argumentos
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--cwd)
|
|
||||||
shift
|
|
||||||
cwd="${1:-}"
|
|
||||||
;;
|
|
||||||
--bin)
|
|
||||||
shift
|
|
||||||
bin="${1:-}"
|
|
||||||
;;
|
|
||||||
--session)
|
|
||||||
shift
|
|
||||||
session="${1:-}"
|
|
||||||
;;
|
|
||||||
--cols)
|
|
||||||
shift
|
|
||||||
cols="${1:-40}"
|
|
||||||
;;
|
|
||||||
-h|--help)
|
|
||||||
cat <<'USAGE'
|
|
||||||
Uso: launch_fleetclaude [opciones]
|
|
||||||
|
|
||||||
Abre una ventana kitty con una sesion tmux de dos panes: la TUI fleetview a la
|
|
||||||
izquierda y 'claude --dangerously-skip-permissions' a la derecha.
|
|
||||||
|
|
||||||
Opciones:
|
|
||||||
--cwd <dir> Directorio de trabajo de los panes.
|
|
||||||
Default: raiz del repo fn_registry (derivada dinamicamente).
|
|
||||||
--bin <path> Ruta al binario de la TUI fleetview.
|
|
||||||
Default: <repo>/apps/fleetview/fleetview
|
|
||||||
--session <name> Nombre de la sesion tmux. Default: fleet.
|
|
||||||
--cols <n> Ancho (columnas) del pane izquierdo. Default: 40.
|
|
||||||
-h, --help Muestra esta ayuda.
|
|
||||||
|
|
||||||
Ejemplos:
|
|
||||||
launch_fleetclaude
|
|
||||||
launch_fleetclaude --cwd ~/fn_registry
|
|
||||||
launch_fleetclaude --session fleet --cols 50
|
|
||||||
USAGE
|
|
||||||
return 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "launch_fleetclaude: opcion desconocida: '$1' (usa -h)" >&2
|
|
||||||
return 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Derivar la raiz del repo fn_registry dinamicamente (NO hardcodear paths
|
|
||||||
# de usuario). Estrategia: subir desde la ubicacion del script con
|
|
||||||
# 'git rev-parse --show-toplevel'; fallbacks razonables si no aplica.
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
local script_dir repo_root=""
|
|
||||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
# El script vive en <repo>/bash/functions/infra/, asi que la raiz son 3
|
|
||||||
# niveles arriba; pero preferimos git para robustez.
|
|
||||||
repo_root="$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null || true)"
|
|
||||||
if [[ -z "$repo_root" ]]; then
|
|
||||||
# Fallback 1: navegacion relativa desde la ubicacion del script.
|
|
||||||
repo_root="$(cd "$script_dir/../../.." 2>/dev/null && pwd || true)"
|
|
||||||
fi
|
|
||||||
if [[ -z "$repo_root" ]]; then
|
|
||||||
# Fallback 2: variable de entorno del registry o el cwd actual.
|
|
||||||
repo_root="${FN_REGISTRY_ROOT:-$PWD}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Defaults derivados de la raiz del repo.
|
|
||||||
[[ -z "$cwd" ]] && cwd="$repo_root"
|
|
||||||
[[ -z "$bin" ]] && bin="$repo_root/apps/fleetview/fleetview"
|
|
||||||
|
|
||||||
# Validar cwd: si no existe, caer al repo_root.
|
|
||||||
if [[ ! -d "$cwd" ]]; then
|
|
||||||
echo "launch_fleetclaude: --cwd '$cwd' no existe; usando '$repo_root'." >&2
|
|
||||||
cwd="$repo_root"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Comprobar herramientas necesarias.
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
if ! command -v tmux >/dev/null 2>&1; then
|
|
||||||
echo "launch_fleetclaude: tmux no esta instalado." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
# Nota: kitty NO se exige aqui. La ruta interactiva (TTY) reutiliza la
|
|
||||||
# terminal actual con `exec tmux attach` y no necesita kitty. Solo la
|
|
||||||
# ruta sin-TTY (abrir ventana nueva con setsid kitty) lo requiere, y ahi
|
|
||||||
# se comprueba justo antes de usarlo.
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Comando para el pane izquierdo:
|
|
||||||
# - Si el binario fleetview existe -> ejecutarlo (exec, sin shell colgado).
|
|
||||||
# - Si NO existe -> mensaje claro + shell interactiva (no falla en silencio).
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
local left_cmd
|
|
||||||
if [[ -x "$bin" ]]; then
|
|
||||||
left_cmd="exec $(printf '%q' "$bin")"
|
|
||||||
else
|
|
||||||
# Fallback claro: instruye como compilar la TUI y deja una shell viva.
|
|
||||||
left_cmd="echo 'fleetview no compilado: cd apps/fleetview && go build -o fleetview .'; exec \"\$SHELL\""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Montar la sesion tmux SOLO si no existe (idempotencia). Socket aislado $T.
|
|
||||||
#
|
|
||||||
# Targeting por PANE ID (%0/%1), no por indice (console.0). El socket
|
|
||||||
# -L fleet sigue leyendo ~/.tmux.conf; si el usuario tiene
|
|
||||||
# `base-index 1` / `pane-base-index 1` (muy comun), el primer pane es el
|
|
||||||
# indice 1 y cualquier referencia a console.0 falla con
|
|
||||||
# "can't find pane: 0". Los pane ID son estables e inmunes al base-index.
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
local left_pane right_pane
|
|
||||||
if $T has-session -t "$session" 2>/dev/null; then
|
|
||||||
echo "launch_fleetclaude: la sesion tmux '$session' ya existe; reutilizandola."
|
|
||||||
else
|
|
||||||
echo "launch_fleetclaude: creando sesion tmux '$session' en '$cwd'."
|
|
||||||
|
|
||||||
# Sesion detached con ventana 'console'. Capturamos el pane ID del pane
|
|
||||||
# izquierdo (la TUI fleetview, o el fallback claro).
|
|
||||||
left_pane=$($T new-session -d -s "$session" -n console -c "$cwd" -P -F '#{pane_id}')
|
|
||||||
$T send-keys -t "$left_pane" "$left_cmd" C-m
|
|
||||||
|
|
||||||
# pane derecho = claude, dividiendo horizontalmente (split lado a lado).
|
|
||||||
right_pane=$($T split-window -h -t "$left_pane" -c "$cwd" -P -F '#{pane_id}')
|
|
||||||
$T send-keys -t "$right_pane" "exec claude --dangerously-skip-permissions" C-m
|
|
||||||
|
|
||||||
# Fijar el ancho del pane izquierdo en columnas.
|
|
||||||
$T resize-pane -t "$left_pane" -x "$cols"
|
|
||||||
|
|
||||||
# Foco inicial en el pane de claude (derecha).
|
|
||||||
$T select-pane -t "$right_pane"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Si reutilizamos sesion (o por seguridad), derivar el pane ID de la TUI:
|
|
||||||
# el primer pane de la ventana 'console' (orden por indice) es el izquierdo.
|
|
||||||
if [[ -z "$left_pane" ]]; then
|
|
||||||
left_pane=$($T list-panes -t "$session":console -F '#{pane_id}' 2>/dev/null | head -n1)
|
|
||||||
fi
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Atajos globales (alt+*) en el socket aislado: redirigen la tecla al pane
|
|
||||||
# de la TUI (console.0) ESTES DONDE ESTES, para controlar la flota sin salir
|
|
||||||
# del pane de Claude. La TUI (fleetview) es quien interpreta Up/Down/Enter/n.
|
|
||||||
# `bind -n` = tabla root (sin prefijo). Idempotente: re-set en cada lanzamiento.
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
$T bind -n M-Up send-keys -t "$left_pane" Up
|
|
||||||
$T bind -n M-Down send-keys -t "$left_pane" Down
|
|
||||||
$T bind -n M-Enter send-keys -t "$left_pane" Enter
|
|
||||||
$T bind -n M-n send-keys -t "$left_pane" n
|
|
||||||
$T bind -n M-0 send-keys -t "$left_pane" n
|
|
||||||
$T bind -n M-k send-keys -t "$left_pane" k
|
|
||||||
$T bind -n M-r send-keys -t "$left_pane" r
|
|
||||||
$T bind -n M-u send-keys -t "$left_pane" u
|
|
||||||
$T bind -n M-h send-keys -t "$left_pane" h
|
|
||||||
$T bind -n M-Left send-keys -t "$left_pane" Escape
|
|
||||||
$T bind -n M-q send-keys -t "$left_pane" Q
|
|
||||||
# Raton: enruta clicks/rueda al pane bajo el cursor; la TUI los interpreta.
|
|
||||||
$T set -g mouse on
|
|
||||||
# Al salir un Claude (exit / Ctrl-D / kill), cerrar su window en vez de
|
|
||||||
# dejarla muerta ("dead" pane) en la sesion.
|
|
||||||
$T set -g remain-on-exit off
|
|
||||||
|
|
||||||
# Estetica neutra: sin el verde fosforo por defecto de tmux. Status bar gris y
|
|
||||||
# bordes de pane gris tenue, iguales en activo e inactivo (separacion simple,
|
|
||||||
# sin resaltado de enfoque).
|
|
||||||
$T set -g status-style "bg=colour236,fg=colour250"
|
|
||||||
$T set -g pane-border-style "fg=colour238"
|
|
||||||
$T set -g pane-active-border-style "fg=colour240"
|
|
||||||
|
|
||||||
# Mantener el ancho del sidebar (pane 0) cuando kitty redimensiona la ventana
|
|
||||||
# tras el attach, o cuando se conmuta de Claude (window-linked / layout change).
|
|
||||||
$T set-hook -g client-resized "resize-pane -t $left_pane -x $cols"
|
|
||||||
$T set-hook -g window-layout-changed "resize-pane -t $left_pane -x $cols"
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Lanzar kitty adjuntando la sesion, DESACOPLADA del shell padre con
|
|
||||||
# setsid, para que no muera al cerrar la terminal invocadora.
|
|
||||||
# (Mismo patron que reboot_all_claudes para relanzar terminales.)
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Adjuntar la sesion:
|
|
||||||
# - Si se invoca desde una terminal interactiva, convertir ESA terminal en
|
|
||||||
# el panel FleetView (exec reemplaza el proceso; al hacer detach vuelve la
|
|
||||||
# shell). Asi `fleetclaude` no abre otra ventana: usa la actual.
|
|
||||||
# - Si NO hay TTY (atajo de escritorio, cron, script), abrir una ventana
|
|
||||||
# kitty nueva desacoplada (setsid) como antes.
|
|
||||||
if [ -t 0 ] && [ -t 1 ]; then
|
|
||||||
exec tmux -L fleet attach -t "$session"
|
|
||||||
fi
|
|
||||||
# Ruta sin-TTY: necesitamos kitty para abrir la ventana nueva.
|
|
||||||
if ! command -v kitty >/dev/null 2>&1; then
|
|
||||||
echo "launch_fleetclaude: kitty no esta instalado (necesario solo sin TTY)." >&2
|
|
||||||
echo "launch_fleetclaude: lanzalo desde una terminal interactiva, o instala kitty." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
setsid kitty --title "FleetView" -e tmux -L fleet attach -t "$session" </dev/null >/dev/null 2>&1 &
|
|
||||||
disown 2>/dev/null || true
|
|
||||||
|
|
||||||
echo "launch_fleetclaude: ventana kitty 'FleetView' adjunta a la sesion tmux '$session'."
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
|
||||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
||||||
launch_fleetclaude "$@"
|
|
||||||
fi
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
---
|
|
||||||
name: list_claude_agents
|
|
||||||
kind: function
|
|
||||||
lang: bash
|
|
||||||
domain: infra
|
|
||||||
version: "1.0.0"
|
|
||||||
purity: impure
|
|
||||||
signature: "list_claude_agents([--json] [--exclude-current] [-h|--help])"
|
|
||||||
description: "Lista todas las instancias de Claude Code VIVAS cruzando pgrep -x claude con los archivos de estado por proceso ~/.claude/sessions/<PID>.json. Para cada claude vivo y validado devuelve PID, status (idle/busy), etime (tiempo de vida), KITTY_PID de su ventana kitty, sessionId y cwd. Es la herramienta de seguimiento de la flota del modo orquestador: el Claude principal ve que agentes secundarios siguen vivos, en que directorio trabajan y su sessionId para retomarlos con claude --resume."
|
|
||||||
tags: [orchestration, claude, session, fleet, kitty, infra, terminal-capture]
|
|
||||||
uses_functions: []
|
|
||||||
uses_types: []
|
|
||||||
returns: []
|
|
||||||
returns_optional: false
|
|
||||||
error_type: "error_go_core"
|
|
||||||
imports: []
|
|
||||||
params:
|
|
||||||
- name: "--json"
|
|
||||||
desc: "Imprime un array JSON (un objeto por agente con pid, session_id, cwd, status, etime, kitty_pid, self) en vez de la tabla legible. Pensado para que el agente parsee y decida cual retomar/parar."
|
|
||||||
- name: "--exclude-current"
|
|
||||||
desc: "Omite la propia sesion del listado. Detecta el claude propio subiendo por la cadena de ancestros de $$ hasta hallar un proceso con comm=claude. Sin esta opcion, la sesion actual se marca (columna SELF en tabla / self=true en JSON)."
|
|
||||||
- name: "-h|--help"
|
|
||||||
desc: "Muestra el uso y termina con exit 0."
|
|
||||||
output: "En modo tabla: una fila por claude vivo y validado con columnas PID, STATUS, ETIME, KITTY, SELF, SESSION_ID, CWD. En modo --json: array JSON con pid, session_id, cwd, status, etime, kitty_pid (null si no corre en kitty) y self. Si no hay claudes vivos imprime aviso (tabla) o [] (json) y exit 0. Exit 0 normal; exit 2 si flag invalido."
|
|
||||||
tested: false
|
|
||||||
tests: []
|
|
||||||
test_file_path: ""
|
|
||||||
file_path: "bash/functions/infra/list_claude_agents.sh"
|
|
||||||
notes: "Mecanismo (Claude Code 2.1.x sobre Linux + kitty): pgrep -x claude -> PIDs vivos; ~/.claude/sessions/<PID>.json -> sessionId/cwd/status/procStart (parseado con python3); validacion en tres capas: kill -0 <PID> exito, el JSON existe, y anti-PID-reciclado comparando procStart del JSON con el campo 22 de /proc/<PID>/stat (si difieren el JSON es huerfano de un PID reusado y se omite). KITTY_PID se saca del environ del proceso (tr '\\0' '\\n' < /proc/<PID>/environ | sed -n 's/^KITTY_PID=//p'). etime via ps -o etime= -p <PID>. Reusa la misma logica de descubrimiento y validacion que reboot_all_claudes_bash_infra. El codigo JSON va en python3 -c con los datos por stdin TSV (no heredoc) para no colisionar el stdin del pipe."
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ejemplo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Tabla legible de la flota de Claudes vivos (PID, status, etime, kitty, sessionId, cwd).
|
|
||||||
./fn run list_claude_agents
|
|
||||||
|
|
||||||
# Array JSON para parsear (decidir cual retomar con claude --resume <session_id>).
|
|
||||||
./fn run list_claude_agents --json
|
|
||||||
|
|
||||||
# Omitir la propia sesion (ver solo los agentes secundarios).
|
|
||||||
./fn run list_claude_agents --exclude-current
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cuando usarla
|
|
||||||
|
|
||||||
Cuando el orquestador necesita ver la flota de Claudes secundarios vivos (PID, cwd, sessionId, status) para seguir su progreso o decidir cual retomar/parar. Lanzala al inicio de un ciclo de seguimiento para saber que agentes siguen activos y en que directorio trabaja cada uno; usa `--json` cuando vayas a programar la decision (filtrar por `status`, extraer `session_id` para un `claude --resume`).
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **Requiere Claude Code >= 2.1.x.** Depende de que cada sesion escriba `~/.claude/sessions/<PID>.json` con los campos `sessionId`, `cwd`, `status`, `procStart`. Si una version futura cambia el formato, la funcion deja de mapear PID -> sessionId y omitira las sesiones.
|
|
||||||
- **Un JSON puede ser huerfano por PID reciclado.** El sistema operativo reusa PIDs; un `<PID>.json` viejo puede apuntar a un proceso `claude` distinto. Por eso se valida `procStart` del JSON contra el campo 22 de `/proc/<PID>/stat`; si no coincide la entrada se descarta. Sin esa validacion se reportarian agentes fantasma.
|
|
||||||
- **El titulo exacto de la ventana kitty no se recupera sin `kitty @`.** Se reporta el `KITTY_PID` (suficiente para identificar la ventana); mapearlo al titulo requeriria `kitty @ ls`, que solo funciona si el control remoto de kitty esta habilitado. KISS: se omite por defecto. Un claude que corra fuera de kitty (terminal integrado de un editor, etc.) sale con `KITTY` vacio `(none)` / `kitty_pid: null`.
|
|
||||||
- **Solo ve procesos del usuario actual.** `pgrep -x claude` y la lectura de `/proc/<PID>/{environ,stat}` solo cubren los claudes del propio usuario; no lista sesiones de otros usuarios del sistema.
|
|
||||||
- **`status` refleja el ultimo estado guardado en el JSON**, no necesariamente el instante exacto de la consulta (Claude actualiza el archivo al cambiar de estado). Pueden aparecer valores como `idle`, `busy` o `waiting`.
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# list_claude_agents — Lista todas las instancias de Claude Code VIVAS cruzando
|
|
||||||
# pgrep -x claude con los archivos de estado por proceso ~/.claude/sessions/<PID>.json.
|
|
||||||
# Para cada claude vivo y validado reporta: PID, status (idle/busy), etime (tiempo de
|
|
||||||
# vida del proceso), KITTY_PID de la ventana kitty si corre en una, sessionId y cwd.
|
|
||||||
# Es la herramienta de "seguimiento de la flota" del modo orquestador: el Claude
|
|
||||||
# principal la usa para ver que agentes secundarios siguen vivos, en que directorio
|
|
||||||
# trabajan y su sessionId (para poder retomarlos con claude --resume <sessionId>).
|
|
||||||
#
|
|
||||||
# Mecanismo (Claude Code 2.1.x sobre Linux + kitty):
|
|
||||||
# - pgrep -x claude -> PIDs de las sesiones interactivas vivas.
|
|
||||||
# - ~/.claude/sessions/<PID>.json -> mapea PID a {sessionId, cwd, status, procStart}.
|
|
||||||
# - Anti-PID-reciclado: procStart del JSON debe coincidir con el campo 22 de
|
|
||||||
# /proc/<PID>/stat; ademas kill -0 <PID> debe tener exito y el JSON debe existir.
|
|
||||||
# - KITTY_PID del environ del proceso -> ventana kitty (titulo exacto requeriria
|
|
||||||
# 'kitty @ ls'; aqui se reporta el KITTY_PID, suficiente para identificarla).
|
|
||||||
# - etime via ps -o etime= -p <PID>.
|
|
||||||
set -euo pipefail
|
|
||||||
IFS=$' \t\n'
|
|
||||||
|
|
||||||
list_claude_agents() {
|
|
||||||
local output="table" # table | json
|
|
||||||
local exclude_current=0
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Parseo de argumentos
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--json)
|
|
||||||
output="json"
|
|
||||||
;;
|
|
||||||
--exclude-current)
|
|
||||||
exclude_current=1
|
|
||||||
;;
|
|
||||||
-h|--help)
|
|
||||||
cat <<'USAGE'
|
|
||||||
Uso: list_claude_agents [--json] [--exclude-current]
|
|
||||||
|
|
||||||
Lista las instancias de Claude Code vivas y validas, una fila por agente, con su
|
|
||||||
PID, status, etime (tiempo de vida), KITTY_PID, sessionId y cwd. Pensada para el
|
|
||||||
modo orquestador: ver la flota de Claudes secundarios y su sessionId para retomar
|
|
||||||
(claude --resume <sessionId>) o decidir cual parar.
|
|
||||||
|
|
||||||
Opciones:
|
|
||||||
--json Imprime un array JSON (pid, session_id, cwd, status, etime,
|
|
||||||
kitty_pid) en vez de la tabla. Util para parsear.
|
|
||||||
--exclude-current Omite la propia sesion (sube por la cadena de ancestros de
|
|
||||||
$$ hasta hallar un proceso con comm=claude). Sin esta opcion,
|
|
||||||
la sesion actual se marca con self=true / SELF en la tabla.
|
|
||||||
-h, --help Muestra esta ayuda.
|
|
||||||
|
|
||||||
Ejemplos:
|
|
||||||
list_claude_agents
|
|
||||||
list_claude_agents --json
|
|
||||||
list_claude_agents --exclude-current
|
|
||||||
USAGE
|
|
||||||
return 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "list_claude_agents: opcion desconocida: '$1' (usa -h)" >&2
|
|
||||||
return 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Detectar el PID de la sesion actual subiendo por la cadena de ancestros
|
|
||||||
# hasta encontrar un proceso cuyo comm sea exactamente "claude".
|
|
||||||
# Se usa tanto para --exclude-current como para marcar la fila propia.
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
local current_claude_pid=""
|
|
||||||
local walk="$$"
|
|
||||||
local guard=0
|
|
||||||
while [[ -n "$walk" && "$walk" != "0" && "$walk" != "1" ]]; do
|
|
||||||
local comm=""
|
|
||||||
comm="$(cat "/proc/$walk/comm" 2>/dev/null || true)"
|
|
||||||
if [[ "$comm" == "claude" ]]; then
|
|
||||||
current_claude_pid="$walk"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
# campo 4 de /proc/<pid>/stat es el PPID
|
|
||||||
walk="$(awk '{print $4}' "/proc/$walk/stat" 2>/dev/null || true)"
|
|
||||||
guard=$((guard + 1))
|
|
||||||
[[ "$guard" -gt 64 ]] && break
|
|
||||||
done
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Recolectar las sesiones vivas y validarlas.
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
local sessions_dir="$HOME/.claude/sessions"
|
|
||||||
local pids=""
|
|
||||||
pids="$(pgrep -x claude 2>/dev/null || true)"
|
|
||||||
|
|
||||||
if [[ -z "$pids" ]]; then
|
|
||||||
if [[ "$output" == "json" ]]; then
|
|
||||||
echo "[]"
|
|
||||||
else
|
|
||||||
echo "list_claude_agents: no hay sesiones de Claude Code vivas (pgrep -x claude vacio)."
|
|
||||||
fi
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Arrays paralelos con la flota validada.
|
|
||||||
local -a a_pid a_status a_etime a_kitty a_sid a_cwd a_self
|
|
||||||
|
|
||||||
local pid
|
|
||||||
for pid in $pids; do
|
|
||||||
# Validacion 1: el proceso debe seguir vivo.
|
|
||||||
if ! kill -0 "$pid" 2>/dev/null; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Validacion 2: debe existir su JSON de sesion.
|
|
||||||
local json="$sessions_dir/$pid.json"
|
|
||||||
if [[ ! -f "$json" ]]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Parsear el JSON con python3 (campos sessionId, cwd, status, procStart).
|
|
||||||
# Salida: lineas "clave=valor" en orden fijo.
|
|
||||||
local parsed=""
|
|
||||||
parsed="$(python3 - "$json" <<'PY' 2>/dev/null || true
|
|
||||||
import json, sys
|
|
||||||
try:
|
|
||||||
with open(sys.argv[1]) as fh:
|
|
||||||
d = json.load(fh)
|
|
||||||
except Exception:
|
|
||||||
sys.exit(0)
|
|
||||||
print("sessionId=" + str(d.get("sessionId", "")))
|
|
||||||
print("cwd=" + str(d.get("cwd", "")))
|
|
||||||
print("status=" + str(d.get("status", "")))
|
|
||||||
print("procStart=" + str(d.get("procStart", "")))
|
|
||||||
PY
|
|
||||||
)"
|
|
||||||
[[ -z "$parsed" ]] && continue
|
|
||||||
|
|
||||||
local sid cwd status proc_start_json
|
|
||||||
sid="$(printf '%s\n' "$parsed" | sed -n 's/^sessionId=//p')"
|
|
||||||
cwd="$(printf '%s\n' "$parsed" | sed -n 's/^cwd=//p')"
|
|
||||||
status="$(printf '%s\n' "$parsed" | sed -n 's/^status=//p')"
|
|
||||||
proc_start_json="$(printf '%s\n' "$parsed" | sed -n 's/^procStart=//p')"
|
|
||||||
|
|
||||||
[[ -z "$sid" ]] && continue
|
|
||||||
|
|
||||||
# Validacion 3 (anti-PID-reciclado): procStart del JSON debe coincidir
|
|
||||||
# con el campo 22 de /proc/<PID>/stat.
|
|
||||||
local proc_start_real=""
|
|
||||||
proc_start_real="$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true)"
|
|
||||||
if [[ -n "$proc_start_json" && "$proc_start_json" != "$proc_start_real" ]]; then
|
|
||||||
# JSON huerfano de un PID reciclado: omitir.
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Omitir la propia sesion si se pidio --exclude-current.
|
|
||||||
if [[ "$exclude_current" -eq 1 && -n "$current_claude_pid" && "$pid" == "$current_claude_pid" ]]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# KITTY_PID de la ventana kitty (vacio si claude no corre en kitty).
|
|
||||||
local kitty_pid=""
|
|
||||||
kitty_pid="$(tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | sed -n 's/^KITTY_PID=//p' | head -n1)"
|
|
||||||
|
|
||||||
# etime: tiempo transcurrido desde que arranco el proceso.
|
|
||||||
local etime=""
|
|
||||||
etime="$(ps -o etime= -p "$pid" 2>/dev/null | tr -d ' ' || true)"
|
|
||||||
|
|
||||||
# Marca de sesion propia (solo relevante cuando NO se excluye).
|
|
||||||
local self="false"
|
|
||||||
if [[ -n "$current_claude_pid" && "$pid" == "$current_claude_pid" ]]; then
|
|
||||||
self="true"
|
|
||||||
fi
|
|
||||||
|
|
||||||
a_pid+=("$pid")
|
|
||||||
a_status+=("${status:-?}")
|
|
||||||
a_etime+=("${etime:-?}")
|
|
||||||
a_kitty+=("${kitty_pid:-}")
|
|
||||||
a_sid+=("$sid")
|
|
||||||
a_cwd+=("${cwd:-?}")
|
|
||||||
a_self+=("$self")
|
|
||||||
done
|
|
||||||
|
|
||||||
local total="${#a_pid[@]}"
|
|
||||||
if [[ "$total" -eq 0 ]]; then
|
|
||||||
if [[ "$output" == "json" ]]; then
|
|
||||||
echo "[]"
|
|
||||||
else
|
|
||||||
echo "list_claude_agents: ninguna sesion valida encontrada (PIDs huerfanos, reciclados, o sin JSON)."
|
|
||||||
fi
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Salida JSON.
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
if [[ "$output" == "json" ]]; then
|
|
||||||
# Delegar el escaping correcto de strings (cwd con espacios, etc.) a python3.
|
|
||||||
# El codigo python va en -c y los datos por stdin (TSV), para no colisionar
|
|
||||||
# el heredoc con el stdin del pipe.
|
|
||||||
local i
|
|
||||||
{
|
|
||||||
for ((i = 0; i < total; i++)); do
|
|
||||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
|
||||||
"${a_pid[$i]}" \
|
|
||||||
"${a_sid[$i]}" \
|
|
||||||
"${a_cwd[$i]}" \
|
|
||||||
"${a_status[$i]}" \
|
|
||||||
"${a_etime[$i]}" \
|
|
||||||
"${a_kitty[$i]}" \
|
|
||||||
"${a_self[$i]}"
|
|
||||||
done
|
|
||||||
} | python3 -c '
|
|
||||||
import json, sys
|
|
||||||
out = []
|
|
||||||
for line in sys.stdin:
|
|
||||||
line = line.rstrip("\n")
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
pid, sid, cwd, status, etime, kitty, self_ = line.split("\t")
|
|
||||||
out.append({
|
|
||||||
"pid": int(pid) if pid.isdigit() else pid,
|
|
||||||
"session_id": sid,
|
|
||||||
"cwd": cwd,
|
|
||||||
"status": status,
|
|
||||||
"etime": etime,
|
|
||||||
"kitty_pid": (int(kitty) if kitty.isdigit() else (kitty or None)),
|
|
||||||
"self": (self_ == "true"),
|
|
||||||
})
|
|
||||||
print(json.dumps(out, indent=2))
|
|
||||||
'
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Salida tabla legible.
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
echo "list_claude_agents — claudes vivos: ${total}"
|
|
||||||
echo
|
|
||||||
printf '%-8s %-7s %-12s %-9s %-6s %-38s %s\n' \
|
|
||||||
"PID" "STATUS" "ETIME" "KITTY" "SELF" "SESSION_ID" "CWD"
|
|
||||||
printf '%-8s %-7s %-12s %-9s %-6s %-38s %s\n' \
|
|
||||||
"--------" "-------" "------------" "---------" "------" \
|
|
||||||
"--------------------------------------" "---"
|
|
||||||
|
|
||||||
local i
|
|
||||||
for ((i = 0; i < total; i++)); do
|
|
||||||
local self_mark=""
|
|
||||||
[[ "${a_self[$i]}" == "true" ]] && self_mark="SELF"
|
|
||||||
printf '%-8s %-7s %-12s %-9s %-6s %-38s %s\n' \
|
|
||||||
"${a_pid[$i]}" \
|
|
||||||
"${a_status[$i]}" \
|
|
||||||
"${a_etime[$i]}" \
|
|
||||||
"${a_kitty[$i]:-(none)}" \
|
|
||||||
"${self_mark:--}" \
|
|
||||||
"${a_sid[$i]}" \
|
|
||||||
"${a_cwd[$i]}"
|
|
||||||
done
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
|
||||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
||||||
list_claude_agents "$@"
|
|
||||||
fi
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
---
|
|
||||||
name: reboot_all_claudes
|
|
||||||
kind: function
|
|
||||||
lang: bash
|
|
||||||
domain: infra
|
|
||||||
version: "1.0.0"
|
|
||||||
purity: impure
|
|
||||||
signature: "reboot_all_claudes([--go|--yes] [--resume-mode resume|continue|none] [--exclude-current] [--only-idle] [-h|--help])"
|
|
||||||
description: "Cierra todas las terminales kitty con una sesion de Claude Code corriendo y las relanza retomando la misma sesion (claude --resume <sessionId>). Mapea cada PID vivo a su ~/.claude/sessions/<PID>.json para sacar sessionId, cwd y la ventana kitty. DRY-RUN por defecto; --go ejecuta de verdad de forma desacoplada."
|
|
||||||
tags: [claude, session, terminal, kitty, reboot, infra, terminal-capture]
|
|
||||||
uses_functions: []
|
|
||||||
uses_types: []
|
|
||||||
returns: []
|
|
||||||
returns_optional: false
|
|
||||||
error_type: "error_go_core"
|
|
||||||
imports: []
|
|
||||||
params:
|
|
||||||
- name: "--go"
|
|
||||||
desc: "Ejecuta de verdad: mata las ventanas kitty y relanza las sesiones (detached). Alias --yes. Sin esto es dry-run."
|
|
||||||
- name: "--yes"
|
|
||||||
desc: "Alias de --go."
|
|
||||||
- name: "--resume-mode <resume|continue|none>"
|
|
||||||
desc: "Estrategia de reanudacion. resume (default): claude --resume <sessionId>. continue: claude --continue. none: sesion nueva en el mismo cwd."
|
|
||||||
- name: "--exclude-current"
|
|
||||||
desc: "No cierra ni relanza la terminal desde la que se invoca. Detecta el claude propio subiendo por la cadena de PPIDs hasta hallar un ancestro con comm=claude."
|
|
||||||
- name: "--only-idle"
|
|
||||||
desc: "Omite las sesiones con status busy (no pierde el turno en vuelo). Por defecto se incluyen todas y el dry-run avisa cuales estan busy."
|
|
||||||
- name: "-h|--help"
|
|
||||||
desc: "Muestra el uso y termina."
|
|
||||||
output: "Imprime una tabla del plan (PID, KITTY_PID, status, accion, sessionId, cwd) y el comando claude exacto por sesion. En dry-run no toca nada. Con --go lanza un script desacoplado en /tmp que cierra ventanas y relanza. Exit 0 normal; exit 2 si flags invalidos."
|
|
||||||
tested: false
|
|
||||||
tests: []
|
|
||||||
test_file_path: ""
|
|
||||||
file_path: "bash/functions/infra/reboot_all_claudes.sh"
|
|
||||||
notes: "Mecanismo (Claude Code 2.1.x sobre Linux + kitty): pgrep -x claude -> PIDs vivos; ~/.claude/sessions/<PID>.json -> sessionId/cwd/status/procStart; anti-PID-reciclado comparando procStart del JSON con el campo 22 de /proc/<PID>/stat; KITTY_PID del environ -> ventana a cerrar con SIGTERM; cmdline -> flags conservados (sin argv0 ni resume previos). El relanzamiento usa setsid kitty --directory <cwd> zsh -ic 'claude ...; exec zsh'. Como la propia terminal es una victima, el plan --go se escribe a /tmp y se lanza con setsid para sobrevivir al cierre del padre."
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ejemplo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Dry-run (default seguro): ver el plan sin tocar nada.
|
|
||||||
reboot_all_claudes
|
|
||||||
|
|
||||||
# Reiniciar de verdad todas las sesiones MENOS la terminal actual.
|
|
||||||
reboot_all_claudes --go --exclude-current
|
|
||||||
|
|
||||||
# Reiniciar solo las sesiones idle (no perder turnos en vuelo), de verdad.
|
|
||||||
reboot_all_claudes --go --only-idle
|
|
||||||
|
|
||||||
# Arrancar sesiones nuevas (sin reanudar la conversacion) en cada cwd.
|
|
||||||
reboot_all_claudes --go --resume-mode none
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cuando usarla
|
|
||||||
|
|
||||||
Tras actualizar Claude Code (para que todas las sesiones corran la version nueva), o cuando varias sesiones se cuelgan y quieres reiniciarlas todas de golpe retomando exactamente la conversacion donde estaba cada una. Lanza siempre primero sin flags para revisar el plan; luego repite con `--go`.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **Es impura y se auto-mata.** La terminal desde la que la invocas suele ser una de las victimas; por eso el modo `--go` escribe un script a `/tmp/reboot_all_claudes.<pid>.<ts>.sh` y lo lanza con `setsid` para que el reparenting a init garantice los relanzamientos aunque el padre muera. Usa `--exclude-current` si quieres conservar la terminal actual.
|
|
||||||
- **Sesiones `busy` pierden el turno en vuelo.** Por defecto se reinician igual y el dry-run lo avisa explicitamente. Al reanudar con `--resume` se recupera hasta el ultimo mensaje completo guardado en el `.jsonl`. Usa `--only-idle` para no tocarlas.
|
|
||||||
- **Depende de `~/.claude/sessions/<PID>.json`** (formato de Claude Code 2.1.x). Si una version futura cambia el formato, la funcion deja de mapear PID -> sessionId y omitira las sesiones.
|
|
||||||
- **Asume kitty como terminal.** Si un claude corre fuera de kitty (sin `KITTY_PID` en el environ, p.ej. terminal integrado de un editor), el fallback mata directamente el PID de claude y abre una kitty nueva en su `cwd`.
|
|
||||||
- **Anti-PID-reciclado:** valida `procStart` del JSON contra el campo 22 de `/proc/<PID>/stat`; si no coincide (o el JSON no existe, o `kill -0` falla) la sesion se omite como huerfana.
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# reboot_all_claudes — Cierra todas las terminales con una sesion de Claude Code
|
|
||||||
# corriendo y las relanza retomando exactamente la sesion que tenian
|
|
||||||
# (claude --resume <sessionId>). Por defecto es DRY-RUN: imprime el plan sin
|
|
||||||
# tocar nada. Usar --go para ejecutarlo de verdad.
|
|
||||||
#
|
|
||||||
# Mecanismo (Claude Code 2.1.x sobre Linux + kitty):
|
|
||||||
# - pgrep -x claude -> PIDs de las sesiones interactivas vivas.
|
|
||||||
# - ~/.claude/sessions/<PID>.json -> mapea PID a {sessionId, cwd, status, procStart}.
|
|
||||||
# - Anti-PID-reciclado: procStart del JSON debe coincidir con el campo 22 de
|
|
||||||
# /proc/<PID>/stat; ademas kill -0 <PID> debe tener exito.
|
|
||||||
# - KITTY_PID del environ del proceso -> ventana kitty a cerrar.
|
|
||||||
# - cmdline del proceso -> flags originales a conservar (sin argv0 ni resume previos).
|
|
||||||
# - Relanzamiento detached (setsid) para sobrevivir al cierre de la propia terminal.
|
|
||||||
set -euo pipefail
|
|
||||||
IFS=$' \t\n'
|
|
||||||
|
|
||||||
reboot_all_claudes() {
|
|
||||||
local mode="dry" # dry | go
|
|
||||||
local resume_mode="resume" # resume | continue | none
|
|
||||||
local exclude_current=0
|
|
||||||
local only_idle=0
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Parseo de argumentos
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--go|--yes)
|
|
||||||
mode="go"
|
|
||||||
;;
|
|
||||||
--resume-mode)
|
|
||||||
shift
|
|
||||||
resume_mode="${1:-}"
|
|
||||||
case "$resume_mode" in
|
|
||||||
resume|continue|none) ;;
|
|
||||||
*)
|
|
||||||
echo "reboot_all_claudes: --resume-mode invalido: '$resume_mode' (usa resume|continue|none)" >&2
|
|
||||||
return 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
;;
|
|
||||||
--exclude-current)
|
|
||||||
exclude_current=1
|
|
||||||
;;
|
|
||||||
--only-idle)
|
|
||||||
only_idle=1
|
|
||||||
;;
|
|
||||||
-h|--help)
|
|
||||||
cat <<'USAGE'
|
|
||||||
Uso: reboot_all_claudes [opciones]
|
|
||||||
|
|
||||||
Cierra todas las terminales con una sesion de Claude Code corriendo y las
|
|
||||||
relanza retomando la misma sesion (claude --resume <sessionId>).
|
|
||||||
|
|
||||||
Por defecto es DRY-RUN (accion destructiva => default seguro): imprime el plan
|
|
||||||
y NO mata ni relanza nada.
|
|
||||||
|
|
||||||
Opciones:
|
|
||||||
--go, --yes Ejecuta de verdad (kills + relanzamientos detached).
|
|
||||||
--resume-mode <modo> resume (default) | continue | none.
|
|
||||||
resume -> claude --resume <sessionId>
|
|
||||||
continue -> claude --continue
|
|
||||||
none -> claude (sesion nueva en el mismo cwd)
|
|
||||||
--exclude-current No cierra ni relanza la terminal desde la que se invoca.
|
|
||||||
--only-idle Omite sesiones con status busy (no pierde turnos en vuelo).
|
|
||||||
-h, --help Muestra esta ayuda.
|
|
||||||
|
|
||||||
Ejemplos:
|
|
||||||
reboot_all_claudes # dry-run, ve el plan
|
|
||||||
reboot_all_claudes --go --exclude-current # reinicia todas menos esta terminal
|
|
||||||
USAGE
|
|
||||||
return 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "reboot_all_claudes: opcion desconocida: '$1' (usa -h)" >&2
|
|
||||||
return 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Detectar el PID de la sesion actual subiendo por la cadena de ancestros
|
|
||||||
# hasta encontrar un proceso cuyo comm sea exactamente "claude".
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
local current_claude_pid=""
|
|
||||||
if [[ "$exclude_current" -eq 1 ]]; then
|
|
||||||
local walk="$$"
|
|
||||||
local guard=0
|
|
||||||
while [[ -n "$walk" && "$walk" != "0" && "$walk" != "1" ]]; do
|
|
||||||
local comm=""
|
|
||||||
comm="$(cat "/proc/$walk/comm" 2>/dev/null || true)"
|
|
||||||
if [[ "$comm" == "claude" ]]; then
|
|
||||||
current_claude_pid="$walk"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
# campo 4 de /proc/<pid>/stat es el PPID
|
|
||||||
walk="$(awk '{print $4}' "/proc/$walk/stat" 2>/dev/null || true)"
|
|
||||||
guard=$((guard + 1))
|
|
||||||
[[ "$guard" -gt 64 ]] && break
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Recolectar las sesiones vivas y validarlas.
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
local sessions_dir="$HOME/.claude/sessions"
|
|
||||||
local pids=""
|
|
||||||
pids="$(pgrep -x claude 2>/dev/null || true)"
|
|
||||||
|
|
||||||
if [[ -z "$pids" ]]; then
|
|
||||||
echo "reboot_all_claudes: no hay sesiones de Claude Code vivas (pgrep -x claude vacio)."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Arrays paralelos con el plan validado.
|
|
||||||
local -a plan_pid plan_kitty plan_status plan_cwd plan_sid plan_cmd plan_skip plan_skipreason
|
|
||||||
|
|
||||||
local pid
|
|
||||||
for pid in $pids; do
|
|
||||||
# Validacion 1: el proceso debe seguir vivo.
|
|
||||||
if ! kill -0 "$pid" 2>/dev/null; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Validacion 2: debe existir su JSON de sesion.
|
|
||||||
local json="$sessions_dir/$pid.json"
|
|
||||||
if [[ ! -f "$json" ]]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Parsear el JSON con python3 (campos sessionId, cwd, status, procStart).
|
|
||||||
# Salida: lineas "clave=valor" en orden fijo.
|
|
||||||
local parsed=""
|
|
||||||
parsed="$(python3 - "$json" <<'PY' 2>/dev/null || true
|
|
||||||
import json, sys
|
|
||||||
try:
|
|
||||||
with open(sys.argv[1]) as fh:
|
|
||||||
d = json.load(fh)
|
|
||||||
except Exception:
|
|
||||||
sys.exit(0)
|
|
||||||
print("sessionId=" + str(d.get("sessionId", "")))
|
|
||||||
print("cwd=" + str(d.get("cwd", "")))
|
|
||||||
print("status=" + str(d.get("status", "")))
|
|
||||||
print("procStart=" + str(d.get("procStart", "")))
|
|
||||||
PY
|
|
||||||
)"
|
|
||||||
[[ -z "$parsed" ]] && continue
|
|
||||||
|
|
||||||
local sid cwd status proc_start_json
|
|
||||||
sid="$(printf '%s\n' "$parsed" | sed -n 's/^sessionId=//p')"
|
|
||||||
cwd="$(printf '%s\n' "$parsed" | sed -n 's/^cwd=//p')"
|
|
||||||
status="$(printf '%s\n' "$parsed" | sed -n 's/^status=//p')"
|
|
||||||
proc_start_json="$(printf '%s\n' "$parsed" | sed -n 's/^procStart=//p')"
|
|
||||||
|
|
||||||
[[ -z "$sid" ]] && continue
|
|
||||||
|
|
||||||
# Validacion 3 (anti-PID-reciclado): procStart del JSON debe coincidir
|
|
||||||
# con el campo 22 de /proc/<PID>/stat.
|
|
||||||
local proc_start_real=""
|
|
||||||
proc_start_real="$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true)"
|
|
||||||
if [[ -n "$proc_start_json" && "$proc_start_json" != "$proc_start_real" ]]; then
|
|
||||||
# JSON huerfano de un PID reciclado: omitir.
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# KITTY_PID de la ventana kitty (vacio si claude no corre en kitty).
|
|
||||||
local kitty_pid=""
|
|
||||||
kitty_pid="$(tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | sed -n 's/^KITTY_PID=//p' | head -n1)"
|
|
||||||
|
|
||||||
# Flags originales: leer cmdline, descartar argv0 (claude) y cualquier
|
|
||||||
# flag de resume/continue previo para no duplicarlos.
|
|
||||||
local raw_cmd=""
|
|
||||||
raw_cmd="$(tr '\0' '\n' < "/proc/$pid/cmdline" 2>/dev/null || true)"
|
|
||||||
local -a kept_flags=()
|
|
||||||
local first=1 tok skipnext=0
|
|
||||||
while IFS= read -r tok; do
|
|
||||||
[[ -z "$tok" ]] && continue
|
|
||||||
if [[ "$first" -eq 1 ]]; then
|
|
||||||
# argv0 (la ruta o nombre de claude) — descartar.
|
|
||||||
first=0
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if [[ "$skipnext" -eq 1 ]]; then
|
|
||||||
skipnext=0
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
case "$tok" in
|
|
||||||
--resume|--continue|-r|-c)
|
|
||||||
# Resume/continue previos: omitir (y su posible valor para --resume).
|
|
||||||
if [[ "$tok" == "--resume" || "$tok" == "-r" ]]; then
|
|
||||||
skipnext=1
|
|
||||||
fi
|
|
||||||
continue
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
kept_flags+=("$tok")
|
|
||||||
done <<< "$raw_cmd"
|
|
||||||
|
|
||||||
# Construir la estrategia de resume.
|
|
||||||
local -a launch_args=()
|
|
||||||
case "$resume_mode" in
|
|
||||||
resume) launch_args=("--resume" "$sid") ;;
|
|
||||||
continue) launch_args=("--continue") ;;
|
|
||||||
none) launch_args=() ;;
|
|
||||||
esac
|
|
||||||
launch_args+=("${kept_flags[@]}")
|
|
||||||
|
|
||||||
# Comando claude final (para mostrar y ejecutar).
|
|
||||||
local claude_cmd="claude"
|
|
||||||
local a
|
|
||||||
for a in "${launch_args[@]}"; do
|
|
||||||
claude_cmd+=" $(printf '%q' "$a")"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Decidir si se omite esta sesion del plan.
|
|
||||||
local skip=0 skipreason=""
|
|
||||||
if [[ "$exclude_current" -eq 1 && -n "$current_claude_pid" && "$pid" == "$current_claude_pid" ]]; then
|
|
||||||
skip=1
|
|
||||||
skipreason="terminal actual (--exclude-current)"
|
|
||||||
elif [[ "$only_idle" -eq 1 && "$status" == "busy" ]]; then
|
|
||||||
skip=1
|
|
||||||
skipreason="busy (--only-idle)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
plan_pid+=("$pid")
|
|
||||||
plan_kitty+=("${kitty_pid:-}")
|
|
||||||
plan_status+=("${status:-?}")
|
|
||||||
plan_cwd+=("${cwd:-?}")
|
|
||||||
plan_sid+=("$sid")
|
|
||||||
plan_cmd+=("$claude_cmd")
|
|
||||||
plan_skip+=("$skip")
|
|
||||||
plan_skipreason+=("$skipreason")
|
|
||||||
done
|
|
||||||
|
|
||||||
local total="${#plan_pid[@]}"
|
|
||||||
if [[ "$total" -eq 0 ]]; then
|
|
||||||
echo "reboot_all_claudes: ninguna sesion valida encontrada (todos los PIDs eran huerfanos o reciclados)."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Imprimir el plan (siempre, tanto en dry-run como en --go).
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
echo "reboot_all_claudes — modo: ${mode} resume: ${resume_mode} sesiones: ${total}"
|
|
||||||
echo
|
|
||||||
printf '%-8s %-9s %-7s %-6s %-38s %s\n' "PID" "KITTY" "STATUS" "ACCION" "SESSION_ID" "CWD"
|
|
||||||
printf '%-8s %-9s %-7s %-6s %-38s %s\n' "--------" "---------" "-------" "------" "--------------------------------------" "---"
|
|
||||||
|
|
||||||
local i busy_count=0 act_count=0
|
|
||||||
for ((i = 0; i < total; i++)); do
|
|
||||||
local accion="reinic"
|
|
||||||
if [[ "${plan_skip[$i]}" -eq 1 ]]; then
|
|
||||||
accion="OMITE"
|
|
||||||
else
|
|
||||||
act_count=$((act_count + 1))
|
|
||||||
fi
|
|
||||||
[[ "${plan_status[$i]}" == "busy" ]] && busy_count=$((busy_count + 1))
|
|
||||||
printf '%-8s %-9s %-7s %-6s %-38s %s\n' \
|
|
||||||
"${plan_pid[$i]}" \
|
|
||||||
"${plan_kitty[$i]:-(none)}" \
|
|
||||||
"${plan_status[$i]}" \
|
|
||||||
"$accion" \
|
|
||||||
"${plan_sid[$i]}" \
|
|
||||||
"${plan_cwd[$i]}"
|
|
||||||
if [[ "${plan_skip[$i]}" -eq 1 ]]; then
|
|
||||||
echo " -> omitida: ${plan_skipreason[$i]}"
|
|
||||||
else
|
|
||||||
echo " -> ${plan_cmd[$i]}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo
|
|
||||||
|
|
||||||
# Aviso explicito de sesiones busy que SI se van a reiniciar.
|
|
||||||
if [[ "$only_idle" -eq 0 ]]; then
|
|
||||||
local warned=0
|
|
||||||
for ((i = 0; i < total; i++)); do
|
|
||||||
if [[ "${plan_skip[$i]}" -eq 0 && "${plan_status[$i]}" == "busy" ]]; then
|
|
||||||
if [[ "$warned" -eq 0 ]]; then
|
|
||||||
echo "AVISO: las siguientes sesiones estan BUSY y se reiniciaran; perderan el turno en vuelo"
|
|
||||||
echo " (al reanudar con --resume se recupera hasta el ultimo mensaje completo guardado):"
|
|
||||||
warned=1
|
|
||||||
fi
|
|
||||||
echo " - PID ${plan_pid[$i]} cwd=${plan_cwd[$i]}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
[[ "$warned" -eq 1 ]] && echo
|
|
||||||
fi
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# DRY-RUN: parar aqui.
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
if [[ "$mode" == "dry" ]]; then
|
|
||||||
echo "DRY-RUN: no se ha matado ni relanzado nada."
|
|
||||||
echo "Para ejecutar de verdad: reboot_all_claudes --go"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$act_count" -eq 0 ]]; then
|
|
||||||
echo "reboot_all_claudes: nada que hacer (todas las sesiones quedaron omitidas)."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# MODO --go: construir un script desacoplado que mata las ventanas y
|
|
||||||
# relanza las sesiones. Se ejecuta con setsid para que sobreviva al cierre
|
|
||||||
# de la propia terminal (que es una de las victimas).
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
local ts script log
|
|
||||||
ts="$(date +%s)"
|
|
||||||
script="/tmp/reboot_all_claudes.$$.$ts.sh"
|
|
||||||
log="/tmp/reboot_all_claudes.$ts.log"
|
|
||||||
|
|
||||||
{
|
|
||||||
echo '#!/usr/bin/env bash'
|
|
||||||
echo 'set -uo pipefail'
|
|
||||||
echo '# Dar tiempo a que la terminal padre devuelva el control antes de matar.'
|
|
||||||
echo 'sleep 1'
|
|
||||||
echo
|
|
||||||
for ((i = 0; i < total; i++)); do
|
|
||||||
[[ "${plan_skip[$i]}" -eq 1 ]] && continue
|
|
||||||
local kp="${plan_kitty[$i]}"
|
|
||||||
local cp="${plan_pid[$i]}"
|
|
||||||
local cwd="${plan_cwd[$i]}"
|
|
||||||
local cmd="${plan_cmd[$i]}"
|
|
||||||
echo "# --- sesion PID ${cp} (kitty ${kp:-none}) ---"
|
|
||||||
if [[ -n "$kp" ]]; then
|
|
||||||
# Cerrar la ventana kitty limpia con SIGTERM.
|
|
||||||
echo "kill $(printf '%q' "$kp") 2>/dev/null || true"
|
|
||||||
else
|
|
||||||
# Sin kitty: matar el propio claude.
|
|
||||||
echo "kill $(printf '%q' "$cp") 2>/dev/null || true"
|
|
||||||
fi
|
|
||||||
# Relanzar en una kitty nueva, detached, en el cwd correcto.
|
|
||||||
# zsh -ic '...; exec zsh' replica el patron del usuario: al salir de
|
|
||||||
# claude queda una shell interactiva viva.
|
|
||||||
printf 'setsid kitty --directory %q zsh -ic %q </dev/null >/dev/null 2>&1 &\n' \
|
|
||||||
"$cwd" "${cmd}; exec zsh"
|
|
||||||
echo
|
|
||||||
done
|
|
||||||
echo 'exit 0'
|
|
||||||
} > "$script"
|
|
||||||
|
|
||||||
chmod +x "$script"
|
|
||||||
echo "reboot_all_claudes: lanzando plan desacoplado -> $script (log: $log)"
|
|
||||||
setsid bash "$script" </dev/null >>"$log" 2>&1 &
|
|
||||||
disown 2>/dev/null || true
|
|
||||||
echo "reboot_all_claudes: hecho. Las terminales se cerraran y reabriran en ~1s."
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
|
||||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
||||||
reboot_all_claudes "$@"
|
|
||||||
fi
|
|
||||||
@@ -3,12 +3,12 @@ name: write_mcp_jupyter_config
|
|||||||
kind: function
|
kind: function
|
||||||
lang: bash
|
lang: bash
|
||||||
domain: infra
|
domain: infra
|
||||||
version: "1.2.0"
|
version: "1.1.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "write_mcp_jupyter_config([project_dir: string], [port: int]) -> string"
|
signature: "write_mcp_jupyter_config([project_dir: string], [port: int]) -> string"
|
||||||
description: "Genera o actualiza .mcp.json para un analisis Jupyter. La entrada jupyter usa el wrapper jupyter_mcp_serve.sh con env overrides (venv, root y puerto del analisis), de modo que el MCP arranca su propio Jupyter con el venv del analisis. Merge con jq reemplazando la entrada jupyter entera."
|
description: "Genera o actualiza .mcp.json con la config de jupyter-mcp-server apuntando al console-script del venv local (transport stdio + flags --jupyter-url/--jupyter-token). Merge con jq reemplazando la entrada jupyter entera."
|
||||||
tags: [mcp, jupyter, config, setup, infra, notebook]
|
tags: [mcp, jupyter, config, setup, infra, notebook]
|
||||||
uses_functions: [jupyter_mcp_serve_bash_infra]
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
returns_optional: false
|
returns_optional: false
|
||||||
@@ -16,9 +16,9 @@ error_type: "error_go_core"
|
|||||||
imports: []
|
imports: []
|
||||||
params:
|
params:
|
||||||
- name: project_dir
|
- name: project_dir
|
||||||
desc: "directorio del proyecto/analisis Jupyter (default: directorio actual)"
|
desc: "directorio del proyecto Jupyter (default: directorio actual)"
|
||||||
- name: port
|
- name: port
|
||||||
desc: "puerto Jupyter del analisis (default: 8888)"
|
desc: "puerto Jupyter (default: detectado automáticamente)"
|
||||||
output: "ruta del archivo .mcp.json generado o actualizado"
|
output: "ruta del archivo .mcp.json generado o actualizado"
|
||||||
tested: false
|
tested: false
|
||||||
tests: []
|
tests: []
|
||||||
@@ -33,33 +33,25 @@ source write_mcp_jupyter_config.sh
|
|||||||
path=$(write_mcp_jupyter_config $HOME/fn_registry/analysis/finanzas 8890)
|
path=$(write_mcp_jupyter_config $HOME/fn_registry/analysis/finanzas 8890)
|
||||||
echo "Config MCP en: $path"
|
echo "Config MCP en: $path"
|
||||||
# Genera .mcp.json con:
|
# Genera .mcp.json con:
|
||||||
# "command": "bash"
|
# "command": ".../.venv/bin/jupyter-mcp-server"
|
||||||
# "args": [".../bash/functions/infra/jupyter_mcp_serve.sh"]
|
# "args": ["--transport","stdio","--jupyter-url","http://localhost:8890","--jupyter-token",""]
|
||||||
# "env": {
|
|
||||||
# "JUPYTER_MCP_VENV": ".../analysis/finanzas/.venv",
|
|
||||||
# "JUPYTER_MCP_ROOT": ".../analysis/finanzas",
|
|
||||||
# "JUPYTER_MCP_PORT": "8890",
|
|
||||||
# "JUPYTER_MCP_TOKEN": ""
|
|
||||||
# }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cuando usarla
|
## Cuando usarla
|
||||||
|
|
||||||
- Al crear un analysis Jupyter nuevo (la usa el pipeline `init_jupyter_analysis`).
|
- Al crear un analysis Jupyter nuevo (la usa el pipeline `init_jupyter_analysis`).
|
||||||
- Tras mover/recrear un venv y necesitar regenerar el `.mcp.json` del analysis.
|
- Tras mover/recrear un venv y necesitar regenerar el `.mcp.json` del analysis.
|
||||||
- Para reparar un `.mcp.json` con el comando viejo (console-script directo que no arranca Jupyter, o `python -m jupyter_mcp_server.server`).
|
- Para reparar un `.mcp.json` con el comando viejo roto (`python -m jupyter_mcp_server.server`).
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- **Usa el wrapper, no el console-script directo**: el `.mcp.json` apunta a `jupyter_mcp_serve.sh` (ver `jupyter_mcp_serve_bash_infra`), que arranca (o reusa) el Jupyter del analisis con su venv antes de exec del MCP. Con el console-script directo (`jupyter-mcp-server --jupyter-url ...`) el MCP solo se CONECTA: si el server no esta levantado no hay kernel y las operaciones sobre notebooks fallan. Con el wrapper basta abrir Claude desde el analisis — no hace falta lanzar `run-jupyter-lab.sh` aparte.
|
- **NUNCA `python -m jupyter_mcp_server.server`** — `server.py` no tiene bloque `__main__`; el proceso importa y sale 0 y el MCP nunca arranca. El entrypoint real es la CLI (`jupyter_mcp_server.CLI:server`), expuesta como console-script `jupyter-mcp-server`. Sin subcomando arranca en stdio por defecto.
|
||||||
- **El venv del kernel es el del analisis** (`JUPYTER_MCP_VENV`), no `python/.venv` del repo. Asi cada analisis ejecuta con sus propias dependencias sin contaminar el venv canonico. Este fix nacio de un caso real (analisis `nats`): trabajar desde la raiz de `fn_registry` cargaba el MCP global (8899, venv `python/.venv`) que no tenia `nats-py`.
|
- **No usa env vars** `SERVER_URL`/`TOKEN`. La CLI lee flags `--jupyter-url` / `--jupyter-token` (cubren document + runtime). Configs viejas con bloque `env` quedan inertes.
|
||||||
- **Reuso por puerto**: si ya hay un Jupyter escuchando en `JUPYTER_MCP_PORT` (p.ej. lanzado por `run-jupyter-lab.sh`, que es colaborativo), el wrapper lo reusa en vez de arrancar otro. Si no hay ninguno, el wrapper levanta uno propio (sin `--collaborative`, suficiente para el MCP). Para colaboracion humana en tiempo real, lanzar `run-jupyter-lab.sh` antes.
|
- **Tolera Jupyter apagado al boot**: el MCP responde `initialize` tras un connect-timeout (~10s) y sirve igual. Arrancar Jupyter despues en `:port` y los tools se enganchan. No hace falta reiniciar Claude por tener Jupyter caido al inicio.
|
||||||
- **NUNCA `python -m jupyter_mcp_server.server`** — `server.py` no tiene bloque `__main__`; importa y sale 0, el MCP nunca arranca. El entrypoint real es el console-script `jupyter-mcp-server`, que el wrapper localiza dentro del venv del analisis.
|
- **Requiere `jupyter-mcp-server` instalado en el venv**: `uv pip install jupyter-mcp-server`. La funcion aborta si el console-script no existe.
|
||||||
- **Requiere `jupyter-mcp-server` instalado en el venv del analisis**: `uv pip install jupyter-mcp-server`. La funcion aborta si el console-script no existe.
|
- **Path atado al venv del analysis**: si borras el analysis, ese `.mcp.json` apunta a un binario inexistente. Para un MCP jupyter global e independiente, el `.mcp.json` raiz de `fn_registry` usa el binario del venv canonico `python/.venv/bin/jupyter-mcp-server` (sobrevive el borrado de cualquier analysis).
|
||||||
- **Localiza el wrapper subiendo directorios** desde `project_dir` (hasta 8 niveles) buscando `bash/functions/infra/jupyter_mcp_serve.sh`; si no lo encuentra, usa `FN_REGISTRY_ROOT`. Aborta si no aparece por ninguna via.
|
- **Merge con jq usa `+` (shallow)** en el mapa de servidores para reemplazar la entrada `jupyter` entera; `*` (deep) dejaba keys huerfanas de configs viejas.
|
||||||
- **Merge con jq usa `+` (shallow)** en el mapa de servidores para reemplazar la entrada `jupyter` entera; `*` (deep) dejaba keys huerfanas de configs viejas (p.ej. el bloque `args` del console-script directo).
|
|
||||||
|
|
||||||
## Capability growth log
|
## Capability growth log
|
||||||
|
|
||||||
- v1.2.0 (2026-06-03) — el `.mcp.json` generado usa el wrapper `jupyter_mcp_serve.sh` con env overrides (`JUPYTER_MCP_VENV/ROOT/PORT/TOKEN`) en vez del console-script directo. Garantiza que el MCP arranca su propio Jupyter con el venv del analisis (antes solo conectaba y usaba el venv equivocado si se abria Claude desde la raiz del repo). Declara dependencia `jupyter_mcp_serve_bash_infra`.
|
|
||||||
- v1.1.0 (2026-05-28) — fix comando roto: console-script `jupyter-mcp-server` + flags stdio en vez de `python -m ...server` + env vars. Merge `+` para reemplazar entrada entera. Tag `notebook`.
|
- v1.1.0 (2026-05-28) — fix comando roto: console-script `jupyter-mcp-server` + flags stdio en vez de `python -m ...server` + env vars. Merge `+` para reemplazar entrada entera. Tag `notebook`.
|
||||||
|
|||||||
@@ -1,32 +1,21 @@
|
|||||||
# write_mcp_jupyter_config
|
# write_mcp_jupyter_config
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server para un
|
# Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server.
|
||||||
# analisis/proyecto. La entrada `jupyter` usa el wrapper `jupyter_mcp_serve.sh`
|
# Usa el console-script `jupyter-mcp-server` del venv local con transport stdio
|
||||||
# (no el console-script directo), de modo que el MCP SIEMPRE tiene servidor: el
|
# y los flags --jupyter-url / --jupyter-token (NO env vars, NO `-m ...server`).
|
||||||
# wrapper arranca (o reusa) un Jupyter Lab en el puerto indicado usando el venv
|
# Hace merge si ya existe .mcp.json (requiere jq).
|
||||||
# del propio analisis y lo engancha al MCP por stdio.
|
|
||||||
#
|
|
||||||
# Por que el wrapper y no el console-script directo: el console-script
|
|
||||||
# `jupyter-mcp-server --jupyter-url http://localhost:PORT` solo se CONECTA, no
|
|
||||||
# arranca Jupyter. Si el server no esta levantado, el MCP responde `initialize`
|
|
||||||
# pero no hay kernel y toda operacion sobre notebooks falla. El wrapper levanta el
|
|
||||||
# server con el venv correcto (JUPYTER_MCP_VENV) antes de exec del MCP, asi que
|
|
||||||
# abrir Claude desde el analisis basta — no hace falta lanzar run-jupyter-lab.sh
|
|
||||||
# aparte. Si ya hay un Jupyter en ese puerto (p.ej. run-jupyter-lab.sh), lo reusa.
|
|
||||||
#
|
|
||||||
# Env overrides que se inyectan al wrapper (ver jupyter_mcp_serve.sh):
|
|
||||||
# JUPYTER_MCP_VENV venv del analisis (su .venv, con jupyter + jupyter-mcp-server)
|
|
||||||
# JUPYTER_MCP_ROOT root de notebooks = directorio del analisis
|
|
||||||
# JUPYTER_MCP_PORT puerto del Jupyter gestionado
|
|
||||||
# JUPYTER_MCP_TOKEN token (vacio: solo escucha en 127.0.0.1)
|
|
||||||
#
|
#
|
||||||
# GOTCHA (2026-05-28): `python -m jupyter_mcp_server.server` NO arranca nada —
|
# GOTCHA (2026-05-28): `python -m jupyter_mcp_server.server` NO arranca nada —
|
||||||
# server.py no tiene bloque __main__. El entrypoint real es el console-script
|
# server.py no tiene bloque __main__, asi que el proceso importa y sale 0 y el
|
||||||
# `jupyter-mcp-server` (que el wrapper localiza dentro del venv del analisis).
|
# MCP nunca levanta. El entrypoint real es la CLI (`jupyter_mcp_server.CLI:server`,
|
||||||
|
# expuesta como console-script `jupyter-mcp-server`), que sin subcomando arranca
|
||||||
|
# en stdio por defecto. La config tampoco lee SERVER_URL/TOKEN: usa los flags
|
||||||
|
# --jupyter-url / --jupyter-token. El MCP tolera que Jupyter este apagado al
|
||||||
|
# arrancar (responde `initialize` tras un connect-timeout ~10s y sirve igual).
|
||||||
#
|
#
|
||||||
# USO (sourced):
|
# USO (sourced):
|
||||||
# source write_mcp_jupyter_config.sh
|
# source write_mcp_jupyter_config.sh
|
||||||
# write_mcp_jupyter_config /path/to/analysis 8890
|
# write_mcp_jupyter_config /path/to/project 8888
|
||||||
|
|
||||||
write_mcp_jupyter_config() {
|
write_mcp_jupyter_config() {
|
||||||
local project_dir="${1:-.}"
|
local project_dir="${1:-.}"
|
||||||
@@ -42,47 +31,23 @@ write_mcp_jupyter_config() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Verificar que el console-script esta instalado en el venv del analisis
|
# Verificar que el console-script esta instalado
|
||||||
if [ ! -x "$mcp_bin" ]; then
|
if [ ! -x "$mcp_bin" ]; then
|
||||||
echo "write_mcp_jupyter_config: jupyter-mcp-server no instalado en el venv (${mcp_bin}). Instala con: uv pip install jupyter-mcp-server" >&2
|
echo "write_mcp_jupyter_config: jupyter-mcp-server no instalado en el venv (${mcp_bin}). Instala con: uv pip install jupyter-mcp-server" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Localizar el wrapper jupyter_mcp_serve.sh subiendo desde el directorio del
|
|
||||||
# analisis hasta la raiz del repo. Fallback a FN_REGISTRY_ROOT.
|
|
||||||
local wrapper="" d="$abs_project"
|
|
||||||
local i
|
|
||||||
for i in 1 2 3 4 5 6 7 8; do
|
|
||||||
if [ -f "$d/bash/functions/infra/jupyter_mcp_serve.sh" ]; then
|
|
||||||
wrapper="$d/bash/functions/infra/jupyter_mcp_serve.sh"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
d="$(dirname "$d")"
|
|
||||||
[ "$d" = "/" ] && break
|
|
||||||
done
|
|
||||||
if [ -z "$wrapper" ] && [ -n "${FN_REGISTRY_ROOT:-}" ] && [ -f "${FN_REGISTRY_ROOT}/bash/functions/infra/jupyter_mcp_serve.sh" ]; then
|
|
||||||
wrapper="${FN_REGISTRY_ROOT}/bash/functions/infra/jupyter_mcp_serve.sh"
|
|
||||||
fi
|
|
||||||
if [ -z "$wrapper" ]; then
|
|
||||||
echo "write_mcp_jupyter_config: no encuentro bash/functions/infra/jupyter_mcp_serve.sh subiendo desde ${abs_project} ni en FN_REGISTRY_ROOT" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
local new_config
|
local new_config
|
||||||
new_config=$(cat << EOF
|
new_config=$(cat << EOF
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"jupyter": {
|
"jupyter": {
|
||||||
"command": "bash",
|
"command": "${mcp_bin}",
|
||||||
"args": [
|
"args": [
|
||||||
"${wrapper}"
|
"--transport", "stdio",
|
||||||
],
|
"--jupyter-url", "http://localhost:${port}",
|
||||||
"env": {
|
"--jupyter-token", ""
|
||||||
"JUPYTER_MCP_VENV": "${abs_project}/.venv",
|
]
|
||||||
"JUPYTER_MCP_ROOT": "${abs_project}",
|
|
||||||
"JUPYTER_MCP_PORT": "${port}",
|
|
||||||
"JUPYTER_MCP_TOKEN": ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,7 +57,7 @@ EOF
|
|||||||
if [ -f "$mcp_file" ] && command -v jq &>/dev/null; then
|
if [ -f "$mcp_file" ] && command -v jq &>/dev/null; then
|
||||||
# Merge conservando otros servidores MCP. Usa `+` (shallow) en el mapa de
|
# Merge conservando otros servidores MCP. Usa `+` (shallow) en el mapa de
|
||||||
# servidores para REEMPLAZAR la entrada `jupyter` entera — `*` (deep) dejaba
|
# servidores para REEMPLAZAR la entrada `jupyter` entera — `*` (deep) dejaba
|
||||||
# keys huerfanas de configs viejas (ej. flags `args` obsoletos).
|
# keys huerfanas de configs viejas (ej. bloque `env` obsoleto).
|
||||||
jq -s '.[0] * {mcpServers: ((.[0].mcpServers // {}) + (.[1].mcpServers // {}))}' \
|
jq -s '.[0] * {mcpServers: ((.[0].mcpServers // {}) + (.[1].mcpServers // {}))}' \
|
||||||
"$mcp_file" <(echo "$new_config") > "${mcp_file}.tmp"
|
"$mcp_file" <(echo "$new_config") > "${mcp_file}.tmp"
|
||||||
mv "${mcp_file}.tmp" "$mcp_file"
|
mv "${mcp_file}.tmp" "$mcp_file"
|
||||||
|
|||||||
@@ -3,15 +3,14 @@ name: full_git_pull
|
|||||||
kind: pipeline
|
kind: pipeline
|
||||||
lang: bash
|
lang: bash
|
||||||
domain: pipelines
|
domain: pipelines
|
||||||
version: "1.1.0"
|
version: "1.0.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "full_git_pull() -> stdout: tabla resumen"
|
signature: "full_git_pull() -> stdout: tabla resumen"
|
||||||
description: "Pull automatico de fn_registry + todos los sub-repos locales + submodules + fn sync. Descubre repos locales, stashea dirty trees antes de pullear, hace pull --ff-only, actualiza submodulos del repo principal, pulla ~/.password-store, regenera registry.db con fn index, ejecuta fn sync y reclona los sub-repos hijos faltantes de cada project (apps/analysis) via clone_project_subrepos."
|
description: "Pull automatico de fn_registry + todos los sub-repos locales + submodules + fn sync. Descubre repos locales, stashea dirty trees antes de pullear, hace pull --ff-only, actualiza submodulos del repo principal, pulla ~/.password-store, regenera registry.db con fn index y ejecuta fn sync."
|
||||||
tags: [git, pull, sync, registry, pipeline, pendiente-usar]
|
tags: [git, pull, sync, registry, pipeline, pendiente-usar]
|
||||||
uses_functions:
|
uses_functions:
|
||||||
- discover_git_repos_bash_infra
|
- discover_git_repos_bash_infra
|
||||||
- git_pull_with_stash_bash_infra
|
- git_pull_with_stash_bash_infra
|
||||||
- clone_project_subrepos_bash_pipelines
|
|
||||||
- pass_get_bash_infra
|
- pass_get_bash_infra
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
@@ -52,10 +51,4 @@ bash bash/functions/pipelines/full_git_pull.sh
|
|||||||
|
|
||||||
## Notas
|
## Notas
|
||||||
|
|
||||||
Solo hace pull fast-forward — nunca rebase ni merge automatico. Los repos con divergencia o conflicto de stash se listan al final del resumen para intervencion manual, pero el pipeline no aborta por ellos. Modo completamente no-interactivo.
|
Solo hace pull fast-forward — nunca rebase ni merge automatico. Los repos con divergencia o conflicto de stash se listan al final del resumen para intervencion manual, pero el pipeline no aborta por ellos. No clona repos faltantes: cada PC tiene el subset que le interesa (clonar manualmente si se necesita uno nuevo). Modo completamente no-interactivo.
|
||||||
|
|
||||||
Desde v1.1.0 SI reclona los sub-repos hijos faltantes de cada project: tras `fn sync` (que trae a `registry.db` las filas de apps/analysis de todos los PCs), itera los projects y llama `clone_project_subrepos` para traer al disco los hijos que falten, re-indexando si clono alguno. `registry.db` actua como manifest de sub-repos, asi que clonar el project paraguas + `/full-git-pull` reconstruye su arbol entero sin adivinar nombres. Los repos sueltos (sin project) siguen sin auto-clonarse: cada PC tiene el subset que le interesa.
|
|
||||||
|
|
||||||
## Capability growth log
|
|
||||||
|
|
||||||
- v1.1.0 (2026-06-10) — anade el paso 6: reclonado de sub-repos hijos de cada project via `clone_project_subrepos` tras `fn sync`, con re-index si clona alguno. Permite reconstruir el arbol completo de un project en un PC nuevo (issue 0171).
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user