Compare commits
605 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c7ff8d761 | |||
| 138f4b2713 | |||
| 25425a5fd6 | |||
| 89441539fa | |||
| 1d3d2f43b3 | |||
| 2effb688b0 | |||
| eb30074792 | |||
| f8efb7d177 | |||
| f428f2c82a | |||
| f36d091704 | |||
| 938853d268 | |||
| b31ea70771 | |||
| 2e5c630d38 | |||
| c52846d475 | |||
| b5affae68c | |||
| 5b4452b9fe | |||
| e0f8f3a068 | |||
| b21e7587ad | |||
| d4924f5cab | |||
| 853b3c0363 | |||
| f164ef230f | |||
| ff255c9a3c | |||
| 6c7f60fb6c | |||
| 75ac96a2d1 | |||
| da56085e74 | |||
| ecd864f2d3 | |||
| a91ef5aace | |||
| c2bdc586a4 | |||
| 61a46e4b21 | |||
| 3633d128aa | |||
| 892ff4f789 | |||
| 4388b54356 | |||
| b21adb40c9 | |||
| 6fd2e9d071 | |||
| d9ef4e54f4 | |||
| 2ea9206934 | |||
| 355bcac6c7 | |||
| 4eb4c1cf98 | |||
| 40aacac590 | |||
| e9bcbecd24 | |||
| 7eb7b3d0c8 | |||
| 61ec4c8a76 | |||
| a843f84a18 | |||
| 6f3c129a14 | |||
| bc270db723 | |||
| a3a263702b | |||
| 78c4f593a4 | |||
| 0f72cc8ad3 | |||
| 030e44b027 | |||
| ca2e5588cc | |||
| 5fb2269c00 | |||
| 5e6a974a5d | |||
| 5d2a14e50a | |||
| 212875ed0d | |||
| d6175964e4 | |||
| 5974484bd4 | |||
| 67fff0d677 | |||
| 890f641692 | |||
| ae5d27a5ec | |||
| 0ed949d235 | |||
| c438dc6916 | |||
| 4027aeaaf5 | |||
| 9ff0b3900c | |||
| ce9fa3b451 | |||
| e0cce972ea | |||
| 380a7a8f35 | |||
| 7ecbee1175 | |||
| fe39de8b22 | |||
| 951a77ec7f | |||
| 88119ee1b2 | |||
| 282c2e3ba8 | |||
| 950b994797 | |||
| 23f5f1c25f | |||
| be8a61e724 | |||
| 80f44cc89e | |||
| 188122812a | |||
| e2ecdc7533 | |||
| 7d82359a45 | |||
| 4e8b5af6c4 | |||
| cfdf515228 | |||
| d110aa40f9 | |||
| aec5d82011 | |||
| 88b5b27dc0 | |||
| 574b3f6823 | |||
| 552c40bc42 | |||
| 1702f12664 | |||
| a802f59f55 | |||
| ef60449e64 | |||
| c7904a7dcb | |||
| b4c28da2ba | |||
| 2297edf2ab | |||
| 9d0a1d99e8 | |||
| a396ee781a | |||
| 42c14fae59 | |||
| bd036cf3d4 | |||
| b5fc99c2fa | |||
| 401d8523b4 | |||
| b8dd7ea018 | |||
| da61fa4d47 | |||
| aca2348a20 | |||
| 4b9698b1b7 | |||
| bf78a8c9be | |||
| f851a63742 | |||
| 783a232104 | |||
| 5bd0862d8c | |||
| aceb10b672 | |||
| 416b15786d | |||
| 83c16d81b4 | |||
| 8618aa1be3 | |||
| 4d5a5bd3ea | |||
| 793481bb11 | |||
| c3fe61818e | |||
| 1ffedbf48d | |||
| c9bb356ffe | |||
| fc627930f9 | |||
| c2d156a8fb | |||
| c149ea161f | |||
| 7490336709 | |||
| 75714c9007 | |||
| 625569485f | |||
| c0e0ceadd8 | |||
| 32fc9c725b | |||
| c5f1b55a8e | |||
| 76dcb05bd3 | |||
| 046f3ab2cb | |||
| 5bee3d813f | |||
| 5194de3c04 | |||
| 1e8ade0ed4 | |||
| b4db4e4ef5 | |||
| dabc945eda | |||
| f5c651d1f1 | |||
| 3b3378cfc1 | |||
| e72d6364d4 | |||
| 7894a3d54a | |||
| ea899daa14 | |||
| 7b0384c804 | |||
| d115d8e830 | |||
| 07d06d5e7d | |||
| b04bb846c7 | |||
| 3de82c53c1 | |||
| 80e1076d99 | |||
| 46ac1ee031 | |||
| a028928bc7 | |||
| 71f55e0c17 | |||
| 81d8a7c95d | |||
| 6249e01419 | |||
| f102aba952 | |||
| 471e14caf7 | |||
| 563c6c7677 | |||
| bfc93d6997 | |||
| e6451b4912 | |||
| 1a3538785c | |||
| ada9b96765 | |||
| ddf45c6e41 | |||
| 7761740d53 | |||
| 0076870e99 | |||
| e1f41b263d | |||
| 829bd64aaa | |||
| dff0c0d2b7 | |||
| 2341a4a0ca | |||
| fea3cdad5d | |||
| fa5bcca155 | |||
| fa9b1d449d | |||
| 0a76353a13 | |||
| b10c545479 | |||
| 428b203e53 | |||
| e3a84b1635 | |||
| 6526da32dc | |||
| 0904409c59 | |||
| 25a809e3eb | |||
| 336051fef5 | |||
| c1396db84d | |||
| 1861205504 | |||
| 313f857c23 | |||
| 7644a50d00 | |||
| bbce9541c9 | |||
| 35312ea66e | |||
| 982a9f9a2b | |||
| 54cee13e8e | |||
| 777b071ef8 | |||
| ac11300335 | |||
| eb2078ac9a | |||
| b9ffc13caf | |||
| a6e3298f1b | |||
| 79b5f0b194 | |||
| 9a2fe5349b | |||
| 427262b892 | |||
| 97725e0641 | |||
| 32e58556fa | |||
| ebc012a5db | |||
| 2124f6be07 | |||
| 9904d5cd63 | |||
| 7c09255c8a | |||
| 6dd1fe07bd | |||
| 9b2745fa25 | |||
| cbe162630c | |||
| 63fbbb9cd0 | |||
| 6d70160919 | |||
| f906ffbec4 | |||
| 2aceccfd7e | |||
| cd445d8e1a | |||
| aeec68a552 | |||
| 70a996d654 | |||
| a03da106a6 | |||
| 33aace3686 | |||
| cbc0714c80 | |||
| 405ceacb0a | |||
| 13b12e2471 | |||
| ea0c00c4f8 | |||
| f4acd56694 | |||
| a5c721655e | |||
| 015cf290eb | |||
| cbf8fd911f | |||
| 503ccf30f4 | |||
| 1e25617ae1 | |||
| 9a4a86d317 | |||
| 1506c646a0 | |||
| 00d18d38b6 | |||
| 5044528175 | |||
| b5058c56fe | |||
| 13339abdb3 | |||
| ad254beeac | |||
| 7779ce7b46 | |||
| e70d0940a4 | |||
| dd3f73905f | |||
| b2d7b29e00 | |||
| 50c7452df3 | |||
| f62392179f | |||
| 41e66f60df | |||
| 78dc004371 | |||
| 6b8f0dc10e | |||
| 3699a2554d | |||
| 715074c2e8 | |||
| f858f3a9fc | |||
| 557ec658c9 | |||
| 6123c87483 | |||
| 0e27401e03 | |||
| 8c7311b70d | |||
| e4f86594f0 | |||
| 0adb5eeaa6 | |||
| 958189227d | |||
| 96fcd05511 | |||
| 08cc179ca8 | |||
| e356b7ac42 | |||
| 914372a517 | |||
| 8afdedf793 | |||
| 3e0d3d612a | |||
| 10e0b712ca | |||
| c1b1d8fbad | |||
| 0cbc08723d | |||
| 836ff02578 | |||
| 363fc07e74 | |||
| edcf029c6d | |||
| 02eed13913 | |||
| 5bbe45ca30 | |||
| 58c4bc5f05 | |||
| b837b8281a | |||
| 73e2f688b6 | |||
| 23333a03bd | |||
| 63031c26e0 | |||
| 1078b2d2e1 | |||
| 7a96f01a20 | |||
| bcdb51e1b8 | |||
| d3397fb17c | |||
| aa3bc6dad7 | |||
| 8f24dec23c | |||
| 4efbd61603 | |||
| 0b6b984dd3 | |||
| 071aa71a04 | |||
| 64330944e1 | |||
| 643d3a2abf | |||
| 118015062b | |||
| 1fa82447c2 | |||
| 281502ac92 | |||
| 3b662ac4c3 | |||
| 44e189c5cc | |||
| 580e4ba1fd | |||
| c8bb3e7044 | |||
| e72e526c64 | |||
| 66f5ca1a4f | |||
| b9810a88d4 | |||
| 76215765a7 | |||
| 4456d58abe | |||
| c4c49d1813 | |||
| d2a244a765 | |||
| d854bcbae9 | |||
| 4268b6f187 | |||
| 7159ee6dcb | |||
| e7ab06ee29 | |||
| 14cd888c2e | |||
| b208517e0f | |||
| adbe8c889c | |||
| e0e037c869 | |||
| f61d2834e8 | |||
| 24efee80e2 | |||
| d317900eea | |||
| da6a8b5e59 | |||
| 5d5b1d3fea | |||
| bae4f45268 | |||
| 439d3776a3 | |||
| b093c898a8 | |||
| d3d5af51f2 | |||
| 0c7a8393ab | |||
| e5d2201377 | |||
| a6941b55c4 | |||
| 087412d73a | |||
| 61a238b3fd | |||
| 07a653d97d | |||
| 24905eebc7 | |||
| f8a54942ee | |||
| efadd0c0a4 | |||
| 099be06409 | |||
| d1d20bdc04 | |||
| 83f64aa3e1 | |||
| 30c1289434 | |||
| a11a58dab0 | |||
| 7d598c7345 | |||
| ac65791663 | |||
| a209afa46b | |||
| 426a842e2b | |||
| 075088b7aa | |||
| 144b15f0ce | |||
| c2e4f6a9e1 | |||
| e3cfab3dc7 | |||
| e828af3ac1 | |||
| 42957d10f6 | |||
| 2b55a4823d | |||
| 8eebd1abce | |||
| 3f622561ce | |||
| 6f269949f1 | |||
| bdce314199 | |||
| e115c2e3fd | |||
| 4610bb4a99 | |||
| b828fd6acc | |||
| 9b1ca41c4d | |||
| 3008b56e76 | |||
| d7c3daaa6b | |||
| e39445dd55 | |||
| 652ee19f29 | |||
| 32fc008cae | |||
| 28ff9c3f79 | |||
| ab226d7137 | |||
| 8a96ebe412 | |||
| a402192e73 | |||
| f79f2e757c | |||
| ca7a5874e4 | |||
| ca07927d38 | |||
| d771c21a46 | |||
| 4aa3bc2d94 | |||
| 7bda65209c | |||
| c25f623355 | |||
| 3b37827d16 | |||
| bcfe87af7f | |||
| 526d7f4977 | |||
| 6d63f058de | |||
| e19bc09f4d | |||
| 7eab4a52e9 | |||
| 057f55c8a9 | |||
| 7b2004c649 | |||
| 6c83263d9b | |||
| f46fde3656 | |||
| 4601af88b5 | |||
| fc1ebb4967 | |||
| 07341aa89f | |||
| 4bc6d1bced | |||
| 5f282bedc5 | |||
| 3b2cd26a06 | |||
| 66e54f092d | |||
| 22994f14bf | |||
| e96f8eaf6a | |||
| 5bbdf2ff16 | |||
| 19722cb085 | |||
| 6fac9e1ef0 | |||
| 1ab39d105a | |||
| 2c1a956b32 | |||
| e35ec39c10 | |||
| 637bc8fd34 | |||
| 75157f528a | |||
| 77be3ce325 | |||
| 9634cfdb4a | |||
| 6cf006d87b | |||
| 4d25ebd070 | |||
| 0bd91f04b8 | |||
| 0bfe267501 | |||
| 4b420fb24b | |||
| 3262d058a6 | |||
| 69dcfec4eb | |||
| 31708d0942 | |||
| 53976c0c31 | |||
| 04c3ead5fa | |||
| e076901aa9 | |||
| d80f0412a8 | |||
| 9e8c0d66bb | |||
| df0227d4f2 | |||
| ae22787e60 | |||
| ab3069ae17 | |||
| 1675d2bb84 | |||
| 4ac93a0933 | |||
| ae0c4b7389 | |||
| 3d47e74ec7 | |||
| 0255207514 | |||
| 95826cb14f | |||
| ad8ce45865 | |||
| fee892f38e | |||
| dcefa13d2d | |||
| f8aa5e8072 | |||
| bb15b142bf | |||
| 28364cf212 | |||
| 295ab491a3 | |||
| debbdb86be | |||
| 58539f45c9 | |||
| 4299482b75 | |||
| 7081c3b4d1 | |||
| cb25bf6d1b | |||
| e5c17f89d7 | |||
| 9a28d08e38 | |||
| baa72e211e | |||
| 58fab5ad34 | |||
| 854f42ed6b | |||
| 1f59b5b4c3 | |||
| e74ed2e7d3 | |||
| 93ae1bd497 | |||
| b0038aab43 | |||
| 3bb0c7c6f2 | |||
| fb9a598aa9 | |||
| aed8d5b308 | |||
| 6aacdb0323 | |||
| 116bbb5e87 | |||
| 2fd6eeb95b | |||
| cdad1b5832 | |||
| 9747069182 | |||
| a97dd9d9f5 | |||
| 38ac24a0ed | |||
| 851732ce7d | |||
| ff7da29638 | |||
| 94be3b62e7 | |||
| df424f2de0 | |||
| 7670b671f2 | |||
| 4ef5c6e5b8 | |||
| af9ad48c9b | |||
| 327937124f | |||
| 3d515aa441 | |||
| 9b0e1f836d | |||
| ca15655268 | |||
| ab868bcea7 | |||
| cdcdb04d01 | |||
| 53deb8e9a8 | |||
| 38fbb222bf | |||
| f7a4f26cf0 | |||
| 59eea5d0f1 | |||
| de64da7bbc | |||
| bb9c3d1bc3 | |||
| 97512e9a48 | |||
| 8c1315b9d2 | |||
| 02226d61f6 | |||
| fd19cd222a | |||
| d8d72bb8d6 | |||
| 092f14eff0 | |||
| adfd5f63bb | |||
| 74c9e39b58 | |||
| ccd123e062 | |||
| 6877fcc70a | |||
| 3ebda4fcca | |||
| f9c1280964 | |||
| d2cbbdf600 | |||
| b717337b7b | |||
| 5b375cb822 | |||
| ee4e86ee2e | |||
| 5f8b71b528 | |||
| 1e2582b068 | |||
| ae33d02e75 | |||
| a06946e410 | |||
| 6f6bc714a9 | |||
| 54e62ecb91 | |||
| 1a3e77b0d5 | |||
| 8bc721d53b | |||
| 6d73e1b4be | |||
| f2753e6fff | |||
| 773bb3a523 | |||
| ae1c69eee0 | |||
| e76a5e5ab1 | |||
| 94efefc7bf | |||
| 8f45b40528 | |||
| ac9965220d | |||
| 1344e557e5 | |||
| 2721b9cc8f | |||
| d9414e4cba | |||
| 7aa7790931 | |||
| c3dfc9315f | |||
| cb96e85b69 | |||
| 0bdb3d72d7 | |||
| 4e8bbb0a88 | |||
| ffbcafa52d | |||
| d9b448a07b | |||
| 5c712bb974 | |||
| 29dee49a36 | |||
| f0d9ffa2bb | |||
| 132a7d3240 | |||
| dcd1843609 | |||
| d2ae672a23 | |||
| 76a607cf6f | |||
| a1b7e5e143 | |||
| fc8062bade | |||
| 7eef2544ab | |||
| 5aef738bc8 | |||
| 126a20ce07 | |||
| f3e62e8303 | |||
| 5965997c9e | |||
| 690e68a542 | |||
| c311623a76 | |||
| b1016ec845 | |||
| cbc4c5eafa | |||
| 89e443ab18 | |||
| f4932ce64c | |||
| 2d108c295a | |||
| 73a4c3a148 | |||
| 356dbcdadd | |||
| 1233efb31d | |||
| 513c2fb4a7 | |||
| 9b5c430f7f | |||
| 5f4f1f7508 | |||
| 9b4bb3aabc | |||
| 34ecadf5a4 | |||
| b55f120a00 | |||
| 89730911c2 | |||
| 3a3a8fd9a9 | |||
| 29b1c4cd8b | |||
| 131f860a94 | |||
| 9660a1c432 | |||
| 256e038cbe | |||
| b406b29074 | |||
| 834e910bcf | |||
| 7605a5760a | |||
| 9f4ac6de32 | |||
| a9f2c60e3d | |||
| 9fd0ca9cac | |||
| 63a9cb5273 | |||
| 25a392df48 | |||
| 9c0d24d3ef | |||
| bee3b0d946 | |||
| af039f6023 | |||
| f168795bda | |||
| bbd2cbff3e | |||
| 056ce6679c | |||
| eb9476503f | |||
| e7a00e221e | |||
| f61a4c4b18 | |||
| 40d6db312d | |||
| c5bb64160f | |||
| e89b78cc45 | |||
| e33b306225 | |||
| 9c859e96d8 | |||
| 10d17f9362 | |||
| 974f704214 | |||
| 0fa16a033c | |||
| f851988d6f | |||
| e9a8cbf20f | |||
| 846012c087 | |||
| 6d0d63cb23 | |||
| b220f8c0be | |||
| 4c52b41b7b | |||
| aea2131dcb | |||
| 1aaeec5090 | |||
| 7e3599e3ac | |||
| 29c8046d4e | |||
| 125ef74358 | |||
| 960f310bcf | |||
| 268a76602a | |||
| dc78d8fea3 | |||
| e02a950ee0 | |||
| a75170cbc6 | |||
| c33e907fef | |||
| d7f2c00d7b | |||
| 8f24157096 | |||
| 3b88857999 | |||
| 4d6ea9a910 | |||
| bb38eedfd1 | |||
| 9d3bfd2cd2 | |||
| 90693fb32f | |||
| b5a6711c64 | |||
| c72ae15429 | |||
| e3bb9c3b38 | |||
| 48caec5665 | |||
| 169cb0853b | |||
| add09c2faa | |||
| f748256c1d | |||
| 4b2240fbce | |||
| c2528c6ea4 | |||
| dd324b7785 | |||
| 9095fe8c65 | |||
| d6240022a4 | |||
| 405be396c8 | |||
| 2c15a0b5e9 | |||
| eaed99e52c | |||
| ac71d4b079 | |||
| f11f60d121 | |||
| e0573302af | |||
| 54be36dd63 | |||
| 72c572e1ea | |||
| 2bae07d1f5 | |||
| 9abaefeb00 | |||
| 05444f74d3 | |||
| 528a16cd5a | |||
| d549aa0314 | |||
| 3798e2d959 |
@@ -27,8 +27,6 @@ Cualquier decision tecnica que choque con estos objetivos esta mal priorizada. E
|
|||||||
|
|
||||||
**Reglas y convenciones:** ver `.claude/rules/INDEX.md`
|
**Reglas y convenciones:** ver `.claude/rules/INDEX.md`
|
||||||
|
|
||||||
**Slash commands:** `/commands` lista todos los slash commands del repo agrupados por namespace (global + projects). Project commands viven en `projects/<p>/.claude/commands/` y se exponen como `/<project>:<cmd>` via symlink. Ver `.claude/rules/project_commands.md`.
|
|
||||||
|
|
||||||
**Migraciones SQLite obligatorias:** todo cambio de schema en cualquier `.db` (apps, operations.db, registry.db) va en `migrations/NNN_*.sql` numerado. Aditivo, idempotente, aplicado al arrancar via `embed.FS`. Nunca borrar `.db` ni modificar migraciones existentes. Aplica retroactivamente. Ver `.claude/rules/db_migrations.md`.
|
**Migraciones SQLite obligatorias:** todo cambio de schema en cualquier `.db` (apps, operations.db, registry.db) va en `migrations/NNN_*.sql` numerado. Aditivo, idempotente, aplicado al arrancar via `embed.FS`. Nunca borrar `.db` ni modificar migraciones existentes. Aplica retroactivamente. Ver `.claude/rules/db_migrations.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -42,10 +42,10 @@ Opcionalmente:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Por id
|
# Por id
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, dir_path FROM apps WHERE id = '<app_id>';"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, dir_path FROM apps WHERE id = '<app_id>';"
|
||||||
|
|
||||||
# Por dir_path
|
# Por dir_path
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, dir_path FROM apps WHERE dir_path = '<dir>';"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, dir_path FROM apps WHERE dir_path = '<dir>';"
|
||||||
```
|
```
|
||||||
|
|
||||||
Si no hay match → reportar y abortar.
|
Si no hay match → reportar y abortar.
|
||||||
@@ -78,8 +78,8 @@ APP_DB="$APP_DIR/operations.db"
|
|||||||
|
|
||||||
# Si no existe, inicializar (aplica migraciones, incluida 005_e2e_runs)
|
# Si no existe, inicializar (aplica migraciones, incluida 005_e2e_runs)
|
||||||
if [ ! -f "$APP_DB" ]; then
|
if [ ! -f "$APP_DB" ]; then
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init "$APP_DIR"
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init "$APP_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Verificar tabla e2e_runs existe (migracion 005)
|
# Verificar tabla e2e_runs existe (migracion 005)
|
||||||
@@ -97,7 +97,7 @@ Hay dos caminos:
|
|||||||
**Camino A — invocar funcion del registry (preferido):**
|
**Camino A — invocar funcion del registry (preferido):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
./fn run e2e_run_checks_go_infra ...
|
./fn run e2e_run_checks_go_infra ...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -139,15 +139,15 @@ func main() {
|
|||||||
|
|
||||||
Ejecutar con:
|
Ejecutar con:
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
CGO_ENABLED=1 go run -tags fts5 /tmp/run_e2e_<id>.go /tmp/checks.yaml "$APP_DIR"
|
CGO_ENABLED=1 go run -tags fts5 /tmp/run_e2e_<id>.go /tmp/checks.yaml "$APP_DIR"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Eval assertions activas (si la app las tiene)
|
### 5. Eval assertions activas (si la app las tiene)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops assertion eval --db "$APP_DB"
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval --db "$APP_DB"
|
||||||
```
|
```
|
||||||
|
|
||||||
Capturar fallos como warning checks adicionales.
|
Capturar fallos como warning checks adicionales.
|
||||||
|
|||||||
@@ -15,20 +15,20 @@ Eres el agente constructor del fn_registry. Tu rol es crear funciones, tests y t
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Buscar si ya existe algo similar (OBLIGATORIO antes de crear)
|
# Buscar si ya existe algo similar (OBLIGATORIO antes de crear)
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||||
|
|
||||||
# Buscar tipos existentes
|
# Buscar tipos existentes
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||||
|
|
||||||
# Ver funciones de un dominio
|
# Ver funciones de un dominio
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, purity, signature FROM functions WHERE domain = 'DOMINIO' ORDER BY name;"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, purity, signature FROM functions WHERE domain = 'DOMINIO' ORDER BY name;"
|
||||||
|
|
||||||
# Ver tipos de un dominio
|
# Ver tipos de un dominio
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'DOMINIO';"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'DOMINIO';"
|
||||||
|
|
||||||
# Verificar que un ID referenciado existe
|
# Verificar que un ID referenciado existe
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM functions WHERE id = 'ID_AQUI';"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = 'ID_AQUI';"
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM types WHERE id = 'ID_AQUI';"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM types WHERE id = 'ID_AQUI';"
|
||||||
```
|
```
|
||||||
|
|
||||||
Si algo similar ya existe, informa al usuario y sugiere mejorarlo en vez de duplicarlo.
|
Si algo similar ya existe, informa al usuario y sugiere mejorarlo en vez de duplicarlo.
|
||||||
@@ -39,13 +39,13 @@ Antes de implementar logica desde cero, busca funciones del registry que puedas
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Buscar funciones reutilizables por lo que hacen (ampliar con OR y prefijos)
|
# Buscar funciones reutilizables por lo que hacen (ampliar con OR y prefijos)
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, purity, signature, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:filter* OR description:map* OR description:transform*') ORDER BY name;"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, purity, signature, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:filter* OR description:map* OR description:transform*') ORDER BY name;"
|
||||||
|
|
||||||
# Ver que retorna y que tipos usa una funcion candidata
|
# Ver que retorna y que tipos usa una funcion candidata
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, signature, returns, uses_types FROM functions WHERE id = 'ID_CANDIDATO';"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature, returns, uses_types FROM functions WHERE id = 'ID_CANDIDATO';"
|
||||||
|
|
||||||
# Buscar funciones puras del mismo dominio (las mas componibles)
|
# Buscar funciones puras del mismo dominio (las mas componibles)
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, signature FROM functions WHERE domain = 'DOMINIO' AND purity = 'pure' ORDER BY name;"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature FROM functions WHERE domain = 'DOMINIO' AND purity = 'pure' ORDER BY name;"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Criterios de reutilizacion:**
|
**Criterios de reutilizacion:**
|
||||||
@@ -78,38 +78,38 @@ Esto acelera la construccion y fortalece el grafo de dependencias del registry.
|
|||||||
| `bash` | `bash/functions/{domain}/{name}.sh` | `bash/functions/pipelines/{name}.sh` | *(no aplica)* |
|
| `bash` | `bash/functions/{domain}/{name}.sh` | `bash/functions/pipelines/{name}.sh` | *(no aplica)* |
|
||||||
| `typescript` | `frontend/functions/{domain}/{name}.ts` | *(no aplica)* | `frontend/types/{domain}/{name}.ts` |
|
| `typescript` | `frontend/functions/{domain}/{name}.ts` | *(no aplica)* | `frontend/types/{domain}/{name}.ts` |
|
||||||
|
|
||||||
**Ruta absoluta donde crear el archivo** = `$HOME/fn_registry/` + `file_path` del .md.
|
**Ruta absoluta donde crear el archivo** = `/home/lucas/fn_registry/` + `file_path` del .md.
|
||||||
|
|
||||||
Ejemplo: si `lang: bash` y `domain: infra`, el archivo va en:
|
Ejemplo: si `lang: bash` y `domain: infra`, el archivo va en:
|
||||||
- `$HOME/fn_registry/bash/functions/infra/{name}.sh` + `.md`
|
- `/home/lucas/fn_registry/bash/functions/infra/{name}.sh` + `.md`
|
||||||
- **NUNCA** en `$HOME/fn_registry/functions/infra/{name}.sh`
|
- **NUNCA** en `/home/lucas/fn_registry/functions/infra/{name}.sh`
|
||||||
|
|
||||||
### Estructura detallada
|
### Estructura detallada
|
||||||
|
|
||||||
**Go** (carpeta raiz: `functions/` y `types/`)
|
**Go** (carpeta raiz: `functions/` y `types/`)
|
||||||
- Funciones: `$HOME/fn_registry/functions/{domain}/{name}.go` + `.md`
|
- Funciones: `/home/lucas/fn_registry/functions/{domain}/{name}.go` + `.md`
|
||||||
- Tests: `$HOME/fn_registry/functions/{domain}/{name}_test.go`
|
- Tests: `/home/lucas/fn_registry/functions/{domain}/{name}_test.go`
|
||||||
- Tipos: `$HOME/fn_registry/functions/{domain}/{name}.go` (codigo, mismo paquete Go) + `$HOME/fn_registry/types/{domain}/{name}.md` (metadata con file_path apuntando a functions/)
|
- Tipos: `/home/lucas/fn_registry/functions/{domain}/{name}.go` (codigo, mismo paquete Go) + `/home/lucas/fn_registry/types/{domain}/{name}.md` (metadata con file_path apuntando a functions/)
|
||||||
- Pipelines: `$HOME/fn_registry/functions/pipelines/{name}.go` + `.md`
|
- Pipelines: `/home/lucas/fn_registry/functions/pipelines/{name}.go` + `.md`
|
||||||
- Paquete Go = nombre del directorio (core, finance, datascience, cybersecurity, infra, shell, tui, io)
|
- Paquete Go = nombre del directorio (core, finance, datascience, cybersecurity, infra, shell, tui, io)
|
||||||
|
|
||||||
**Python** (carpeta raiz: `python/`)
|
**Python** (carpeta raiz: `python/`)
|
||||||
- Funciones: `$HOME/fn_registry/python/functions/{domain}/{name}.py` + `.md`
|
- Funciones: `/home/lucas/fn_registry/python/functions/{domain}/{name}.py` + `.md`
|
||||||
- Tests: `$HOME/fn_registry/python/functions/{domain}/{name}_test.py`
|
- Tests: `/home/lucas/fn_registry/python/functions/{domain}/{name}_test.py`
|
||||||
- Tipos: `$HOME/fn_registry/python/types/{domain}/{name}.py` + `.md`
|
- Tipos: `/home/lucas/fn_registry/python/types/{domain}/{name}.py` + `.md`
|
||||||
- Pipelines: `$HOME/fn_registry/python/functions/pipelines/{name}.py` + `.md`
|
- Pipelines: `/home/lucas/fn_registry/python/functions/pipelines/{name}.py` + `.md`
|
||||||
|
|
||||||
**Bash** (carpeta raiz: `bash/`)
|
**Bash** (carpeta raiz: `bash/`)
|
||||||
- Funciones: `$HOME/fn_registry/bash/functions/{domain}/{name}.sh` + `.md`
|
- Funciones: `/home/lucas/fn_registry/bash/functions/{domain}/{name}.sh` + `.md`
|
||||||
- Tests: `$HOME/fn_registry/bash/functions/{domain}/{name}_test.sh`
|
- Tests: `/home/lucas/fn_registry/bash/functions/{domain}/{name}_test.sh`
|
||||||
- Pipelines: `$HOME/fn_registry/bash/functions/pipelines/{name}.sh` + `.md`
|
- Pipelines: `/home/lucas/fn_registry/bash/functions/pipelines/{name}.sh` + `.md`
|
||||||
- Tipos: Bash no tiene tipos — usar solo `uses_types` para referenciar tipos de otros lenguajes
|
- Tipos: Bash no tiene tipos — usar solo `uses_types` para referenciar tipos de otros lenguajes
|
||||||
|
|
||||||
**TypeScript** (carpeta raiz: `frontend/`)
|
**TypeScript** (carpeta raiz: `frontend/`)
|
||||||
- Funciones puras: `$HOME/fn_registry/frontend/functions/core/{name}.ts` + `.md`
|
- Funciones puras: `/home/lucas/fn_registry/frontend/functions/core/{name}.ts` + `.md`
|
||||||
- Componentes React: `$HOME/fn_registry/frontend/functions/ui/{name}.tsx` + `.md`
|
- Componentes React: `/home/lucas/fn_registry/frontend/functions/ui/{name}.tsx` + `.md`
|
||||||
- Tests: junto al archivo, `{name}.test.ts` o `{name}.test.tsx`
|
- Tests: junto al archivo, `{name}.test.ts` o `{name}.test.tsx`
|
||||||
- Tipos: `$HOME/fn_registry/frontend/types/{domain}/{name}.ts` + `.md`
|
- Tipos: `/home/lucas/fn_registry/frontend/types/{domain}/{name}.ts` + `.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -591,7 +591,7 @@ Documentar completamente el .md igualmente.
|
|||||||
1. **BUSCAR** en registry.db con FTS5 si existe algo similar
|
1. **BUSCAR** en registry.db con FTS5 si existe algo similar
|
||||||
2. **VALIDAR** que los IDs referenciados (uses_functions, uses_types, returns, error_type) existen en la BD
|
2. **VALIDAR** que los IDs referenciados (uses_functions, uses_types, returns, error_type) existen en la BD
|
||||||
3. **CREAR** los archivos en la carpeta raiz correcta segun el lenguaje (ver tabla REGLA CRITICA): Go en `functions/`, Python en `python/functions/`, Bash en `bash/functions/`, TypeScript en `frontend/functions/`
|
3. **CREAR** los archivos en la carpeta raiz correcta segun el lenguaje (ver tabla REGLA CRITICA): Go en `functions/`, Python en `python/functions/`, Bash en `bash/functions/`, TypeScript en `frontend/functions/`
|
||||||
4. **INDEXAR** ejecutando: `cd $HOME/fn_registry && CGO_ENABLED=1 ./fn index`
|
4. **INDEXAR** ejecutando: `cd /home/lucas/fn_registry && CGO_ENABLED=1 ./fn index`
|
||||||
5. **VERIFICAR** con: `./fn show {id}` que se indexo correctamente
|
5. **VERIFICAR** con: `./fn show {id}` que se indexo correctamente
|
||||||
6. Si hay errores de validacion, corregirlos y re-indexar
|
6. Si hay errores de validacion, corregirlos y re-indexar
|
||||||
|
|
||||||
@@ -600,10 +600,10 @@ Documentar completamente el .md igualmente.
|
|||||||
1. **LEER** la funcion existente (codigo + .md) desde la BD: `sqlite3 registry.db "SELECT code, signature FROM functions WHERE id = '...'"`
|
1. **LEER** la funcion existente (codigo + .md) desde la BD: `sqlite3 registry.db "SELECT code, signature FROM functions WHERE id = '...'"`
|
||||||
2. **CREAR** el archivo de test
|
2. **CREAR** el archivo de test
|
||||||
3. **EJECUTAR** los tests:
|
3. **EJECUTAR** los tests:
|
||||||
- Go: `cd $HOME/fn_registry && CGO_ENABLED=1 go test -tags fts5 -run TestNombre ./functions/{domain}/`
|
- Go: `cd /home/lucas/fn_registry && CGO_ENABLED=1 go test -tags fts5 -run TestNombre ./functions/{domain}/`
|
||||||
- Python: `cd $HOME/fn_registry/python && python -m pytest functions/{domain}/{name}_test.py`
|
- Python: `cd /home/lucas/fn_registry/python && python -m pytest functions/{domain}/{name}_test.py`
|
||||||
- TypeScript: desde `frontend/`, ejecutar con el test runner configurado
|
- TypeScript: desde `frontend/`, ejecutar con el test runner configurado
|
||||||
- Bash: `cd $HOME/fn_registry && bash bash/functions/{domain}/{name}_test.sh`
|
- Bash: `cd /home/lucas/fn_registry && bash bash/functions/{domain}/{name}_test.sh`
|
||||||
4. **ACTUALIZAR** el .md con `tested: true`, `tests: [...]` y `test_file_path`
|
4. **ACTUALIZAR** el .md con `tested: true`, `tests: [...]` y `test_file_path`
|
||||||
5. **RE-INDEXAR** y verificar
|
5. **RE-INDEXAR** y verificar
|
||||||
|
|
||||||
@@ -620,19 +620,19 @@ Documentar completamente el .md igualmente.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Compilar CLI (necesario si se modifico codigo del CLI)
|
# Compilar CLI (necesario si se modifico codigo del CLI)
|
||||||
cd $HOME/fn_registry && CGO_ENABLED=1 go build -tags fts5 -o fn ./cmd/fn/
|
cd /home/lucas/fn_registry && CGO_ENABLED=1 go build -tags fts5 -o fn ./cmd/fn/
|
||||||
|
|
||||||
# Indexar registry
|
# Indexar registry
|
||||||
cd $HOME/fn_registry && CGO_ENABLED=1 ./fn index
|
cd /home/lucas/fn_registry && CGO_ENABLED=1 ./fn index
|
||||||
|
|
||||||
# Tests Go de un dominio
|
# Tests Go de un dominio
|
||||||
cd $HOME/fn_registry && CGO_ENABLED=1 go test -tags fts5 ./functions/{domain}/
|
cd /home/lucas/fn_registry && CGO_ENABLED=1 go test -tags fts5 ./functions/{domain}/
|
||||||
|
|
||||||
# Tests Go de todo el registry
|
# Tests Go de todo el registry
|
||||||
cd $HOME/fn_registry && CGO_ENABLED=1 go test -tags fts5 ./...
|
cd /home/lucas/fn_registry && CGO_ENABLED=1 go test -tags fts5 ./...
|
||||||
|
|
||||||
# Mostrar funcion indexada
|
# Mostrar funcion indexada
|
||||||
cd $HOME/fn_registry && ./fn show {id}
|
cd /home/lucas/fn_registry && ./fn show {id}
|
||||||
```
|
```
|
||||||
|
|
||||||
### fn run — Ejecutar funciones y pipelines directamente
|
### fn run — Ejecutar funciones y pipelines directamente
|
||||||
@@ -640,7 +640,7 @@ cd $HOME/fn_registry && ./fn show {id}
|
|||||||
Despues de crear/indexar, puedes ejecutar directamente con `fn run`:
|
Despues de crear/indexar, puedes ejecutar directamente con `fn run`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
|
|
||||||
# Go pipeline (go run . en su directorio)
|
# Go pipeline (go run . en su directorio)
|
||||||
./fn run init_metabase --project test
|
./fn run init_metabase --project test
|
||||||
@@ -729,7 +729,7 @@ Peticion: "Crea una funcion que calcule la media de un slice de float64"
|
|||||||
|
|
||||||
### Paso 1: Buscar en BD
|
### Paso 1: Buscar en BD
|
||||||
```bash
|
```bash
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:mean* OR name:average* OR description:media* OR description:average*') ORDER BY name;"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:mean* OR name:average* OR description:media* OR description:average*') ORDER BY name;"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Paso 2: Crear archivos
|
### Paso 2: Crear archivos
|
||||||
@@ -823,6 +823,6 @@ func TestMean(t *testing.T) {
|
|||||||
|
|
||||||
### Paso 3: Indexar y verificar
|
### Paso 3: Indexar y verificar
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry && CGO_ENABLED=1 ./fn index
|
cd /home/lucas/fn_registry && CGO_ENABLED=1 ./fn index
|
||||||
./fn show mean_go_core
|
./fn show mean_go_core
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -35,22 +35,22 @@ Las apps estan indexadas en registry.db con toda la metadata necesaria para ejec
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ver todas las apps disponibles
|
# Ver todas las apps disponibles
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, lang, domain, description, entry_point, dir_path FROM apps ORDER BY name;"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, domain, description, entry_point, dir_path FROM apps ORDER BY name;"
|
||||||
|
|
||||||
# Ver app completa con dependencias y framework
|
# Ver app completa con dependencias y framework
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, lang, entry_point, dir_path, uses_functions, uses_types, framework, tags FROM apps WHERE id = 'APP_ID';"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, entry_point, dir_path, uses_functions, uses_types, framework, tags FROM apps WHERE id = 'APP_ID';"
|
||||||
|
|
||||||
# Buscar apps por FTS (nombre, descripcion, tags, documentacion)
|
# Buscar apps por FTS (nombre, descripcion, tags, documentacion)
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, lang, description FROM apps WHERE id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, description FROM apps WHERE id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||||
|
|
||||||
# Apps de un dominio
|
# Apps de un dominio
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, description, entry_point FROM apps WHERE domain = 'DOMINIO';"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, description, entry_point FROM apps WHERE domain = 'DOMINIO';"
|
||||||
|
|
||||||
# Apps que usan una funcion especifica
|
# Apps que usan una funcion especifica
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name FROM apps WHERE uses_functions LIKE '%funcion_id%';"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name FROM apps WHERE uses_functions LIKE '%funcion_id%';"
|
||||||
|
|
||||||
# Ver documentacion completa de una app
|
# Ver documentacion completa de una app
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT documentation, notes FROM apps WHERE id = 'APP_ID';"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT documentation, notes FROM apps WHERE id = 'APP_ID';"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Campos clave de apps para ejecucion:**
|
**Campos clave de apps para ejecucion:**
|
||||||
@@ -65,19 +65,19 @@ sqlite3 $HOME/fn_registry/registry.db "SELECT documentation, notes FROM apps WHE
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ver pipeline/funcion completa
|
# Ver pipeline/funcion completa
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, signature, description, uses_functions, uses_types FROM functions WHERE id = 'ID_AQUI';"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, signature, description, uses_functions, uses_types FROM functions WHERE id = 'ID_AQUI';"
|
||||||
|
|
||||||
# Ver codigo de la funcion
|
# Ver codigo de la funcion
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT code FROM functions WHERE id = 'ID_AQUI';"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT code FROM functions WHERE id = 'ID_AQUI';"
|
||||||
|
|
||||||
# Pipelines disponibles (con tag launcher para TUI)
|
# Pipelines disponibles (con tag launcher para TUI)
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, signature, description FROM functions WHERE kind = 'pipeline' ORDER BY name;"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature, description FROM functions WHERE kind = 'pipeline' ORDER BY name;"
|
||||||
|
|
||||||
# Funciones impuras ejecutables directamente
|
# Funciones impuras ejecutables directamente
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, signature, description FROM functions WHERE purity = 'impure' AND kind = 'function' ORDER BY name;"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature, description FROM functions WHERE purity = 'impure' AND kind = 'function' ORDER BY name;"
|
||||||
|
|
||||||
# Buscar por FTS
|
# Buscar por FTS
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Usar contexto de apps para ejecucion inteligente
|
### Usar contexto de apps para ejecucion inteligente
|
||||||
@@ -98,10 +98,10 @@ Cuando te pidan ejecutar una app, sigue este flujo:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Desde la raiz del registry
|
# Desde la raiz del registry
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
|
|
||||||
# Opcion A: Usar el CLI
|
# Opcion A: Usar el CLI
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||||
|
|
||||||
# Opcion B: Copiar template directamente
|
# Opcion B: Copiar template directamente
|
||||||
cp fn_operations/project_template/operations.db apps/{app_name}/operations.db
|
cp fn_operations/project_template/operations.db apps/{app_name}/operations.db
|
||||||
@@ -221,10 +221,10 @@ Las entities representan los datos concretos del proyecto. Las relations documen
|
|||||||
### Crear entities (datos que el pipeline consume o produce)
|
### Crear entities (datos que el pipeline consume o produce)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
|
|
||||||
# Entity de entrada
|
# Entity de entrada
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops entity add \
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity add \
|
||||||
--db apps/{app_name}/operations.db \
|
--db apps/{app_name}/operations.db \
|
||||||
--name "btc_ticks" \
|
--name "btc_ticks" \
|
||||||
--type-ref "tick_go_finance" \
|
--type-ref "tick_go_finance" \
|
||||||
@@ -235,7 +235,7 @@ FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops entity add \
|
|||||||
--metadata '{"pair":"BTCUSDT","exchange":"binance"}'
|
--metadata '{"pair":"BTCUSDT","exchange":"binance"}'
|
||||||
|
|
||||||
# Entity de salida
|
# Entity de salida
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops entity add \
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity add \
|
||||||
--db apps/{app_name}/operations.db \
|
--db apps/{app_name}/operations.db \
|
||||||
--name "btc_ohlcv_5m" \
|
--name "btc_ohlcv_5m" \
|
||||||
--type-ref "ohlcv_go_finance" \
|
--type-ref "ohlcv_go_finance" \
|
||||||
@@ -249,7 +249,7 @@ FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops entity add \
|
|||||||
### Crear relations (como se conectan entities)
|
### Crear relations (como se conectan entities)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops relation add \
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops relation add \
|
||||||
--db apps/{app_name}/operations.db \
|
--db apps/{app_name}/operations.db \
|
||||||
--name "ticks_to_ohlcv" \
|
--name "ticks_to_ohlcv" \
|
||||||
--from-entity "{entity_id}" \
|
--from-entity "{entity_id}" \
|
||||||
@@ -262,13 +262,13 @@ FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops relation add \
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Listar entities
|
# Listar entities
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops entity list --db apps/{app_name}/operations.db
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity list --db apps/{app_name}/operations.db
|
||||||
|
|
||||||
# Listar relations
|
# Listar relations
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops relation list --db apps/{app_name}/operations.db
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops relation list --db apps/{app_name}/operations.db
|
||||||
|
|
||||||
# Ver grafo ASCII
|
# Ver grafo ASCII
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops graph --db apps/{app_name}/operations.db
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops graph --db apps/{app_name}/operations.db
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -280,7 +280,7 @@ FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops graph --db apps/{app_name}/operation
|
|||||||
`fn run` despacha automaticamente segun el lenguaje y tipo:
|
`fn run` despacha automaticamente segun el lenguaje y tipo:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
|
|
||||||
# Go pipeline (go run . en su directorio)
|
# Go pipeline (go run . en su directorio)
|
||||||
./fn run init_metabase --project test
|
./fn run init_metabase --project test
|
||||||
@@ -318,13 +318,13 @@ Para apps con su propio main.go/main.py/main.sh:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Go app
|
# Go app
|
||||||
cd $HOME/fn_registry/apps/{app_name} && CGO_ENABLED=1 go run -tags fts5 . [flags]
|
cd /home/lucas/fn_registry/apps/{app_name} && CGO_ENABLED=1 go run -tags fts5 . [flags]
|
||||||
|
|
||||||
# Python app
|
# Python app
|
||||||
cd $HOME/fn_registry/apps/{app_name} && python3 main.py [args]
|
cd /home/lucas/fn_registry/apps/{app_name} && python3 main.py [args]
|
||||||
|
|
||||||
# Bash app
|
# Bash app
|
||||||
cd $HOME/fn_registry/apps/{app_name} && bash main.sh [args]
|
cd /home/lucas/fn_registry/apps/{app_name} && bash main.sh [args]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Capturar metricas de ejecucion
|
### Capturar metricas de ejecucion
|
||||||
@@ -340,7 +340,7 @@ Al ejecutar, siempre captura:
|
|||||||
```bash
|
```bash
|
||||||
# Ejemplo: ejecutar con captura de tiempo
|
# Ejemplo: ejecutar con captura de tiempo
|
||||||
START=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
START=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
OUTPUT=$(cd $HOME/fn_registry/apps/{app_name} && CGO_ENABLED=1 go run -tags fts5 . 2>&1)
|
OUTPUT=$(cd /home/lucas/fn_registry/apps/{app_name} && CGO_ENABLED=1 go run -tags fts5 . 2>&1)
|
||||||
EXIT_CODE=$?
|
EXIT_CODE=$?
|
||||||
END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
|
||||||
@@ -362,7 +362,7 @@ echo "Status: $STATUS | Start: $START | End: $END"
|
|||||||
### Via CLI
|
### Via CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution add \
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
|
||||||
--db apps/{app_name}/operations.db \
|
--db apps/{app_name}/operations.db \
|
||||||
--pipeline-id "tick_to_ohlcv_go_finance" \
|
--pipeline-id "tick_to_ohlcv_go_finance" \
|
||||||
--relation-id "{relation_id}" \
|
--relation-id "{relation_id}" \
|
||||||
@@ -396,16 +396,16 @@ sqlite3 apps/{app_name}/operations.db "INSERT INTO executions (id, pipeline_id,
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Listar todas
|
# Listar todas
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db
|
||||||
|
|
||||||
# Por pipeline
|
# Por pipeline
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db --pipeline-id "ID"
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db --pipeline-id "ID"
|
||||||
|
|
||||||
# Por status
|
# Por status
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db --status failure
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db --status failure
|
||||||
|
|
||||||
# Detalle de una ejecucion
|
# Detalle de una ejecucion
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution show --db apps/{app_name}/operations.db --id "EXEC_ID"
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution show --db apps/{app_name}/operations.db --id "EXEC_ID"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -441,12 +441,12 @@ Si hay assertions definidas sobre las entities afectadas, evaluarlas para verifi
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Evaluar assertions de una entity
|
# Evaluar assertions de una entity
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops assertion eval \
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval \
|
||||||
--db apps/{app_name}/operations.db \
|
--db apps/{app_name}/operations.db \
|
||||||
--entity-id "ENTITY_ID"
|
--entity-id "ENTITY_ID"
|
||||||
|
|
||||||
# Evaluar Y reaccionar (actualiza status de entities, crea proposals si hay fallos criticos)
|
# Evaluar Y reaccionar (actualiza status de entities, crea proposals si hay fallos criticos)
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops assertion eval \
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval \
|
||||||
--db apps/{app_name}/operations.db \
|
--db apps/{app_name}/operations.db \
|
||||||
--entity-id "ENTITY_ID" \
|
--entity-id "ENTITY_ID" \
|
||||||
--react
|
--react
|
||||||
@@ -467,10 +467,10 @@ Cuando el usuario pide ejecutar algo que aun no tiene app:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Crear directorio
|
# 1. Crear directorio
|
||||||
mkdir -p $HOME/fn_registry/apps/{app_name}
|
mkdir -p /home/lucas/fn_registry/apps/{app_name}
|
||||||
|
|
||||||
# 2. Crear app.md (OBLIGATORIO)
|
# 2. Crear app.md (OBLIGATORIO)
|
||||||
cat > $HOME/fn_registry/apps/{app_name}/app.md << 'MDEOF'
|
cat > /home/lucas/fn_registry/apps/{app_name}/app.md << 'MDEOF'
|
||||||
---
|
---
|
||||||
name: {app_name}
|
name: {app_name}
|
||||||
lang: go
|
lang: go
|
||||||
@@ -490,7 +490,7 @@ dir_path: "apps/{app_name}"
|
|||||||
MDEOF
|
MDEOF
|
||||||
|
|
||||||
# 3. Crear .gitignore
|
# 3. Crear .gitignore
|
||||||
cat > $HOME/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
|
cat > /home/lucas/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
|
||||||
operations.db
|
operations.db
|
||||||
operations.db-wal
|
operations.db-wal
|
||||||
operations.db-shm
|
operations.db-shm
|
||||||
@@ -499,7 +499,7 @@ build/
|
|||||||
GIEOF
|
GIEOF
|
||||||
|
|
||||||
# 4. Inicializar modulo Go
|
# 4. Inicializar modulo Go
|
||||||
cd $HOME/fn_registry/apps/{app_name}
|
cd /home/lucas/fn_registry/apps/{app_name}
|
||||||
go mod init fn_registry/apps/{app_name}
|
go mod init fn_registry/apps/{app_name}
|
||||||
|
|
||||||
# 5. Crear main.go minimo
|
# 5. Crear main.go minimo
|
||||||
@@ -523,8 +523,8 @@ func main() {
|
|||||||
GOEOF
|
GOEOF
|
||||||
|
|
||||||
# 6. Inicializar operations.db
|
# 6. Inicializar operations.db
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||||
|
|
||||||
# 7. Indexar en registry.db
|
# 7. Indexar en registry.db
|
||||||
./fn index
|
./fn index
|
||||||
@@ -534,10 +534,10 @@ FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Crear directorio
|
# 1. Crear directorio
|
||||||
mkdir -p $HOME/fn_registry/apps/{app_name}
|
mkdir -p /home/lucas/fn_registry/apps/{app_name}
|
||||||
|
|
||||||
# 2. Crear app.md (OBLIGATORIO)
|
# 2. Crear app.md (OBLIGATORIO)
|
||||||
cat > $HOME/fn_registry/apps/{app_name}/app.md << 'MDEOF'
|
cat > /home/lucas/fn_registry/apps/{app_name}/app.md << 'MDEOF'
|
||||||
---
|
---
|
||||||
name: {app_name}
|
name: {app_name}
|
||||||
lang: py
|
lang: py
|
||||||
@@ -557,7 +557,7 @@ dir_path: "apps/{app_name}"
|
|||||||
MDEOF
|
MDEOF
|
||||||
|
|
||||||
# 3. Crear .gitignore
|
# 3. Crear .gitignore
|
||||||
cat > $HOME/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
|
cat > /home/lucas/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
|
||||||
operations.db
|
operations.db
|
||||||
operations.db-wal
|
operations.db-wal
|
||||||
operations.db-shm
|
operations.db-shm
|
||||||
@@ -565,7 +565,7 @@ __pycache__/
|
|||||||
GIEOF
|
GIEOF
|
||||||
|
|
||||||
# 4. Crear main.py
|
# 4. Crear main.py
|
||||||
cat > $HOME/fn_registry/apps/{app_name}/main.py << 'PYEOF'
|
cat > /home/lucas/fn_registry/apps/{app_name}/main.py << 'PYEOF'
|
||||||
"""Pipeline executor."""
|
"""Pipeline executor."""
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -584,8 +584,8 @@ if __name__ == "__main__":
|
|||||||
PYEOF
|
PYEOF
|
||||||
|
|
||||||
# 5. Inicializar operations.db
|
# 5. Inicializar operations.db
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||||
|
|
||||||
# 6. Indexar en registry.db
|
# 6. Indexar en registry.db
|
||||||
./fn index
|
./fn index
|
||||||
@@ -595,10 +595,10 @@ FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Crear directorio
|
# 1. Crear directorio
|
||||||
mkdir -p $HOME/fn_registry/apps/{app_name}
|
mkdir -p /home/lucas/fn_registry/apps/{app_name}
|
||||||
|
|
||||||
# 2. Crear app.md (OBLIGATORIO)
|
# 2. Crear app.md (OBLIGATORIO)
|
||||||
cat > $HOME/fn_registry/apps/{app_name}/app.md << 'MDEOF'
|
cat > /home/lucas/fn_registry/apps/{app_name}/app.md << 'MDEOF'
|
||||||
---
|
---
|
||||||
name: {app_name}
|
name: {app_name}
|
||||||
lang: bash
|
lang: bash
|
||||||
@@ -618,14 +618,14 @@ dir_path: "apps/{app_name}"
|
|||||||
MDEOF
|
MDEOF
|
||||||
|
|
||||||
# 3. Crear .gitignore
|
# 3. Crear .gitignore
|
||||||
cat > $HOME/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
|
cat > /home/lucas/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
|
||||||
operations.db
|
operations.db
|
||||||
operations.db-wal
|
operations.db-wal
|
||||||
operations.db-shm
|
operations.db-shm
|
||||||
GIEOF
|
GIEOF
|
||||||
|
|
||||||
# 4. Crear main.sh
|
# 4. Crear main.sh
|
||||||
cat > $HOME/fn_registry/apps/{app_name}/main.sh << 'SHEOF'
|
cat > /home/lucas/fn_registry/apps/{app_name}/main.sh << 'SHEOF'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Pipeline executor: {app_name}
|
# Pipeline executor: {app_name}
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -650,11 +650,11 @@ main() {
|
|||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
SHEOF
|
SHEOF
|
||||||
chmod +x $HOME/fn_registry/apps/{app_name}/main.sh
|
chmod +x /home/lucas/fn_registry/apps/{app_name}/main.sh
|
||||||
|
|
||||||
# 5. Inicializar operations.db
|
# 5. Inicializar operations.db
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||||
|
|
||||||
# 6. Indexar en registry.db
|
# 6. Indexar en registry.db
|
||||||
./fn index
|
./fn index
|
||||||
@@ -669,7 +669,7 @@ Este patron captura todo lo necesario para registrar la ejecucion:
|
|||||||
### Go
|
### Go
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
APP_DIR="$HOME/fn_registry/apps/{app_name}"
|
APP_DIR="/home/lucas/fn_registry/apps/{app_name}"
|
||||||
OPS_DB="$APP_DIR/operations.db"
|
OPS_DB="$APP_DIR/operations.db"
|
||||||
PIPELINE_ID="{pipeline_id}"
|
PIPELINE_ID="{pipeline_id}"
|
||||||
RELATION_ID="{relation_id}" # vacio si no aplica
|
RELATION_ID="{relation_id}" # vacio si no aplica
|
||||||
@@ -689,8 +689,8 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Registrar ejecucion
|
# Registrar ejecucion
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution add \
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
|
||||||
--db "$OPS_DB" \
|
--db "$OPS_DB" \
|
||||||
--pipeline-id "$PIPELINE_ID" \
|
--pipeline-id "$PIPELINE_ID" \
|
||||||
--status "$STATUS" \
|
--status "$STATUS" \
|
||||||
@@ -704,7 +704,7 @@ rm -f "$STDOUT_FILE" "$STDERR_FILE"
|
|||||||
### Python
|
### Python
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
APP_DIR="$HOME/fn_registry/apps/{app_name}"
|
APP_DIR="/home/lucas/fn_registry/apps/{app_name}"
|
||||||
OPS_DB="$APP_DIR/operations.db"
|
OPS_DB="$APP_DIR/operations.db"
|
||||||
|
|
||||||
START=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
START=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
@@ -716,8 +716,8 @@ END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|||||||
STATUS="success"
|
STATUS="success"
|
||||||
[ $EXIT_CODE -ne 0 ] && STATUS="failure"
|
[ $EXIT_CODE -ne 0 ] && STATUS="failure"
|
||||||
|
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution add \
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
|
||||||
--db "$OPS_DB" \
|
--db "$OPS_DB" \
|
||||||
--pipeline-id "{pipeline_id}" \
|
--pipeline-id "{pipeline_id}" \
|
||||||
--status "$STATUS" \
|
--status "$STATUS" \
|
||||||
@@ -728,7 +728,7 @@ FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution add \
|
|||||||
### Bash
|
### Bash
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
APP_DIR="$HOME/fn_registry/apps/{app_name}"
|
APP_DIR="/home/lucas/fn_registry/apps/{app_name}"
|
||||||
OPS_DB="$APP_DIR/operations.db"
|
OPS_DB="$APP_DIR/operations.db"
|
||||||
PIPELINE_ID="{pipeline_id}"
|
PIPELINE_ID="{pipeline_id}"
|
||||||
|
|
||||||
@@ -741,8 +741,8 @@ END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|||||||
STATUS="success"
|
STATUS="success"
|
||||||
[ $EXIT_CODE -ne 0 ] && STATUS="failure"
|
[ $EXIT_CODE -ne 0 ] && STATUS="failure"
|
||||||
|
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution add \
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
|
||||||
--db "$OPS_DB" \
|
--db "$OPS_DB" \
|
||||||
--pipeline-id "$PIPELINE_ID" \
|
--pipeline-id "$PIPELINE_ID" \
|
||||||
--status "$STATUS" \
|
--status "$STATUS" \
|
||||||
@@ -758,10 +758,10 @@ Antes de ejecutar, verifica que los snapshots de tipos en operations.db estan al
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Verificar snapshots
|
# Verificar snapshots
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops snapshot check --db apps/{app_name}/operations.db
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot check --db apps/{app_name}/operations.db
|
||||||
|
|
||||||
# Actualizar si estan desactualizados
|
# Actualizar si estan desactualizados
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops snapshot update --db apps/{app_name}/operations.db --id "TYPE_ID"
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot update --db apps/{app_name}/operations.db --id "TYPE_ID"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -800,7 +800,7 @@ Crea una proposal cuando detectes:
|
|||||||
### Como crear proposals
|
### Como crear proposals
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
|
|
||||||
# Proposal para nueva funcion
|
# Proposal para nueva funcion
|
||||||
./fn proposal add \
|
./fn proposal add \
|
||||||
@@ -840,7 +840,7 @@ Cuando la proposal viene de un fallo o anomalia en una ejecucion, incluye la evi
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Obtener el ID de la ejecucion que evidencia el problema
|
# Obtener el ID de la ejecucion que evidencia el problema
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution list \
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list \
|
||||||
--db apps/{app_name}/operations.db --status failure
|
--db apps/{app_name}/operations.db --status failure
|
||||||
|
|
||||||
# Incluir evidencia en la descripcion
|
# Incluir evidencia en la descripcion
|
||||||
@@ -858,19 +858,19 @@ Usa el contexto de la tabla apps para comparar y detectar patrones:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ver que funciones usan las apps — detectar patrones comunes
|
# Ver que funciones usan las apps — detectar patrones comunes
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, uses_functions FROM apps WHERE uses_functions != '[]';"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, uses_functions FROM apps WHERE uses_functions != '[]';"
|
||||||
|
|
||||||
# Ver funciones mas usadas por apps (candidatas a mejora)
|
# Ver funciones mas usadas por apps (candidatas a mejora)
|
||||||
sqlite3 $HOME/fn_registry/registry.db "
|
sqlite3 /home/lucas/fn_registry/registry.db "
|
||||||
SELECT f.value as func_id, COUNT(*) as uso
|
SELECT f.value as func_id, COUNT(*) as uso
|
||||||
FROM apps, json_each(apps.uses_functions) f
|
FROM apps, json_each(apps.uses_functions) f
|
||||||
GROUP BY f.value ORDER BY uso DESC;"
|
GROUP BY f.value ORDER BY uso DESC;"
|
||||||
|
|
||||||
# Ver apps que NO tienen funciones del registry (candidatas a extraccion)
|
# Ver apps que NO tienen funciones del registry (candidatas a extraccion)
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, description FROM apps WHERE uses_functions = '[]';"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, description FROM apps WHERE uses_functions = '[]';"
|
||||||
|
|
||||||
# Ver si ya existe una proposal para algo similar
|
# Ver si ya existe una proposal para algo similar
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, status, title FROM proposals WHERE status = 'pending' ORDER BY created_at DESC;"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, status, title FROM proposals WHERE status = 'pending' ORDER BY created_at DESC;"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Flujo de deteccion al ejecutar
|
### Flujo de deteccion al ejecutar
|
||||||
|
|||||||
@@ -43,12 +43,12 @@ APP_ID="<input>"
|
|||||||
RUN_ID="<input>"
|
RUN_ID="<input>"
|
||||||
|
|
||||||
# dir_path desde registry
|
# dir_path desde registry
|
||||||
DIR_PATH=$(sqlite3 $HOME/fn_registry/registry.db \
|
DIR_PATH=$(sqlite3 /home/lucas/fn_registry/registry.db \
|
||||||
"SELECT dir_path FROM apps WHERE id = '$APP_ID' OR dir_path = '$APP_ID' LIMIT 1;")
|
"SELECT dir_path FROM apps WHERE id = '$APP_ID' OR dir_path = '$APP_ID' LIMIT 1;")
|
||||||
APP_ID=$(sqlite3 $HOME/fn_registry/registry.db \
|
APP_ID=$(sqlite3 /home/lucas/fn_registry/registry.db \
|
||||||
"SELECT id FROM apps WHERE id = '$APP_ID' OR dir_path = '$APP_ID' LIMIT 1;")
|
"SELECT id FROM apps WHERE id = '$APP_ID' OR dir_path = '$APP_ID' LIMIT 1;")
|
||||||
|
|
||||||
APP_DB="$HOME/fn_registry/$DIR_PATH/operations.db"
|
APP_DB="/home/lucas/fn_registry/$DIR_PATH/operations.db"
|
||||||
[ ! -f "$APP_DB" ] && APP_DB="/tmp/$(basename $DIR_PATH)_e2e_runs.db"
|
[ ! -f "$APP_DB" ] && APP_DB="/tmp/$(basename $DIR_PATH)_e2e_runs.db"
|
||||||
|
|
||||||
# Sanity check
|
# Sanity check
|
||||||
@@ -93,7 +93,7 @@ Por cada fallo:
|
|||||||
Antes de crear proposal, verificar que no haya una identica abierta:
|
Antes de crear proposal, verificar que no haya una identica abierta:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sqlite3 $HOME/fn_registry/registry.db "
|
sqlite3 /home/lucas/fn_registry/registry.db "
|
||||||
SELECT id FROM proposals
|
SELECT id FROM proposals
|
||||||
WHERE status = 'pending'
|
WHERE status = 'pending'
|
||||||
AND target_id = '$APP_ID'
|
AND target_id = '$APP_ID'
|
||||||
@@ -139,7 +139,7 @@ Sugerencia generica en `description` (NO codigo concreto, solo direccion):
|
|||||||
Si la misma assertion/check ha disparado proposal mas de 3 veces en los ultimos 30 dias, marcar `priority` (campo extendido si existe, si no, anotar en `description: '[REINCIDENTE x4]'`).
|
Si la misma assertion/check ha disparado proposal mas de 3 veces en los ultimos 30 dias, marcar `priority` (campo extendido si existe, si no, anotar en `description: '[REINCIDENTE x4]'`).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sqlite3 $HOME/fn_registry/registry.db "
|
sqlite3 /home/lucas/fn_registry/registry.db "
|
||||||
SELECT COUNT(*) FROM proposals
|
SELECT COUNT(*) FROM proposals
|
||||||
WHERE target_id = '$APP_ID'
|
WHERE target_id = '$APP_ID'
|
||||||
AND title LIKE '%::$CHECK_ID%'
|
AND title LIKE '%::$CHECK_ID%'
|
||||||
|
|||||||
@@ -30,14 +30,14 @@ Referencia completa: `dev/issues/0069-autonomous-agent-loop-self-iterating-tasks
|
|||||||
6. **Auditoria total**. Cada decision se loggea en `task_runs.progress_json` con razonamiento + fase + run_id.
|
6. **Auditoria total**. Cada decision se loggea en `task_runs.progress_json` con razonamiento + fase + run_id.
|
||||||
7. **No self-modify**. NO modificas tu propio SKILL.md ni el de otros subagentes en la misma run.
|
7. **No self-modify**. NO modificas tu propio SKILL.md ni el de otros subagentes en la misma run.
|
||||||
8. **Cero produccion**. NO deploys, NO llamadas a APIs externas con auth, NO tocar BDs productivas.
|
8. **Cero produccion**. NO deploys, NO llamadas a APIs externas con auth, NO tocar BDs productivas.
|
||||||
9. **NUNCA paths absolutos fuera del worktree**. SIEMPRE rutas relativas o absolutas que apunten dentro de `/tmp/fn_orq_<issue>_<ts>/`. Si necesitas leer algo del repo principal (ej. plantillas docs), copialo al worktree primero. Refuerzo del piloto 1 (2026-05-15): orquestador modifico hooks bash del repo principal usando paths absolutos `$HOME/fn_registry/bash/functions/...` para destrancar pre-commit. Solucion correcta: el fix vive en el worktree, NO en main.
|
9. **NUNCA paths absolutos fuera del worktree**. SIEMPRE rutas relativas o absolutas que apunten dentro de `/tmp/fn_orq_<issue>_<ts>/`. Si necesitas leer algo del repo principal (ej. plantillas docs), copialo al worktree primero. Refuerzo del piloto 1 (2026-05-15): orquestador modifico hooks bash del repo principal usando paths absolutos `/home/lucas/fn_registry/bash/functions/...` para destrancar pre-commit. Solucion correcta: el fix vive en el worktree, NO en main.
|
||||||
10. **Pre-commit hook compartido**. Worktrees comparten `.git/hooks/` con main repo. Si el hook llama scripts via path absoluto a main (ej. `$HOME/fn_registry/bash/functions/cybersecurity/scan_secrets_in_dirty.sh`), el hook ejecutara la version de MAIN, no la del worktree. Opciones legitimas:
|
10. **Pre-commit hook compartido**. Worktrees comparten `.git/hooks/` con main repo. Si el hook llama scripts via path absoluto a main (ej. `/home/lucas/fn_registry/bash/functions/cybersecurity/scan_secrets_in_dirty.sh`), el hook ejecutara la version de MAIN, no la del worktree. Opciones legitimas:
|
||||||
a. Aplicar el fix del hook EN EL WORKTREE y commitearlo en `auto/*` — al mergear el PR, main heredara el fix.
|
a. Aplicar el fix del hook EN EL WORKTREE y commitearlo en `auto/*` — al mergear el PR, main heredara el fix.
|
||||||
b. Si el hook bloquea progreso y el fix del hook excede tu scope, `git commit --no-verify` para ESE commit SOLO, documentando excepcion en `task_runs.events_json[].decision="skip_hook"` con razon.
|
b. Si el hook bloquea progreso y el fix del hook excede tu scope, `git commit --no-verify` para ESE commit SOLO, documentando excepcion en `task_runs.events_json[].decision="skip_hook"` con razon.
|
||||||
NO modificar archivos en main directamente.
|
NO modificar archivos en main directamente.
|
||||||
11. **Post-iteracion sanity check**. Tras cada commit en `auto/*`, verificar:
|
11. **Post-iteracion sanity check**. Tras cada commit en `auto/*`, verificar:
|
||||||
```bash
|
```bash
|
||||||
git -C $HOME/fn_registry status --short
|
git -C /home/lucas/fn_registry status --short
|
||||||
```
|
```
|
||||||
Si la salida cambia respecto al baseline (capturado al inicio del piloto), HAS contaminado el repo principal. ABORT con `status=sandbox_breach` y reporta los archivos afectados en el output al humano.
|
Si la salida cambia respecto al baseline (capturado al inicio del piloto), HAS contaminado el repo principal. ABORT con `status=sandbox_breach` y reporta los archivos afectados en el output al humano.
|
||||||
|
|
||||||
@@ -49,24 +49,24 @@ Antes de arrancar el bucle, comprobar:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Migration 006_task_runs.sql existe
|
# 1. Migration 006_task_runs.sql existe
|
||||||
ls $HOME/fn_registry/fn_operations/migrations/006_task_runs.sql 2>/dev/null \
|
ls /home/lucas/fn_registry/fn_operations/migrations/006_task_runs.sql 2>/dev/null \
|
||||||
|| { echo "ABORT: migration 006_task_runs.sql ausente. Aplicar issue 0069 paso 1 antes."; exit 2; }
|
|| { echo "ABORT: migration 006_task_runs.sql ausente. Aplicar issue 0069 paso 1 antes."; exit 2; }
|
||||||
|
|
||||||
# 2. Subagentes fn-* presentes
|
# 2. Subagentes fn-* presentes
|
||||||
for a in fn-constructor fn-executor fn-recopilador fn-analizador fn-mejorador; do
|
for a in fn-constructor fn-executor fn-recopilador fn-analizador fn-mejorador; do
|
||||||
test -f $HOME/fn_registry/.claude/agents/$a/SKILL.md \
|
test -f /home/lucas/fn_registry/.claude/agents/$a/SKILL.md \
|
||||||
|| { echo "ABORT: subagente $a ausente"; exit 2; }
|
|| { echo "ABORT: subagente $a ausente"; exit 2; }
|
||||||
done
|
done
|
||||||
|
|
||||||
# 3. master local up-to-date con origin (worktree se creara desde master)
|
# 3. master local up-to-date con origin (worktree se creara desde master)
|
||||||
git -C $HOME/fn_registry fetch origin master --quiet
|
git -C /home/lucas/fn_registry fetch origin master --quiet
|
||||||
LOCAL=$(git -C $HOME/fn_registry rev-parse master)
|
LOCAL=$(git -C /home/lucas/fn_registry rev-parse master)
|
||||||
REMOTE=$(git -C $HOME/fn_registry rev-parse origin/master)
|
REMOTE=$(git -C /home/lucas/fn_registry rev-parse origin/master)
|
||||||
test "$LOCAL" = "$REMOTE" \
|
test "$LOCAL" = "$REMOTE" \
|
||||||
|| { echo "ABORT: master local desincronizado con origin. git pull antes."; exit 2; }
|
|| { echo "ABORT: master local desincronizado con origin. git pull antes."; exit 2; }
|
||||||
|
|
||||||
# 4. Branch auto/<issue> NO existe ya (ni local ni en worktrees)
|
# 4. Branch auto/<issue> NO existe ya (ni local ni en worktrees)
|
||||||
git -C $HOME/fn_registry rev-parse --verify "auto/${ISSUE_ID}" >/dev/null 2>&1 \
|
git -C /home/lucas/fn_registry rev-parse --verify "auto/${ISSUE_ID}" >/dev/null 2>&1 \
|
||||||
&& { echo "ABORT: branch auto/${ISSUE_ID} ya existe. Limpiar antes (git branch -D + worktree remove)."; exit 2; }
|
&& { echo "ABORT: branch auto/${ISSUE_ID} ya existe. Limpiar antes (git branch -D + worktree remove)."; exit 2; }
|
||||||
|
|
||||||
# 5. gh CLI autenticado (necesario para PR draft al converger)
|
# 5. gh CLI autenticado (necesario para PR draft al converger)
|
||||||
@@ -116,7 +116,7 @@ BRANCH="auto/${ISSUE_ID}"
|
|||||||
TASK_RUN_ID="task_$(openssl rand -hex 8)"
|
TASK_RUN_ID="task_$(openssl rand -hex 8)"
|
||||||
STARTED_AT=$(date +%s)
|
STARTED_AT=$(date +%s)
|
||||||
WT_ROOT="/tmp/fn_orq_${ISSUE_ID}_${STARTED_AT}"
|
WT_ROOT="/tmp/fn_orq_${ISSUE_ID}_${STARTED_AT}"
|
||||||
REPO="$HOME/fn_registry"
|
REPO="/home/lucas/fn_registry"
|
||||||
|
|
||||||
# Crear worktree aislado desde master (no toca el principal)
|
# Crear worktree aislado desde master (no toca el principal)
|
||||||
git -C "$REPO" worktree add -b "$BRANCH" "$WT_ROOT" master \
|
git -C "$REPO" worktree add -b "$BRANCH" "$WT_ROOT" master \
|
||||||
@@ -187,13 +187,13 @@ while iter < max_iterations and elapsed < max_minutes:
|
|||||||
|
|
||||||
Usar `Agent` tool con `subagent_type` correcto. Prompt **autocontenido** (paths absolutos, IDs, criterio exito).
|
Usar `Agent` tool con `subagent_type` correcto. Prompt **autocontenido** (paths absolutos, IDs, criterio exito).
|
||||||
|
|
||||||
**CRITICO**: pasar `WT_ROOT` (worktree path) en cada prompt y exigir al subagente trabajar dentro de el. Subagentes NO deben tocar el repo principal `$HOME/fn_registry/`.
|
**CRITICO**: pasar `WT_ROOT` (worktree path) en cada prompt y exigir al subagente trabajar dentro de el. Subagentes NO deben tocar el repo principal `/home/lucas/fn_registry/`.
|
||||||
|
|
||||||
Patron prompt:
|
Patron prompt:
|
||||||
```
|
```
|
||||||
Working dir: <WT_ROOT> # NO $HOME/fn_registry
|
Working dir: <WT_ROOT> # NO /home/lucas/fn_registry
|
||||||
Branch: auto/<issue_id>
|
Branch: auto/<issue_id>
|
||||||
Repo principal (solo lectura para registry.db): $HOME/fn_registry
|
Repo principal (solo lectura para registry.db): /home/lucas/fn_registry
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -346,7 +346,7 @@ Cada `progress_json` entry:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `task_runs` no existe | migration 006 no aplicada | abortar pre-condicion 1 |
|
| `task_runs` no existe | migration 006 no aplicada | abortar pre-condicion 1 |
|
||||||
| `worktree add` falla con "already exists" | branch o dir previo no limpiado | `git worktree prune` + `git branch -D auto/<id>`, reintentar |
|
| `worktree add` falla con "already exists" | branch o dir previo no limpiado | `git worktree prune` + `git branch -D auto/<id>`, reintentar |
|
||||||
| Subagente toca `$HOME/fn_registry/` en vez de worktree | prompt sin `WT_ROOT` explicito | rebriefing con working dir explicito |
|
| Subagente toca `/home/lucas/fn_registry/` en vez de worktree | prompt sin `WT_ROOT` explicito | rebriefing con working dir explicito |
|
||||||
| `master` desincronizado con origin | falta `git pull` | abortar pre-condicion 3 |
|
| `master` desincronizado con origin | falta `git pull` | abortar pre-condicion 3 |
|
||||||
| Loop infinito (mismo fail siempre) | watchdog ausente o desactivado | watchdog OBLIGATORIO, no skipear |
|
| Loop infinito (mismo fail siempre) | watchdog ausente o desactivado | watchdog OBLIGATORIO, no skipear |
|
||||||
| Subagente devuelve output ambiguo | prompt insuficiente | rebriefing con paths/IDs explicitos |
|
| Subagente devuelve output ambiguo | prompt insuficiente | rebriefing con paths/IDs explicitos |
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ apps/{app_name}/
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Listar todas las apps
|
# Listar todas las apps
|
||||||
ls -d $HOME/fn_registry/apps/*/
|
ls -d /home/lucas/fn_registry/apps/*/
|
||||||
|
|
||||||
# Verificar que cada app tiene app.md
|
# Verificar que cada app tiene app.md
|
||||||
for app in $HOME/fn_registry/apps/*/; do
|
for app in /home/lucas/fn_registry/apps/*/; do
|
||||||
name=$(basename "$app")
|
name=$(basename "$app")
|
||||||
echo "=== $name ==="
|
echo "=== $name ==="
|
||||||
[ -f "$app/app.md" ] && echo " app.md: OK" || echo " app.md: FALTA"
|
[ -f "$app/app.md" ] && echo " app.md: OK" || echo " app.md: FALTA"
|
||||||
@@ -82,8 +82,8 @@ sqlite3 "$APP_DB" "SELECT * FROM schema_migrations ORDER BY version;" 2>/dev/nul
|
|||||||
**Si faltan tablas**, aplicar migraciones:
|
**Si faltan tablas**, aplicar migraciones:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Integridad de Entities
|
### 3. Integridad de Entities
|
||||||
@@ -96,7 +96,7 @@ sqlite3 "$APP_DB" "SELECT id, name, type_ref, status, domain, source FROM entiti
|
|||||||
|
|
||||||
# Validar que type_ref existe en registry.db
|
# Validar que type_ref existe en registry.db
|
||||||
sqlite3 "$APP_DB" "SELECT DISTINCT type_ref FROM entities;" | while read ref; do
|
sqlite3 "$APP_DB" "SELECT DISTINCT type_ref FROM entities;" | while read ref; do
|
||||||
EXISTS=$(sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM types WHERE id = '$ref';")
|
EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM types WHERE id = '$ref';")
|
||||||
if [ -z "$EXISTS" ]; then
|
if [ -z "$EXISTS" ]; then
|
||||||
echo "ERROR: type_ref '$ref' no existe en registry.db"
|
echo "ERROR: type_ref '$ref' no existe en registry.db"
|
||||||
fi
|
fi
|
||||||
@@ -129,7 +129,7 @@ sqlite3 "$APP_DB" "SELECT r.id, r.name, r.to_entity FROM relations r WHERE r.to_
|
|||||||
|
|
||||||
# Validar que 'via' referencia una funcion/pipeline del registry
|
# Validar que 'via' referencia una funcion/pipeline del registry
|
||||||
sqlite3 "$APP_DB" "SELECT DISTINCT via FROM relations WHERE via != '';" | while read via; do
|
sqlite3 "$APP_DB" "SELECT DISTINCT via FROM relations WHERE via != '';" | while read via; do
|
||||||
EXISTS=$(sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$via';")
|
EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$via';")
|
||||||
if [ -z "$EXISTS" ]; then
|
if [ -z "$EXISTS" ]; then
|
||||||
echo "ERROR: relation.via '$via' no existe en registry.db"
|
echo "ERROR: relation.via '$via' no existe en registry.db"
|
||||||
fi
|
fi
|
||||||
@@ -156,7 +156,7 @@ sqlite3 "$APP_DB" "SELECT id, pipeline_id, status, started_at, duration_ms, reco
|
|||||||
|
|
||||||
# Validar que pipeline_id existe en registry.db
|
# Validar que pipeline_id existe en registry.db
|
||||||
sqlite3 "$APP_DB" "SELECT DISTINCT pipeline_id FROM executions;" | while read pid; do
|
sqlite3 "$APP_DB" "SELECT DISTINCT pipeline_id FROM executions;" | while read pid; do
|
||||||
EXISTS=$(sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$pid';")
|
EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$pid';")
|
||||||
if [ -z "$EXISTS" ]; then
|
if [ -z "$EXISTS" ]; then
|
||||||
echo "ERROR: pipeline_id '$pid' no existe en registry.db"
|
echo "ERROR: pipeline_id '$pid' no existe en registry.db"
|
||||||
fi
|
fi
|
||||||
@@ -230,7 +230,7 @@ sqlite3 "$APP_DB" "SELECT id, version, lang, algebraic, snapped_at FROM types_sn
|
|||||||
|
|
||||||
# Comparar con registry.db — detectar snapshots desactualizados
|
# Comparar con registry.db — detectar snapshots desactualizados
|
||||||
sqlite3 "$APP_DB" "SELECT id, version FROM types_snapshot;" | while IFS='|' read id ver; do
|
sqlite3 "$APP_DB" "SELECT id, version FROM types_snapshot;" | while IFS='|' read id ver; do
|
||||||
REG_VER=$(sqlite3 $HOME/fn_registry/registry.db "SELECT version FROM types WHERE id = '$id';")
|
REG_VER=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT version FROM types WHERE id = '$id';")
|
||||||
if [ -z "$REG_VER" ]; then
|
if [ -z "$REG_VER" ]; then
|
||||||
echo "WARN: snapshot '$id' ya no existe en registry.db"
|
echo "WARN: snapshot '$id' ya no existe en registry.db"
|
||||||
elif [ "$ver" != "$REG_VER" ]; then
|
elif [ "$ver" != "$REG_VER" ]; then
|
||||||
@@ -252,14 +252,14 @@ done
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Verificar que la app esta en registry.db
|
# Verificar que la app esta en registry.db
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, lang, domain, entry_point, dir_path FROM apps WHERE name = '{app_name}';"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, domain, entry_point, dir_path FROM apps WHERE name = '{app_name}';"
|
||||||
|
|
||||||
# Verificar que uses_functions del app.md coincide con lo indexado
|
# Verificar que uses_functions del app.md coincide con lo indexado
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT uses_functions FROM apps WHERE name = '{app_name}';"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT uses_functions FROM apps WHERE name = '{app_name}';"
|
||||||
|
|
||||||
# Verificar que todas las funciones referenciadas existen
|
# Verificar que todas las funciones referenciadas existen
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT f.value FROM apps, json_each(apps.uses_functions) f WHERE apps.name = '{app_name}';" | while read fid; do
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT f.value FROM apps, json_each(apps.uses_functions) f WHERE apps.name = '{app_name}';" | while read fid; do
|
||||||
EXISTS=$(sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$fid';")
|
EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$fid';")
|
||||||
if [ -z "$EXISTS" ]; then
|
if [ -z "$EXISTS" ]; then
|
||||||
echo "ERROR: app usa funcion '$fid' que no existe en registry"
|
echo "ERROR: app usa funcion '$fid' que no existe en registry"
|
||||||
fi
|
fi
|
||||||
@@ -273,7 +273,7 @@ done
|
|||||||
Patron para auditar TODAS las apps de una vez:
|
Patron para auditar TODAS las apps de una vez:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
|
|
||||||
echo "========================================="
|
echo "========================================="
|
||||||
echo "AUDITORIA DE APPS — fn-recopilador"
|
echo "AUDITORIA DE APPS — fn-recopilador"
|
||||||
@@ -327,7 +327,7 @@ for app_dir in apps/*/; do
|
|||||||
[ "$ERROR_LOGS" -gt 0 ] 2>/dev/null && echo " [WARN] $ERROR_LOGS logs de error"
|
[ "$ERROR_LOGS" -gt 0 ] 2>/dev/null && echo " [WARN] $ERROR_LOGS logs de error"
|
||||||
|
|
||||||
# 9. App indexada en registry.db
|
# 9. App indexada en registry.db
|
||||||
INDEXED=$(sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM apps WHERE name = '$APP_NAME';" 2>/dev/null)
|
INDEXED=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM apps WHERE name = '$APP_NAME';" 2>/dev/null)
|
||||||
[ -n "$INDEXED" ] && echo " [OK] Indexada en registry.db" || echo " [WARN] NO indexada en registry.db"
|
[ -n "$INDEXED" ] && echo " [OK] Indexada en registry.db" || echo " [WARN] NO indexada en registry.db"
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -393,25 +393,25 @@ echo "========================================="
|
|||||||
El recopilador puede sugerir o ejecutar estas reparaciones:
|
El recopilador puede sugerir o ejecutar estas reparaciones:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
|
|
||||||
# Aplicar migraciones faltantes
|
# Aplicar migraciones faltantes
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||||
|
|
||||||
# Actualizar snapshot desactualizado
|
# Actualizar snapshot desactualizado
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops snapshot update --db apps/{app_name}/operations.db --id "TYPE_ID"
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot update --db apps/{app_name}/operations.db --id "TYPE_ID"
|
||||||
|
|
||||||
# Verificar snapshots
|
# Verificar snapshots
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops snapshot check --db apps/{app_name}/operations.db
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot check --db apps/{app_name}/operations.db
|
||||||
|
|
||||||
# Evaluar assertions pendientes
|
# Evaluar assertions pendientes
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops assertion eval --db apps/{app_name}/operations.db --entity-id "ENTITY_ID"
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval --db apps/{app_name}/operations.db --entity-id "ENTITY_ID"
|
||||||
|
|
||||||
# Re-indexar para que la app aparezca en registry.db
|
# Re-indexar para que la app aparezca en registry.db
|
||||||
./fn index
|
./fn index
|
||||||
|
|
||||||
# Ver grafo de la app (util para diagnostico visual)
|
# Ver grafo de la app (util para diagnostico visual)
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops graph --db apps/{app_name}/operations.db
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops graph --db apps/{app_name}/operations.db
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+13
-13
@@ -38,13 +38,13 @@ Antes de crear nada, recopilar contexto:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Buscar funciones relevantes por descripcion
|
# Buscar funciones relevantes por descripcion
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, lang, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:TERMINO* OR name:TERMINO*') ORDER BY name;"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, lang, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:TERMINO* OR name:TERMINO*') ORDER BY name;"
|
||||||
|
|
||||||
# Buscar apps similares
|
# Buscar apps similares
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, lang, description, uses_functions FROM apps WHERE id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, description, uses_functions FROM apps WHERE id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||||
|
|
||||||
# Verificar que el nombre no esta tomado
|
# Verificar que el nombre no esta tomado
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM apps WHERE name = 'NOMBRE';"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM apps WHERE name = 'NOMBRE';"
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Presentar plan al usuario** antes de ejecutar:
|
4. **Presentar plan al usuario** antes de ejecutar:
|
||||||
@@ -79,7 +79,7 @@ Usar el Agent tool con `subagent_type: "fn-constructor"` pasando:
|
|||||||
Despues de que fn-constructor termine, verificar que todo se indexo:
|
Despues de que fn-constructor termine, verificar que todo se indexo:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry && ./fn index
|
cd /home/lucas/fn_registry && ./fn index
|
||||||
# Verificar cada funcion creada
|
# Verificar cada funcion creada
|
||||||
./fn show {id_de_cada_funcion}
|
./fn show {id_de_cada_funcion}
|
||||||
```
|
```
|
||||||
@@ -91,7 +91,7 @@ cd $HOME/fn_registry && ./fn index
|
|||||||
### Estructura base (todos los lenguajes)
|
### Estructura base (todos los lenguajes)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p $HOME/fn_registry/apps/{app_name}
|
mkdir -p /home/lucas/fn_registry/apps/{app_name}
|
||||||
```
|
```
|
||||||
|
|
||||||
### app.md (OBLIGATORIO — siempre primero)
|
### app.md (OBLIGATORIO — siempre primero)
|
||||||
@@ -143,7 +143,7 @@ build/
|
|||||||
|
|
||||||
**Go (CLI/TUI):**
|
**Go (CLI/TUI):**
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry/apps/{app_name}
|
cd /home/lucas/fn_registry/apps/{app_name}
|
||||||
go mod init fn_registry/apps/{app_name}
|
go mod init fn_registry/apps/{app_name}
|
||||||
# Crear main.go, app/, config/, views/ segun necesidad
|
# Crear main.go, app/, config/, views/ segun necesidad
|
||||||
```
|
```
|
||||||
@@ -151,7 +151,7 @@ go mod init fn_registry/apps/{app_name}
|
|||||||
**Go (Wails — desktop con UI):**
|
**Go (Wails — desktop con UI):**
|
||||||
```bash
|
```bash
|
||||||
# Usar scaffold del registry
|
# Usar scaffold del registry
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
./fn run scaffold_wails_app -- --name {app_name} --dir apps/{app_name}
|
./fn run scaffold_wails_app -- --name {app_name} --dir apps/{app_name}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -165,20 +165,20 @@ cd $HOME/fn_registry
|
|||||||
```bash
|
```bash
|
||||||
# Crear main.sh con source a funciones del registry
|
# Crear main.sh con source a funciones del registry
|
||||||
# Pattern: source "$REGISTRY_ROOT/bash/functions/{domain}/{func}.sh"
|
# Pattern: source "$REGISTRY_ROOT/bash/functions/{domain}/{func}.sh"
|
||||||
chmod +x $HOME/fn_registry/apps/{app_name}/main.sh
|
chmod +x /home/lucas/fn_registry/apps/{app_name}/main.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Inicializar operations.db
|
### Inicializar operations.db
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Indexar en registry.db
|
### Indexar en registry.db
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry && ./fn index
|
cd /home/lucas/fn_registry && ./fn index
|
||||||
# Verificar
|
# Verificar
|
||||||
sqlite3 registry.db "SELECT id, name, lang, domain FROM apps WHERE name = '{app_name}';"
|
sqlite3 registry.db "SELECT id, name, lang, domain FROM apps WHERE name = '{app_name}';"
|
||||||
```
|
```
|
||||||
@@ -241,7 +241,7 @@ Usar el Agent tool con `subagent_type: "gitea"` pasando:
|
|||||||
```bash
|
```bash
|
||||||
# 1. Crear repo en Gitea (via API)
|
# 1. Crear repo en Gitea (via API)
|
||||||
# 2. Inicializar git en la app
|
# 2. Inicializar git en la app
|
||||||
cd $HOME/fn_registry/apps/{app_name}
|
cd /home/lucas/fn_registry/apps/{app_name}
|
||||||
git init
|
git init
|
||||||
git add -A
|
git add -A
|
||||||
git commit -m "Initial commit: {app_name} — {descripcion}"
|
git commit -m "Initial commit: {app_name} — {descripcion}"
|
||||||
@@ -256,7 +256,7 @@ git push -u origin master
|
|||||||
**Despues de publicar**, actualizar el `repo_url` en app.md y re-indexar:
|
**Despues de publicar**, actualizar el `repo_url` en app.md y re-indexar:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry && ./fn index
|
cd /home/lucas/fn_registry && ./fn index
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
../../projects/aurgi/.claude/commands
|
|
||||||
@@ -1,36 +1,121 @@
|
|||||||
---
|
# /autonomous-task — Lanza fn-orquestador (Fase 6 del ciclo reactivo)
|
||||||
description: "DEPRECADO 2026-05-19 — usa /autopilot. Wrapper directo a fn-orquestador conservado solo como debug primitive."
|
|
||||||
|
Lanza el meta-orquestador autonomo que recorre el bucle CONSTRUIR → EJECUTAR → RECOPILAR → ANALIZAR → MEJORAR sobre un issue, sin intervencion humana, hasta convergencia / estancamiento / timeout / limite de iteraciones.
|
||||||
|
|
||||||
|
Issue 0069. Pre-condiciones obligatorias (chequear ANTES de despachar):
|
||||||
|
|
||||||
|
1. Migration `fn_operations/migrations/006_task_runs.sql` aplicada.
|
||||||
|
2. Subagentes `fn-constructor`, `fn-executor`, `fn-recopilador`, `fn-analizador`, `fn-mejorador`, `fn-orquestador` presentes en `.claude/agents/`.
|
||||||
|
3. `dev/autonomous_protected_paths.json` existe.
|
||||||
|
4. `master` local up-to-date con `origin/master`.
|
||||||
|
5. Branch `auto/<issue_id>` NO existe ya.
|
||||||
|
6. `gh auth status` OK (necesario para PR draft al converger).
|
||||||
|
7. Tipo de tarea soportado: `feature_app_simple`, `bugfix_with_repro`, `refactor_safe`, `add_e2e_check`.
|
||||||
|
|
||||||
|
Si alguna pre-condicion falla → ABORT con razon. NO improvisar.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# /autonomous-task — DEPRECADO (sustituido por `/autopilot`)
|
## Argumento
|
||||||
|
|
||||||
**ESTADO:** deprecado 2026-05-19. Usa `/autopilot <NNNN>` en su lugar.
|
`$ARGUMENTS` — `<issue_id>` o `<task_spec_path>` + flags opcionales.
|
||||||
|
|
||||||
## Por que deprecado
|
```
|
||||||
|
/autonomous-task 0070
|
||||||
|
/autonomous-task 0070 --max-iterations 15 --max-minutes 90
|
||||||
|
/autonomous-task 0070 --auto-apply-proposals safe
|
||||||
|
/autonomous-task 0070 --dry-run
|
||||||
|
/autonomous-task path/to/spec.yaml --branch auto/custom-name
|
||||||
|
```
|
||||||
|
|
||||||
`/autopilot` (v2, 2026-05-19) absorbe la funcionalidad y anade:
|
Flags:
|
||||||
- Pre-flight DoD readiness check (gate STOP — no arranca sin DoD).
|
- `--max-iterations N` tope de iteraciones (default 10)
|
||||||
- Detector issue vs flow.
|
- `--max-minutes M` timeout total (default 60)
|
||||||
- Reporte estructurado al humano post-delegate.
|
- `--auto-apply-proposals` `none|safe|aggressive` (default `safe`)
|
||||||
- Self-Q&A migrado a fn-orquestador.
|
- `--branch NAME` rama TBD (default `auto/<issue_id>`)
|
||||||
|
- `--dry-run` simula, NO aplica
|
||||||
|
|
||||||
Behaviour orquestador-side es identico. La unica diferencia es que `/autopilot` valida antes de delegar; `/autonomous-task` delegaba ciego.
|
---
|
||||||
|
|
||||||
## Sustitucion 1:1
|
## Comportamiento
|
||||||
|
|
||||||
| Antes | Ahora |
|
1. **Verificar pre-condiciones** con script bash (ver arriba). Si alguna falla, reportar y salir.
|
||||||
|---|---|
|
2. **Despachar a `fn-orquestador`** via Agent tool con `subagent_type=fn-orquestador`. Pasar:
|
||||||
| `/autonomous-task 0070` | `/autopilot 0070` |
|
- `issue_id` o `task_spec`
|
||||||
| `/autonomous-task 0070 --max-iterations 15 --max-minutes 90` | `/autopilot 0070 --max-iterations 15 --max-minutes 90` |
|
- flags resueltos
|
||||||
| `/autonomous-task 0070 --dry-run` | `/autopilot 0070 --dry-run` |
|
- paths protegidos (leidos de `dev/autonomous_protected_paths.json`)
|
||||||
| `/autonomous-task 0070 --auto-apply-proposals safe` | `/autopilot 0070 --auto-apply-proposals safe` |
|
3. **El subagente:**
|
||||||
|
- Crea worktree aislado `/tmp/fn_orq_<issue>_<ts>/` desde `master`.
|
||||||
|
- Persiste estado en `task_runs` (operations.db del app target o repo root).
|
||||||
|
- Despacha por fases a los 5 subagentes especializados.
|
||||||
|
- Aplica proposals filtradas por `--auto-apply-proposals`.
|
||||||
|
- Termina con: `converged` (PR draft creado) | `stalled` | `timeout` | `iterations_exhausted` | `needs_human` | `aborted`.
|
||||||
|
4. **Reportar resultado al humano** con:
|
||||||
|
- `status`, `iterations / max`, `duration / max`
|
||||||
|
- `branch`, `worktree`, `PR draft url` si converged
|
||||||
|
- `proposals creadas / aplicadas`
|
||||||
|
- `last run_id` y status
|
||||||
|
- Resumen iter-por-iter del `progress_json`
|
||||||
|
|
||||||
## Modo debug
|
---
|
||||||
|
|
||||||
Si `/autopilot` falla en pre-flight pero quieres forzar dispatch sin DoD check (debug / experimentos), puedes seguir usando `/autonomous-task` que va directo a `fn-orquestador` sin validar. NO RECOMENDADO para uso normal.
|
## Reglas duras (no negociables)
|
||||||
|
|
||||||
## Migration deadline
|
- Sandbox de rama EN WORKTREE — nunca toca master ni el working tree del humano.
|
||||||
|
- No merge automatico — PR draft siempre.
|
||||||
|
- No `--no-verify`, no `--force`, no skip hooks.
|
||||||
|
- Paths protegidos via `dev/autonomous_protected_paths.json`.
|
||||||
|
- Watchdog: 2 iteraciones con mismo set de fails → `status=stalled`.
|
||||||
|
- Auditoria total en `task_runs.progress_json`.
|
||||||
|
- No self-modification: NO toca `.claude/agents/` ni `.claude/commands/`.
|
||||||
|
|
||||||
Sin deadline duro — `/autonomous-task` seguira funcionando hasta que un commit lo elimine. Pero NO se anaden nuevas features aqui; cualquier mejora va a `/autopilot`.
|
---
|
||||||
|
|
||||||
Ver `.claude/commands/autopilot.md` para spec completa.
|
## Integracion con call_monitor (issue 0085)
|
||||||
|
|
||||||
|
El orquestador puede leer `projects/fn_monitoring/apps/call_monitor/operations.db` para:
|
||||||
|
|
||||||
|
- Consultar `function_stats` antes de decidir que funciones usar/reusar.
|
||||||
|
- Filtrar proposals existentes via `mcp__registry__fn_proposal --status pending` para evitar duplicados.
|
||||||
|
- Loggear sus invocaciones via el hook PostToolUse (automatico).
|
||||||
|
|
||||||
|
Tras converger, el `call_monitor propose` ejecutado por el humano (o futuro cron) absorbera las nuevas violations / copied_code / fails para alimentar la siguiente ronda.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipos NO soportados
|
||||||
|
|
||||||
|
- Diseño arquitectura nuevo (humano decide).
|
||||||
|
- Decisiones UX subjetivas.
|
||||||
|
- Cambios BD productiva.
|
||||||
|
- Cualquier cosa que toque secrets/credenciales.
|
||||||
|
- Self-modification del propio orquestador.
|
||||||
|
|
||||||
|
Si el issue contiene criterios no-verificables programaticamente, ABORT con `status=needs_human`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output canonico
|
||||||
|
|
||||||
|
```
|
||||||
|
=== /autonomous-task: 0070 ===
|
||||||
|
status: converged
|
||||||
|
iterations: 7 / 10
|
||||||
|
duration: 23 min / 60
|
||||||
|
branch: auto/0070
|
||||||
|
worktree: /tmp/fn_orq_0070_1731612345
|
||||||
|
PR draft: https://github.com/.../pull/123
|
||||||
|
proposals: 3 creadas, 2 auto-aplicadas
|
||||||
|
last run_id: e2e_run_abc123 (status: pass)
|
||||||
|
|
||||||
|
Iter:
|
||||||
|
1. construir → ok (2 funciones nuevas)
|
||||||
|
2. ejecutar → ok
|
||||||
|
3. analizar → fail (2/8 checks)
|
||||||
|
4. mejorar → 3 proposals (2 auto-applicadas)
|
||||||
|
5. construir → ok (re-build tras patches)
|
||||||
|
6. analizar → pass
|
||||||
|
7. recopilador → ok (operations.db integra)
|
||||||
|
|
||||||
|
Siguiente: revisar PR draft + fn proposal list -s pending --target-id 0070
|
||||||
|
```
|
||||||
|
|||||||
+260
-132
@@ -1,68 +1,238 @@
|
|||||||
---
|
---
|
||||||
name: autopilot
|
name: autopilot
|
||||||
description: Modo full-auto. Pre-flight DoD check, detecta issue vs flow, SIEMPRE delega a fn-orquestador (worktree aislado + PR Gitea). Sin Path inline. Sustituye a /autonomous-task.
|
description: Modo full-auto self-Q&A. Toma issue o flow con DoD definido, valida readiness, ejecuta hasta cerrarlo sin interaccion humana. Ante cada decision se autoformula la pregunta, se autoresponde con razonamiento explicito, y avanza. Spawnea subagentes. Para.
|
||||||
---
|
---
|
||||||
|
|
||||||
# /autopilot — Comando autonomo unificado
|
# /autopilot — Modo autonomo end-to-end con self-Q&A
|
||||||
|
|
||||||
Comando UNICO para ejecutar issue o flow autonomo end-to-end. Sustituye a `/autonomous-task` (deprecado). Hace dos cosas:
|
Ejecuta un issue o flow **hasta cierre** sin intervencion humana. Ante cada decision, Claude **se formula la pregunta a si mismo y se la responde** (self-Q&A) con razonamiento trazable, en vez de abortar. Auto-prefiere la opcion **Recomendada** cuando exista; cuando no, decide en base a evidencia del codigo, registry, y reglas. Cada Q&A queda persistido en `task_runs.events_json[]` para auditoria. Spawnea subagentes en paralelo y persiste estado en `task_runs`.
|
||||||
|
|
||||||
1. **Pre-flight DoD readiness check** — sin DoD claro, no arranca.
|
Diferencia con comandos relacionados:
|
||||||
2. **Delega SIEMPRE a `fn-orquestador`** via Agent tool — worktree aislado en `/tmp/fn_orq_<NNNN>_<ts>/`, branch `auto/<NNNN>-<slug>`, PR draft Gitea al converger.
|
|
||||||
|
|
||||||
NO ejecuta nada inline. NO muta cwd del shell del humano. NO duplica worktrees. Toda la complejidad de bucle + paths protegidos + sanity check vive en `fn-orquestador`.
|
| Comando | Que hace |
|
||||||
|
|---|---|
|
||||||
|
| `/autonomous-task <issue>` | Wrapper directo de `fn-orquestador` (registry-bound, bucle 5 fases) |
|
||||||
|
| `/fix-issue <issue>` | Flujo guiado humano: rama + tasks + tests + version + close (pregunta cuando duda) |
|
||||||
|
| `/flow run <NNNN>` | Runner manual de flows (fase 2 — no implementado) |
|
||||||
|
| `/autopilot <target>` | **Meta-dispatcher**. Detecta issue vs flow, valida DoD, dispatch correcto, auto-defaults SIEMPRE |
|
||||||
|
|
||||||
## Por que solo delegar
|
---
|
||||||
|
|
||||||
Historico: versiones anteriores de `/autopilot` tenian Path A (delegate a orquestador), Path B (registry-only inline), Path C (flow inline). Los Path B/C reimplementaban lo que ya hace `fn-orquestador` (worktree, branch, PR) y arrastraban un bug: `cd` en Bash de Claude Code PERSISTE entre llamadas → si autopilot hace `cd "$WT"`, todos los Bash subsiguientes operan en branch incorrecta. Solucion: NO hacer Path inline, delegar siempre.
|
|
||||||
|
|
||||||
`fn-orquestador` ahora soporta dos `task_type`:
|
|
||||||
- `issue` — flujo CONSTRUIR→EJECUTAR→RECOPILAR→ANALIZAR→MEJORAR (default).
|
|
||||||
- `flow` — parsea `dev/flows/<NNNN>-*.md` ## Flow y ejecuta steps (Path C absorbido).
|
|
||||||
|
|
||||||
## Sintaxis
|
## Sintaxis
|
||||||
|
|
||||||
```
|
```
|
||||||
/autopilot <NNNN> # issue NNNN (default si no hay prefijo)
|
/autopilot <NNNN> # issue NNNN (default si no hay prefijo)
|
||||||
/autopilot issue:<NNNN> # issue explicito
|
/autopilot issue:<NNNN> # issue NNNN explicito
|
||||||
/autopilot i:<NNNN> # alias
|
/autopilot i:<NNNN> # alias issue
|
||||||
/autopilot flow:<NNNN> # flow NNNN
|
/autopilot flow:<NNNN> # flow NNNN
|
||||||
/autopilot f:<NNNN> # alias
|
/autopilot f:<NNNN> # alias flow
|
||||||
/autopilot check <target> # solo audita DoD readiness, no delega
|
/autopilot check <target> # solo audita DoD readiness, no ejecuta
|
||||||
/autopilot <target> --max-iterations N --max-minutes M --dry-run
|
/autopilot <target> --max-iterations N --max-minutes M
|
||||||
|
/autopilot <target> --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
Detector:
|
Detector:
|
||||||
- `^\d{4}[a-z]?$` → issue (sin prefijo = issue por defecto).
|
- `^\d{4}[a-z]?$` -> issue (sin prefijo = issue por defecto).
|
||||||
- `^(issue|i):\d{4}[a-z]?$` → issue.
|
- `^(issue|i):\d{4}[a-z]?$` -> issue.
|
||||||
- `^(flow|f):\d{4}$` → flow.
|
- `^(flow|f):\d{4}$` -> flow.
|
||||||
- Otra cosa → ABORT con error de sintaxis.
|
- Otra cosa -> ABORT con error de sintaxis.
|
||||||
|
|
||||||
## Pre-flight DoD readiness check (OBLIGATORIO)
|
---
|
||||||
|
|
||||||
Sin DoD claro, autopilot no delega. Verificacion es STOP-gate.
|
## Pre-flight: DoD readiness check (OBLIGATORIO)
|
||||||
|
|
||||||
|
Sin DoD claro, autopilot no arranca. La verificacion es STOP-gate, no se rellena por inferencia.
|
||||||
|
|
||||||
### Issue (`dev/issues/<NNNN>-*.md`)
|
### Issue (`dev/issues/<NNNN>-*.md`)
|
||||||
|
|
||||||
|
Lee el .md. Debe cumplir **todos** estos:
|
||||||
|
|
||||||
1. Archivo existe en `dev/issues/` (no en `completed/`).
|
1. Archivo existe en `dev/issues/` (no en `completed/`).
|
||||||
2. Frontmatter con `status`, `priority`.
|
2. Frontmatter valido (`status`, `priority`).
|
||||||
3. Al menos UNA de:
|
3. **Al menos UNA** de:
|
||||||
- `## DoD` o `## Definition of Done` con >=1 bullet/checkbox concreto.
|
- Seccion `## DoD` o `## Definition of Done` con >=1 bullet/checkbox concreto.
|
||||||
- `## Acceptance` con checkboxes `[ ]`.
|
- Seccion `## Acceptance` con checkboxes `[ ]`.
|
||||||
- `## Tests` + `## Tareas` ambas no vacias.
|
- Seccion `## Tests` + `## Tareas` ambas no vacias.
|
||||||
4. Tipo declarado/inferible soportado por `fn-orquestador`: `feature_app_simple`, `bugfix_with_repro`, `refactor_safe`, `add_e2e_check`, `feature_registry_only`.
|
4. Tipo soportado por `/autonomous-task` si va a delegar a orquestador (`feature_app_simple`, `bugfix_with_repro`, `refactor_safe`, `add_e2e_check`). Si no entra en estos, autopilot intenta ruta `/fix-issue` simplificada (registry-only changes sin rama).
|
||||||
5. NO contiene criterios no-verificables ("queda bonito", "intuitivo", "UX mejor"). Grep simple; si match → ABORT con warning.
|
5. **NO** contiene criterios no-verificables: "queda bonito", "es intuitivo", "UX mejor". Heuristica grep simple — si match -> warning + ABORT.
|
||||||
|
|
||||||
|
Si falla -> ABORT con tabla:
|
||||||
|
|
||||||
|
```
|
||||||
|
=== autopilot check 0107c ===
|
||||||
|
status: NOT READY
|
||||||
|
gaps:
|
||||||
|
- Sin seccion DoD/Acceptance/Tests
|
||||||
|
- Frontmatter sin priority
|
||||||
|
fix:
|
||||||
|
- Anadir `## DoD` con 3-5 bullets verificables programaticamente
|
||||||
|
- Anadir `priority: medium` al frontmatter
|
||||||
|
```
|
||||||
|
|
||||||
### Flow (`dev/flows/<NNNN>-*.md`)
|
### Flow (`dev/flows/<NNNN>-*.md`)
|
||||||
|
|
||||||
1. Archivo existe en `dev/flows/`.
|
1. Archivo existe en `dev/flows/` (no en `completed/`).
|
||||||
2. Frontmatter valido.
|
2. Frontmatter valido.
|
||||||
3. `## Acceptance` con >=1 checkbox.
|
3. Seccion `## Acceptance` con >=1 checkbox `[ ]` (o ya `[x]` — significa parcialmente progresado).
|
||||||
4. `## Flow` no vacio.
|
4. Seccion `## Flow` no vacia.
|
||||||
5. Pre-requisitos declarados.
|
5. Pre-requisitos declarados (incluso si vacio explicito).
|
||||||
6. Tabla de funciones recomendadas sin `FALTA: crear <id>` (si los hay → ABORT salvo `--allow-construct-missing`).
|
6. Tabla de funciones recomendadas presente — sin `FALTA: crear <id>` no resuelto (si hay `FALTA`, ABORT con lista de funciones a crear primero).
|
||||||
|
|
||||||
Si falla:
|
Si `FALTA` esta presente: opcionalmente autopilot ofrece spawnear `fn-constructor` para cada FALTA — pero ESO solo si `--allow-construct-missing`. Por defecto ABORT y reportar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modo autonomo (reglas duras de comportamiento)
|
||||||
|
|
||||||
|
Durante toda la ejecucion de `/autopilot`:
|
||||||
|
|
||||||
|
1. **NO invocar `AskUserQuestion` al humano**. En su lugar, **self-Q&A loop**: cuando surja una decision, Claude la formaliza como `Question -> Options -> Reasoning -> Choice` y persiste el bloque en `task_runs.events_json[]`. Ver seccion "Self-Q&A loop" mas abajo. Solo ABORTA con `status=needs_human` si la decision toca: (a) destructivo sin rollback (`--force`, `git reset --hard`, `DROP TABLE`), (b) credenciales/secrets, (c) paths protegidos, (d) contradice DoD explicito del issue. En esos casos, NUNCA self-answer — escala al humano.
|
||||||
|
2. **Auto-pick "Recommended"** en cualquier flag con opciones (mocks vs prod, default branch, etc.) — usar primer item etiquetado como recomendado en el archivo / convencion del proyecto. Si no hay marcado, self-Q&A con justificacion.
|
||||||
|
3. **Acciones destructivas prohibidas sin flag explicito**: `git reset --hard`, `git push --force`, `rm -rf` fuera de `/tmp/`, `DROP TABLE`, `--no-verify`, `--force`. Si una accion las requiere -> ABORT.
|
||||||
|
4. **Hooks NO se saltan**. Si pre-commit falla, fix raiz; si excede scope, ABORT (NO `--no-verify`).
|
||||||
|
5. **Paths protegidos** de `dev/autonomous_protected_paths.json` se respetan exactamente.
|
||||||
|
6. **Rama dedicada + worktree aislado SIEMPRE — sin excepciones**. Autopilot opera en `worktrees/auto-<NNNN>-<slug>/` (rama `auto/<NNNN>-<slug>`) para NO bloquear el working tree principal del humano. Aplica a issues, flows, registry-only y apps. Master NUNCA recibe commits directos en modo autopilot. Pre-flight obligatorio desde el working tree principal:
|
||||||
|
```bash
|
||||||
|
git fetch origin master
|
||||||
|
git -C <main_repo> rev-parse --is-clean # tolerante: solo confirmar master rebased
|
||||||
|
WT=worktrees/auto-<NNNN>-<slug>
|
||||||
|
git worktree add -b auto/<NNNN>-<slug> "$WT" master
|
||||||
|
cd "$WT" # todo el trabajo posterior aqui
|
||||||
|
```
|
||||||
|
Path del worktree:
|
||||||
|
- **Dentro del repo**: `worktrees/auto-<NNNN>-<slug>/` (gitignored). Permite que herramientas con `FN_REGISTRY_ROOT` apunten al worktree y no a master.
|
||||||
|
- Alternativa `/tmp/fn_autopilot_<NNNN>_<ts>/` si la app necesita aislamiento total del filesystem del repo (raro).
|
||||||
|
Reanudacion idempotente: si el worktree ya existe -> `cd` a el + `git rebase master`. Si la rama `auto/<NNNN>-<slug>` existe pero el worktree no -> `git worktree add "$WT" auto/<NNNN>-<slug>`. Conflicto en rebase -> ABORT.
|
||||||
|
Cierre exitoso: merge `--no-ff` a master desde el repo principal (`git -C <main> merge --no-ff auto/<NNNN>-<slug>`) solo tras tests verde + DoD 100%. Luego `git worktree remove <WT>` + `git branch -d auto/<NNNN>-<slug>`.
|
||||||
|
Cierre fallido: worktree y rama quedan vivos para inspeccion humana. Master intacto.
|
||||||
|
**Garantia**: el humano puede seguir editando en el repo principal mientras autopilot avanza — sin colisiones de checkout, sin index lockings, sin "uncommitted changes blocks branch switch".
|
||||||
|
7. **Watchdog**: si la metrica de progreso (`acceptance_done / acceptance_total` para flows; `tests_pass / tests_total` o `checks_pass / checks_total` para issues) NO sube en 3 iteraciones consecutivas -> ABORT con `status=stalled`.
|
||||||
|
8. **Timeout** default 60 min. Override con `--max-minutes`.
|
||||||
|
9. **Idempotencia**: re-lanzar `/autopilot <target>` sobre el mismo target reanuda desde el ultimo `task_run` exitoso (lookup por `issue_id` o `flow_id` en `task_runs`).
|
||||||
|
10. **No self-modification**: NUNCA tocar `.claude/agents/`, `.claude/commands/`, `.claude/rules/`, `.claude/scripts/`, `.claude/CLAUDE.md`.
|
||||||
|
11. **Trazabilidad**: cada decision se persiste en `task_runs.events_json[]` con `{ts, agent, action, evidence, diff_summary, auto_choice, self_qa?}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Q&A loop (corazon del modo)
|
||||||
|
|
||||||
|
Cuando aparece una decision sin Recomendado explicito, **NO abortar y NO preguntar al humano**. Claude:
|
||||||
|
|
||||||
|
1. **Formula la pregunta** en una frase. Una sola pregunta por bloque, especifica, contestable.
|
||||||
|
2. **Lista opciones** (2-4). Misma forma que `AskUserQuestion` interno: `label + description`. Si solo hay una opcion viable, indicalo (`Options: [A] only viable`).
|
||||||
|
3. **Razona en 1-3 lineas** apoyandote en: registry (`mcp__registry__fn_search`), reglas (`.claude/rules/`), tests previos, archivos del repo, convenciones del proyecto.
|
||||||
|
4. **Elige** y marca `confidence: high|med|low`. Si `low` y la accion no es trivialmente reversible -> ABORT con `status=needs_human` y adjunta el bloque Q&A.
|
||||||
|
5. **Persiste** en `events_json[]` con shape:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ts": "...",
|
||||||
|
"agent": "autopilot",
|
||||||
|
"action": "self_qa",
|
||||||
|
"self_qa": {
|
||||||
|
"question": "Crear el flag enabled=false o ya enabled=true?",
|
||||||
|
"options": [
|
||||||
|
{"label": "enabled=false", "rationale": "TBD doctrina (feature_flags.md): merge codigo terminado pero NO expuesto"},
|
||||||
|
{"label": "enabled=true", "rationale": "feature ya tiene tests verde y DoD 100%"}
|
||||||
|
],
|
||||||
|
"choice": "enabled=false",
|
||||||
|
"confidence": "high",
|
||||||
|
"reasoning": "feature_flags.md regla: 'cuando se activa: cambiar enabled:true y rellenar enabled_at'. Activar va en commit posterior."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
6. **Avanza** sin esperar.
|
||||||
|
|
||||||
|
**Tope de self-Q&A**: `--max-self-answers` (default 20). Si se excede -> ABORT `status=overdeliberating` con dump de todas las Q&A. Una iteracion del bucle que necesita >5 Q&A es señal de DoD vago — abortar.
|
||||||
|
|
||||||
|
**Cuando NO usar self-Q&A (ABORT en vez de auto-responder)**:
|
||||||
|
|
||||||
|
| Caso | Razon |
|
||||||
|
|---|---|
|
||||||
|
| Destructivo sin rollback (`git reset --hard`, `rm -rf` fuera `/tmp/`, `DROP TABLE`, `--no-verify`, `--force`) | Coste de error infinito |
|
||||||
|
| Credenciales/tokens/secrets | Riesgo de exfiltracion |
|
||||||
|
| Paths protegidos (`dev/autonomous_protected_paths.json`) | Regla dura del orquestador |
|
||||||
|
| Contradiccion explicita con DoD del issue | DoD es contrato |
|
||||||
|
| Decision arquitectonica multi-app (renombrar tabla compartida, romper API publica) | Blast radius > 1 artefacto |
|
||||||
|
| `confidence: low` + accion no reversible | Self-Q&A no garantiza acierto sin oraculo |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dispatch logic
|
||||||
|
|
||||||
|
### Path A: issue compatible con `fn-orquestador`
|
||||||
|
|
||||||
|
Si el issue declara o se infiere tipo en `(feature_app_simple, bugfix_with_repro, refactor_safe, add_e2e_check)` Y toca apps/modules/framework:
|
||||||
|
|
||||||
|
- Delega a `fn-orquestador` via `Agent(subagent_type="fn-orquestador", ...)` pasando:
|
||||||
|
- `issue_id`, `--auto-apply-proposals safe`, `--max-iterations`, `--max-minutes`, paths protegidos.
|
||||||
|
- Espera resultado, reenvia `task_run_id` + PR draft URL al humano.
|
||||||
|
|
||||||
|
### Path B: issue registry-only (functions/types/docs/rules)
|
||||||
|
|
||||||
|
- **Rama + worktree `worktrees/auto-<NNNN>-<slug>/`** desde master actualizado (regla dura 6). Humano sigue trabajando en el repo principal en paralelo.
|
||||||
|
- Politica `apps_tbd.md` permite push directo a master para registry-only en modo humano, pero `/autopilot` NO lo usa: el aislamiento por rama+worktree es la garantia de rollback + paralelismo en modo autonomo.
|
||||||
|
- Plan inline con TaskCreate:
|
||||||
|
1. Pre-flight worktree (`git fetch` + `git worktree add -b auto/<NNNN>-<slug> worktrees/auto-<NNNN>-<slug> master` + `cd` a el).
|
||||||
|
2. Leer issue + extraer DoD.
|
||||||
|
3. Search registry para piezas existentes (registry-first).
|
||||||
|
4. Si falta funcion -> spawn `fn-constructor` paralelo.
|
||||||
|
5. `fn index`.
|
||||||
|
6. Tests (`go test`, `pytest`, `bash -n`, segun stack).
|
||||||
|
7. Si toco modulos/framework -> `/version` correspondiente.
|
||||||
|
8. Mover `dev/issues/<NNNN>-*.md` a `dev/issues/completed/`.
|
||||||
|
9. Actualizar `dev/issues/README.md` (si existe).
|
||||||
|
10. Commit atomico por bloque logico en la rama.
|
||||||
|
11. Solo si TODOS los tests pasan + DoD 100%: merge `--no-ff` a master + push + delete rama. Si algo falla -> rama queda viva, master intacto.
|
||||||
|
- Verificacion final: DoD checkboxes -> todos marcados.
|
||||||
|
|
||||||
|
### Path C: flow
|
||||||
|
|
||||||
|
Runner inline (fase 2 manual, mientras `/flow run` no exista):
|
||||||
|
|
||||||
|
1. **Rama + worktree `worktrees/auto-flow-<NNNN>-<slug>/`** desde master (regla dura 6) — incluso para flows que solo ejecutan funciones sin escribir codigo, asi los side-effects en `dev/flows/<NNNN>-*.md` (checkboxes) y `dev/flows/runs/*.jsonl` se commitean en rama, no en master. Humano puede seguir editando el repo principal en paralelo.
|
||||||
|
2. Parsea `## Flow` del .md.
|
||||||
|
3. Cada paso tipo `function: <id>` -> `./fn run <id> [args]`.
|
||||||
|
4. Cada paso tipo `cmd: <bash>` -> Bash tool (con guardas destructivas).
|
||||||
|
5. Paso "MANUAL: ..." -> si tiene equivalente automatico (ej. "abrir Chrome y loguearse" vs `cdp_extract_recipe`) usa el automatico; si no tiene equivalente -> ABORT con `status=needs_human` y razon.
|
||||||
|
6. Tras cada paso, evalua `## Acceptance` checkboxes via heuristicas:
|
||||||
|
- "X runs en data_factory" -> `sqlite3` count.
|
||||||
|
- "DAG corre 2 veces consecutivas" -> consultar `dag_engine` logs.
|
||||||
|
- Otros -> dejar como `[ ]` y reportar.
|
||||||
|
7. Persiste run en `dev/flows/runs/<NNNN>-<ts>.jsonl`.
|
||||||
|
8. Si todos `[ ]` -> `[x]` -> commit en rama + merge `--no-ff` a master + `/flow done <NNNN>` + delete rama.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output canonico
|
||||||
|
|
||||||
|
```
|
||||||
|
=== /autopilot 0107c ===
|
||||||
|
target: issue 0107c (refactor data_table)
|
||||||
|
path: B (registry-only)
|
||||||
|
status: done
|
||||||
|
iterations: 3 / 10
|
||||||
|
duration: 18 min / 60
|
||||||
|
dod_checks: 5/5 pass
|
||||||
|
proposals: 2 creadas, 1 auto-aplicada
|
||||||
|
self_qa: 7 (6 high / 1 med / 0 low)
|
||||||
|
agents_spawned: fn-constructor x2, fn-recopilador x1
|
||||||
|
commits: 4 (3 feat + 1 refactor)
|
||||||
|
branch: master (registry-only, push directo)
|
||||||
|
|
||||||
|
Trace:
|
||||||
|
1. construir → ok (2 funciones nuevas, 1 split)
|
||||||
|
2. tests → ok (43/43)
|
||||||
|
3. version → /version modules/data_table major "..."
|
||||||
|
4. close → mv to completed/, push
|
||||||
|
|
||||||
|
Siguiente: ningun paso humano requerido. Verificar con: fn doctor modules
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sub-comando: `/autopilot check <target>`
|
||||||
|
|
||||||
|
Solo audita readiness — **no** ejecuta nada.
|
||||||
|
|
||||||
```
|
```
|
||||||
=== /autopilot check 0125 ===
|
=== /autopilot check 0125 ===
|
||||||
@@ -70,10 +240,12 @@ status: NOT READY
|
|||||||
target: issue 0125 (skill-tree-dashboard-panel)
|
target: issue 0125 (skill-tree-dashboard-panel)
|
||||||
gaps:
|
gaps:
|
||||||
- Sin seccion DoD/Acceptance
|
- Sin seccion DoD/Acceptance
|
||||||
- "UX intuitiva" linea 47 — no verificable
|
- Frontmatter sin priority
|
||||||
|
non_verifiable_criteria:
|
||||||
|
- "UX intuitiva" (linea 47)
|
||||||
fix:
|
fix:
|
||||||
- Anadir ## DoD con 3-5 bullets programaticamente verificables
|
- Anadir ## DoD con 3-5 bullets programaticamente verificables
|
||||||
- Reemplazar criterios subjetivos por mediciones concretas
|
- Reemplazar "UX intuitiva" por criterio medible
|
||||||
```
|
```
|
||||||
|
|
||||||
Si OK:
|
Si OK:
|
||||||
@@ -83,130 +255,86 @@ Si OK:
|
|||||||
status: READY
|
status: READY
|
||||||
target: issue 0107c (refactor data_table)
|
target: issue 0107c (refactor data_table)
|
||||||
dod_items: 5 checkboxes
|
dod_items: 5 checkboxes
|
||||||
task_type: refactor_safe
|
path_inferred: B (registry-only — modules/)
|
||||||
estimated_iter: 3-5
|
estimated_iter: 3-5
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dispatch a fn-orquestador
|
---
|
||||||
|
|
||||||
Tras pre-flight OK, ejecuta:
|
|
||||||
|
|
||||||
```
|
|
||||||
Agent(
|
|
||||||
subagent_type="fn-orquestador",
|
|
||||||
prompt="""
|
|
||||||
Issue/Flow: <path al .md>
|
|
||||||
Modo: REAL (o --dry-run)
|
|
||||||
task_type: <issue|flow>
|
|
||||||
Pre-condiciones verificadas: 7/7 verde
|
|
||||||
Master: <sha> sync con origin
|
|
||||||
Working tree principal: limpio (baseline)
|
|
||||||
Max iter: N
|
|
||||||
Max min: M
|
|
||||||
Auto-apply proposals: safe
|
|
||||||
Token Gitea: pass gitea/dataforge-git-token
|
|
||||||
DB task_runs: apps/deploy_server/operations.db (schema task_id)
|
|
||||||
Reglas duras: autonomous_loop.md (11 reglas)
|
|
||||||
""",
|
|
||||||
run_in_background=true
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Cuando termine, reporta al humano con output canonico del orquestador:
|
|
||||||
|
|
||||||
```
|
|
||||||
=== /autopilot 0121b ===
|
|
||||||
target: issue 0121b (fn doctor e2e-coverage)
|
|
||||||
delegated_to: fn-orquestador
|
|
||||||
status: converged
|
|
||||||
iterations: 1 / 8
|
|
||||||
duration: 4 min / 30
|
|
||||||
task_run_id: task_d285372493cce2e6
|
|
||||||
branch: auto/0121b-orquestador
|
|
||||||
worktree: /tmp/fn_orq_0121b_1779147778
|
|
||||||
PR draft: https://gitea-.../dataforge/fn_registry/pulls/3
|
|
||||||
|
|
||||||
Siguiente: revisar PR, mergear, mover issue a completed/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Reglas duras (autopilot-level)
|
|
||||||
|
|
||||||
1. **Cero cwd mutation**. Autopilot NUNCA hace `cd`. Usa `git -C <repo>` siempre si necesita inspeccionar.
|
|
||||||
2. **Cero ejecucion inline de bucle**. Todo va via `fn-orquestador`. Si autopilot necesita ejecutar algo (pre-flight scripts), es read-only.
|
|
||||||
3. **Cero AskUserQuestion**. Self-pick "Recommended". Si no hay, ABORT con `status=needs_human`.
|
|
||||||
4. **DoD es contrato**. Si DoD no se cumple al final, `task_run.status` queda `partial` y autopilot reporta NOT_DONE — humano decide.
|
|
||||||
5. **Worktree gestion delegada al orquestador**. Autopilot NO crea worktrees propios. NO toca branches.
|
|
||||||
6. **Trazabilidad**: cada decision pre-delegate (especialmente abort de DoD check) se persiste en `task_runs.events_json[]` con `agent: autopilot`.
|
|
||||||
|
|
||||||
## Flags
|
## Flags
|
||||||
|
|
||||||
| Flag | Default | Que hace |
|
| Flag | Default | Que hace |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `--max-iterations N` | 10 | Pasado al orquestador |
|
| `--max-iterations N` | 10 | Tope de iteraciones del bucle |
|
||||||
| `--max-minutes M` | 60 | Pasado al orquestador |
|
| `--max-minutes M` | 60 | Timeout total |
|
||||||
| `--dry-run` | off | Pasado al orquestador |
|
| `--dry-run` | off | Plan + dispatch simulado, no aplica cambios |
|
||||||
| `--allow-construct-missing` | off | Flow con `FALTA: crear <id>` → spawn fn-constructor antes |
|
| `--allow-construct-missing` | off | Si flow tiene `FALTA: crear <id>`, spawn fn-constructor antes |
|
||||||
| `--auto-apply-proposals` | `safe` | Pasado al orquestador |
|
| `--auto-apply-proposals` | `safe` | Pasado a fn-orquestador en Path A |
|
||||||
|
| `--max-self-answers N` | 20 | Tope de bloques Self-Q&A por run. Excedido -> ABORT `overdeliberating` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Errores canonicos
|
## Errores canonicos
|
||||||
|
|
||||||
| Codigo | Significado | Accion |
|
| Codigo | Significado | Accion |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `NOT_READY` | DoD insuficiente | Humano edita .md y relanza |
|
| `NOT_READY` | DoD insuficiente | Humano edita .md y relanza |
|
||||||
| `needs_human` | Decision ambigua | Humano resuelve y relanza |
|
| `needs_human` | Decision sin Recomendado | Humano resuelve y relanza |
|
||||||
| `delegated_failed` | fn-orquestador devolvio fail/stall/timeout | Humano lee `task_runs.events_json` |
|
| `stalled` | 3 iteraciones sin progreso | Humano revisa `events_json` |
|
||||||
| (resto) | Heredados del orquestador (stalled/timeout/aborted_protected_path/...) | Idem |
|
| `timeout` | Excedido `--max-minutes` | Aumentar timeout o partir issue |
|
||||||
|
| `aborted_protected_path` | Cambio en path protegido | Humano revisa intent |
|
||||||
|
| `iterations_exhausted` | Excedido `--max-iterations` | Humano evalua si vale subir tope |
|
||||||
|
| `sandbox_breach` | Diff fuera del worktree | ABORT critico, audit |
|
||||||
|
| `overdeliberating` | Excedido `--max-self-answers` | DoD probablemente vago — humano refina criterios |
|
||||||
|
| `low_confidence_abort` | Self-Q&A devolvio `confidence: low` en accion no reversible | Humano valida la decision concreta |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Anti-patrones
|
## Anti-patrones
|
||||||
|
|
||||||
| Anti-patron | Por que es malo |
|
| Anti-patron | Por que es malo |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Hacer Path B/C inline | Mismo bug de cwd mutation que paso 2026-05-19 |
|
| `/autopilot` sin pre-check DoD | Trabajar sin criterio de exito = bucle infinito |
|
||||||
| Saltar pre-flight DoD | Trabajar sin contrato = bucle infinito |
|
| Auto-relleno de DoD inventada | Criterios falsos -> falso "done" |
|
||||||
| Mergear sin tests verde | fn-orquestador ya impide esto, NO bypaseas |
|
| Merge a master sin tests verde | Master no deployable |
|
||||||
| `AskUserQuestion` desde autopilot | Rompe contrato autonomo |
|
| `AskUserQuestion` al humano | Rompe el contrato autonomo — usa self-Q&A loop |
|
||||||
| Crear worktree propio en autopilot | Duplica + colision con orquestador (paso 2026-05-19) |
|
| Self-Q&A sin razonamiento explicito | Decision opaca, no auditable |
|
||||||
|
| Self-Q&A con `confidence: high` en accion destructiva sin oraculo | Confianza injustificada — escalar |
|
||||||
|
| Salto de hooks (`--no-verify`) | Encubre bugs reales |
|
||||||
|
| Tocar mas issues que el target | Scope creep silencioso |
|
||||||
|
| Borrar archivos sin backup en events_json | Pierde auditoria |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relacion con otras reglas
|
||||||
|
|
||||||
|
- [[autonomous_loop]] — politica del bucle (sandbox, paths protegidos, watchdog).
|
||||||
|
- [[apps_tbd]] — politica TBD por tipo de cambio.
|
||||||
|
- [[apps_subrepo]] — `git init` dentro de apps nuevas antes de limpiar worktree.
|
||||||
|
- [[feature_flags]] — codigo incompleto detras de flag OFF.
|
||||||
|
- [[registry_calls]] — invocaciones canonicas (MCP / fn run / heredoc).
|
||||||
|
- [[e2e_validation]] — `e2e_checks` consumidos por fn-analizador como gate de Path A.
|
||||||
|
- [[delegation]] — spawn fn-constructor antes que escribir inline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Ejemplos
|
## Ejemplos
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Issue con DoD claro
|
# Issue registry-only con DoD claro
|
||||||
/autopilot 0107c
|
/autopilot 0107c
|
||||||
|
/autopilot i:0107c # equivalente con prefijo explicito
|
||||||
|
|
||||||
|
# Issue app que requiere orquestador
|
||||||
|
/autopilot issue:0070 --max-iterations 15 --max-minutes 90
|
||||||
|
|
||||||
# Flow con piezas faltantes — autoriza creacion antes
|
# Flow con piezas faltantes — autoriza creacion antes
|
||||||
/autopilot flow:0008 --allow-construct-missing
|
/autopilot flow:0008 --allow-construct-missing
|
||||||
|
|
||||||
# Solo audit
|
# Solo audit, no ejecutar
|
||||||
/autopilot check 0125
|
/autopilot check 0125
|
||||||
/autopilot check flow:0008
|
/autopilot check flow:0008
|
||||||
|
|
||||||
# Dry run
|
# Dry run
|
||||||
/autopilot 0107c --dry-run
|
/autopilot 0107c --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
## Relacion con otras reglas
|
|
||||||
|
|
||||||
- [[autonomous_loop]] — politica del bucle (sandbox, paths protegidos, watchdog). fn-orquestador la aplica.
|
|
||||||
- [[apps_tbd]] — politica TBD por tipo de cambio.
|
|
||||||
- [[apps_subrepo]] — `git init` dentro de apps nuevas antes de limpiar worktree.
|
|
||||||
- [[feature_flags]] — codigo incompleto detras de flag OFF.
|
|
||||||
- [[registry_calls]] — invocaciones canonicas.
|
|
||||||
- [[e2e_validation]] — `e2e_checks` consumidos por fn-analizador.
|
|
||||||
- [[delegation]] — spawn fn-constructor antes que escribir inline.
|
|
||||||
|
|
||||||
## Migracion desde `/autonomous-task`
|
|
||||||
|
|
||||||
`/autonomous-task` queda DEPRECADO. Sustitucion 1:1:
|
|
||||||
|
|
||||||
| Antes | Ahora |
|
|
||||||
|---|---|
|
|
||||||
| `/autonomous-task 0070` | `/autopilot 0070` |
|
|
||||||
| `/autonomous-task 0070 --max-iterations 15` | `/autopilot 0070 --max-iterations 15` |
|
|
||||||
| `/autonomous-task 0070 --dry-run` | `/autopilot 0070 --dry-run` |
|
|
||||||
|
|
||||||
`/autopilot` anade pre-flight DoD check + detect flow. Behaviour orquestador-side idem.
|
|
||||||
|
|
||||||
## Historico
|
|
||||||
|
|
||||||
- v1 (2026-05-15): introducido con Path A/B/C inline + self-Q&A.
|
|
||||||
- v2 (2026-05-19): simplificado tras incidente cwd mutation en piloto 0121b. Solo delega a fn-orquestador. Self-Q&A movido al orquestador. Sustituye a `/autonomous-task`.
|
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
---
|
|
||||||
description: "Lista todos los slash commands disponibles en el repo: globales de fn_registry + namespaced de cada project. Filtra por substring o por namespace."
|
|
||||||
---
|
|
||||||
|
|
||||||
# /commands — Catalogo de slash commands del repo
|
|
||||||
|
|
||||||
Inventario unificado. Lista los `.md` bajo `.claude/commands/` (recursivo, sigue symlinks) y agrupa por namespace.
|
|
||||||
|
|
||||||
## Sintaxis
|
|
||||||
|
|
||||||
```
|
|
||||||
/commands # listado completo agrupado por namespace
|
|
||||||
/commands <substring> # filtra por substring en nombre o descripcion
|
|
||||||
/commands --ns <namespace> # solo un namespace (global, aurgi, ...)
|
|
||||||
/commands --json # salida JSON para agentes
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementacion
|
|
||||||
|
|
||||||
Bash + awk. Parsea frontmatter `description:` de cada `.md`. Agrupa por subdirectorio (subdir = namespace, root = `global`).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
ROOT="${FN_REGISTRY_ROOT:-/home/egutierrez/fn_registry}"
|
|
||||||
CMD_DIR="$ROOT/.claude/commands"
|
|
||||||
|
|
||||||
# Recolecta: ns|name|description
|
|
||||||
collect() {
|
|
||||||
find -L "$CMD_DIR" -type f -name '*.md' | while read -r f; do
|
|
||||||
rel="${f#$CMD_DIR/}"
|
|
||||||
case "$rel" in
|
|
||||||
*/*) ns="${rel%%/*}"; name="${rel#*/}"; name="${name%.md}" ;;
|
|
||||||
*) ns="global"; name="${rel%.md}" ;;
|
|
||||||
esac
|
|
||||||
desc=$(awk '/^description:/ {sub(/^description:[[:space:]]*/, ""); gsub(/^"|"$/, ""); print; exit}' "$f")
|
|
||||||
printf '%s|%s|%s\n' "$ns" "$name" "${desc:-(sin descripcion)}"
|
|
||||||
done | sort
|
|
||||||
}
|
|
||||||
|
|
||||||
collect | awk -F'|' '
|
|
||||||
{
|
|
||||||
if ($1 != prev_ns) {
|
|
||||||
if (prev_ns) print ""
|
|
||||||
if ($1 == "global") print "## global (/<cmd>)"
|
|
||||||
else print "## " $1 " (/" $1 ":<cmd>)"
|
|
||||||
prev_ns = $1
|
|
||||||
}
|
|
||||||
printf "- /%s%s — %s\n", ($1=="global"?"":$1":"), $2, $3
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Filtros:
|
|
||||||
|
|
||||||
- Substring: `grep -i "<substring>"` sobre stdout.
|
|
||||||
- `--ns X`: filtrar antes del `awk` por `$1 == "X"`.
|
|
||||||
- `--json`: reemplazar el `awk` por `jq -Rsn` que construya array `{namespace, name, description, invocation}`.
|
|
||||||
|
|
||||||
## Salida (formato humano)
|
|
||||||
|
|
||||||
```
|
|
||||||
## global (/<cmd>)
|
|
||||||
- /app — Crear, configurar y desplegar apps del registry
|
|
||||||
- /autopilot — Modo full-auto...
|
|
||||||
- /commands — Catalogo de slash commands del repo
|
|
||||||
...
|
|
||||||
|
|
||||||
## aurgi (/aurgi:<cmd>)
|
|
||||||
- /aurgi:anadir_contexto_aurgi — Anade o modifica contexto...
|
|
||||||
- /aurgi:aumentar_task — Enriquece tarea Aurgi con preguntas...
|
|
||||||
- /aurgi:contexto_aurgi — Aprende el contexto de Aurgi...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cuando usarlo
|
|
||||||
|
|
||||||
- Sesion nueva: ver de un vistazo que slash commands hay disponibles.
|
|
||||||
- Antes de inventar logica inline: comprobar si ya existe un command.
|
|
||||||
- Auditoria: verificar que los projects exponen sus commands correctamente.
|
|
||||||
- Onboarding: nuevo PC clonado, descubrir capacidades del repo sin abrir N archivos.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- Sigue symlinks (`find -L`). Si un symlink apunta a directorio inexistente, devuelve vacio para esa rama — verificar con `ls -L .claude/commands/<ns>/`.
|
|
||||||
- Solo escanea `<root>/.claude/commands/`. Commands user-global en `~/.claude/commands/` NO entran (son personales, fuera del repo).
|
|
||||||
- Namespace = nombre del subdirectorio bajo `.claude/commands/`. Coincide con el project pero no por mecanismo — por convencion. Ver `.claude/rules/project_commands.md`.
|
|
||||||
- Para que un command de project aparezca aqui desde la raiz, hace falta el symlink (`.claude/commands/<project>` -> `../../projects/<project>/.claude/commands`).
|
|
||||||
+22
-59
@@ -1,74 +1,37 @@
|
|||||||
---
|
# /compile — Compila app C++ y la copia al escritorio de Windows
|
||||||
description: "Compila app del registry (C++ o Wails Go), copia el .exe a Desktop/apps/<app>/ y relanza en Windows. Wrapper sobre compile_cpp_app o compile_wails_app segun framework declarado en app.md."
|
|
||||||
---
|
|
||||||
|
|
||||||
# /compile — Compila app C++ o Wails y la copia al escritorio de Windows
|
Wrapper sobre el pipeline `compile_cpp_app_bash_pipelines`. Toda la lógica vive en el registry (resolver app desde CWD/arg, cross-compile MinGW, copiar exe + DLLs + assets/ + enrichers/ + runtime/ a `/mnt/c/Users/lucas/Desktop/apps/<app>/`, taskkill previo, preservar `local_files/`).
|
||||||
|
|
||||||
Wrapper sobre 2 pipelines del registry segun el framework:
|
|
||||||
|
|
||||||
- **C++ (imgui / cmake)** → `compile_cpp_app_bash_pipelines`. Cross-compile MinGW + assets/enrichers/runtime + taskkill, NO relanza.
|
|
||||||
- **Wails Go (matrix_client_pc, matrix_admin_panel, etc.)** → `compile_wails_app_bash_pipelines`. `wails build -platform windows/amd64` con `-tags goolm` si E2EE + taskkill + **RELANZA** la app tras copy.
|
|
||||||
|
|
||||||
Toda la logica vive en el registry (resolver app desde CWD/arg, build, deploy con preservacion de `local_files/`).
|
|
||||||
|
|
||||||
## Dispatch
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
|
./fn run compile_cpp_app "$ARGUMENTS"
|
||||||
# Detecta framework via wails.json o CMakeLists.txt en el dir del app
|
|
||||||
APP="$ARGUMENTS"
|
|
||||||
RESOLVED=$(bash -c '
|
|
||||||
source bash/functions/infra/resolve_cpp_app_dir.sh
|
|
||||||
resolve_cpp_app_dir "'"$APP"'"
|
|
||||||
' 2>/dev/null) || true
|
|
||||||
APP_DIR="$(echo "$RESOLVED" | cut -f2)"
|
|
||||||
|
|
||||||
if [ -n "$APP_DIR" ] && [ -f "$APP_DIR/wails.json" ]; then
|
|
||||||
./fn run compile_wails_app "$ARGUMENTS"
|
|
||||||
elif [ -n "$APP_DIR" ] && [ -f "$APP_DIR/CMakeLists.txt" ]; then
|
|
||||||
./fn run compile_cpp_app "$ARGUMENTS"
|
|
||||||
else
|
|
||||||
echo "ERROR: no se detecto framework (falta wails.json o CMakeLists.txt en $APP_DIR)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Argumento
|
## Argumento
|
||||||
|
|
||||||
`$ARGUMENTS` — opcional. Nombre de app (ej: `chart_demo`, `matrix_client_pc`).
|
`$ARGUMENTS` — opcional. Nombre de app (ej: `chart_demo`).
|
||||||
|
|
||||||
- Sin argumento: deduce desde `pwd` si estas dentro de `cpp/apps/<X>/`, `apps/<X>/` o `projects/*/apps/<X>/`.
|
- Sin argumento: deduce desde `pwd` si estás dentro de `cpp/apps/<X>/` o `projects/*/apps/<X>/`.
|
||||||
- Si no se puede deducir y no se pasa argumento, lista las apps disponibles en stderr y aborta.
|
- Si no se puede deducir y no se pasa argumento, el pipeline lista las apps disponibles en stderr y aborta.
|
||||||
|
|
||||||
## Que hace el pipeline (C++)
|
## Qué hace el pipeline
|
||||||
|
|
||||||
1. `resolve_cpp_app_dir_bash_infra` — resuelve `<app_name>` y `<dir absoluto>`.
|
1. `resolve_cpp_app_dir_bash_infra` — resuelve `<app_name>` y `<dir absoluto>` desde arg o CWD.
|
||||||
2. Verifica `CMakeLists.txt`.
|
2. Verifica `CMakeLists.txt` en el dir resuelto.
|
||||||
3. `build_cpp_windows_bash_infra <app>` — cross-compila con MinGW.
|
3. `build_cpp_windows_bash_infra <app>` — cross-compila el target específico con `cpp/build/windows/` (configura toolchain `mingw-w64.cmake` la primera vez).
|
||||||
4. `deploy_cpp_exe_to_windows_bash_infra <app> <dir>`:
|
4. `deploy_cpp_exe_to_windows_bash_infra <app> <dir>`:
|
||||||
- `taskkill.exe /IM <app>.exe /F`.
|
- `taskkill.exe /IM <app>.exe /F` (pre-autorizado).
|
||||||
- Copia `<app>.exe` + DLLs.
|
- Copia `<app>.exe` + DLLs al top-level de `Desktop/apps/<app>/`.
|
||||||
- rsync `assets/`, `enrichers/`, `runtime/` (si aplica).
|
- rsync `cpp/build/windows/apps/<app>/assets/` → `Desktop/apps/<app>/assets/`.
|
||||||
- Preserva `local_files/`.
|
- rsync `<app_dir>/enrichers/` → `assets/enrichers/` si existe.
|
||||||
- **NO** relanza.
|
- Si `app.md` declara `python_runtime: true`, regenera `runtime/` con `tools/freeze_python_runtime.sh` y rsync a `assets/runtime/`.
|
||||||
|
- Copia `gx-cli`/`gx-cli.exe` si existen.
|
||||||
## Que hace el pipeline (Wails)
|
- **NUNCA** toca `local_files/` (estado del usuario).
|
||||||
|
5. Imprime `ls -lh` del `.exe` final.
|
||||||
1. `resolve_cpp_app_dir_bash_infra` (reusado — sirve para Wails apps tambien).
|
|
||||||
2. Verifica `wails.json` + `go.mod`.
|
|
||||||
3. Detecta `-tags goolm` automaticamente (grep `matrix_crypto_init` en `app.md` o `build:tags` en `wails.json`).
|
|
||||||
4. `wails build -platform windows/amd64 [-tags goolm]`.
|
|
||||||
5. `deploy_wails_exe_to_windows_bash_infra <app> <dir>`:
|
|
||||||
- `taskkill.exe /IM <app>.exe /F`.
|
|
||||||
- Copia `<app>.exe` (+ `appicon.ico` si existe).
|
|
||||||
- **Relanza** via `cmd.exe /c start "" <app>.exe`.
|
|
||||||
- Preserva `local_files/`.
|
|
||||||
|
|
||||||
## Notas
|
## Notas
|
||||||
|
|
||||||
- Solo target Windows hoy. Linux ya lo da `wails build` / `cpp/build/` nativo.
|
- Solo target Windows hoy. Android / Linux quedan fuera (Linux ya lo da `cpp/build/`).
|
||||||
- Variables override-ables: `BUILD_WIN`, `WIN_DESKTOP_APPS`, `FN_REGISTRY_ROOT`.
|
- Variables override-ables: `BUILD_WIN`, `WIN_DESKTOP_APPS`, `FN_REGISTRY_ROOT`.
|
||||||
- Si la app C++ no esta registrada en `cpp/CMakeLists.txt`, el build falla — registrar siguiendo `.claude/rules/cpp_apps.md` §5.
|
- Si la app no está registrada en `cpp/CMakeLists.txt`, `cmake --build --target <app>` falla. Registrar siguiendo `.claude/rules/cpp_apps.md` §5.
|
||||||
- Si la app Wails falla build con `no required module provides package`, correr `go mod tidy` en el dir del app primero.
|
- Para tocar la lógica: editar `bash/functions/{infra,pipelines}/{resolve_cpp_app_dir,deploy_cpp_exe_to_windows,compile_cpp_app}.sh`, no este wrapper.
|
||||||
- Para tocar la logica: editar `bash/functions/{infra,pipelines}/{resolve_cpp_app_dir,build_cpp_windows,deploy_{cpp,wails}_exe_to_windows,compile_{cpp,wails}_app}.sh`, no este wrapper.
|
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ Si `$ARGUMENTS` no empieza por `modify`, es create. Si trae `<name>`, lo usas co
|
|||||||
### Paso 0 — verificar que no existe
|
### Paso 0 — verificar que no existe
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
test -d "$HOME/fn_registry/apps/<name>" \
|
test -d "/home/lucas/fn_registry/apps/<name>" \
|
||||||
|| ls $HOME/fn_registry/projects/*/apps/<name> 2>/dev/null
|
|| ls /home/lucas/fn_registry/projects/*/apps/<name> 2>/dev/null
|
||||||
```
|
```
|
||||||
|
|
||||||
Si existe en cualquier ubicacion: **abortar** y sugerir `/cpp-app modify <name>`. NO sobreescribir.
|
Si existe en cualquier ubicacion: **abortar** y sugerir `/cpp-app modify <name>`. NO sobreescribir.
|
||||||
@@ -42,7 +42,7 @@ Regla dura `cpp_apps.md`: description + icon.phosphor + icon.accent SIEMPRE junt
|
|||||||
|
|
||||||
5. **icon.phosphor** glyph name. Antes de preguntar, ofrece busqueda:
|
5. **icon.phosphor** glyph name. Antes de preguntar, ofrece busqueda:
|
||||||
```bash
|
```bash
|
||||||
ls $HOME/fn_registry/sources/phosphor-core/assets/fill/ | grep -i "<keyword>"
|
ls /home/lucas/fn_registry/sources/phosphor-core/assets/fill/ | grep -i "<keyword>"
|
||||||
```
|
```
|
||||||
Sugiere 3-5 candidatos basados en `description`. Default segun domain: `gfx`->`palette`, `tui`->`terminal`, `tools`->`wrench`, `infra`->`gear`, `finance`->`chart-line-up`, `datascience`->`graph`, `cybersecurity`->`shield`.
|
Sugiere 3-5 candidatos basados en `description`. Default segun domain: `gfx`->`palette`, `tui`->`terminal`, `tools`->`wrench`, `infra`->`gear`, `finance`->`chart-line-up`, `datascience`->`graph`, `cybersecurity`->`shield`.
|
||||||
6. **icon.accent** hex `#rrggbb` (palette select):
|
6. **icon.accent** hex `#rrggbb` (palette select):
|
||||||
@@ -122,7 +122,7 @@ Mostrar bloque YAML completo del `app.md` que se va a generar + flags del scaffo
|
|||||||
Una vez confirmado:
|
Una vez confirmado:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
|
|
||||||
# 1. Scaffolder
|
# 1. Scaffolder
|
||||||
./fn run init_cpp_app <name> \
|
./fn run init_cpp_app <name> \
|
||||||
@@ -178,7 +178,7 @@ cd $HOME/fn_registry
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Buscar apps/<name>/ o projects/*/apps/<name>/
|
# Buscar apps/<name>/ o projects/*/apps/<name>/
|
||||||
sqlite3 $HOME/fn_registry/registry.db \
|
sqlite3 /home/lucas/fn_registry/registry.db \
|
||||||
"SELECT id, dir_path FROM apps WHERE name='<name>' AND lang='cpp';"
|
"SELECT id, dir_path FROM apps WHERE name='<name>' AND lang='cpp';"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -211,7 +211,7 @@ Para cada cambio: usa `Edit` sobre los archivos correspondientes. NUNCA `Write`
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Siempre
|
# Siempre
|
||||||
cd $HOME/fn_registry && ./fn index
|
cd /home/lucas/fn_registry && ./fn index
|
||||||
|
|
||||||
# Si toco icon.* -> regenerar appicon
|
# Si toco icon.* -> regenerar appicon
|
||||||
./fn run generate_app_icon "<phosphor>" "<accent>" "<dir>/appicon.ico"
|
./fn run generate_app_icon "<phosphor>" "<accent>" "<dir>/appicon.ico"
|
||||||
|
|||||||
@@ -38,19 +38,19 @@ Consultar `registry.db` para encontrar funciones existentes relevantes y evitar
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Buscar funciones similares por nombre y descripcion (OBLIGATORIO — usar multiples terminos)
|
# Buscar funciones similares por nombre y descripcion (OBLIGATORIO — usar multiples terminos)
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, lang, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO1* OR description:TERMINO1* OR name:TERMINO2* OR description:TERMINO2*') ORDER BY name;"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, lang, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO1* OR description:TERMINO1* OR name:TERMINO2* OR description:TERMINO2*') ORDER BY name;"
|
||||||
|
|
||||||
# Buscar tipos relacionados
|
# Buscar tipos relacionados
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, algebraic, lang, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, lang, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||||
|
|
||||||
# Funciones del dominio objetivo
|
# Funciones del dominio objetivo
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, signature, description FROM functions WHERE domain = 'DOMINIO' AND lang = 'LANG' ORDER BY name;"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, signature, description FROM functions WHERE domain = 'DOMINIO' AND lang = 'LANG' ORDER BY name;"
|
||||||
|
|
||||||
# Tipos del dominio objetivo
|
# Tipos del dominio objetivo
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'DOMINIO' ORDER BY name;"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'DOMINIO' ORDER BY name;"
|
||||||
|
|
||||||
# Funciones que podrian componerse (misma firma de retorno)
|
# Funciones que podrian componerse (misma firma de retorno)
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, purity, signature FROM functions WHERE returns LIKE '%TIPO%' OR signature LIKE '%TIPO%' ORDER BY name;"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, purity, signature FROM functions WHERE returns LIKE '%TIPO%' OR signature LIKE '%TIPO%' ORDER BY name;"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Clasificar resultados en:**
|
**Clasificar resultados en:**
|
||||||
@@ -103,7 +103,7 @@ Para cada batch del plan, lanzar agentes `fn-constructor` **en paralelo** (un ag
|
|||||||
Usar el Agent tool con `subagent_type: "fn-constructor"` pasando un prompt completo con:
|
Usar el Agent tool con `subagent_type: "fn-constructor"` pasando un prompt completo con:
|
||||||
|
|
||||||
```
|
```
|
||||||
Crea la siguiente funcion para el registry fn_registry en $HOME/fn_registry:
|
Crea la siguiente funcion para el registry fn_registry en /home/lucas/fn_registry:
|
||||||
|
|
||||||
Funcion: {nombre}
|
Funcion: {nombre}
|
||||||
Kind: {kind}
|
Kind: {kind}
|
||||||
@@ -149,7 +149,7 @@ Despues de que TODOS los fn-constructor terminen:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Indexar todo de una vez
|
# Indexar todo de una vez
|
||||||
cd $HOME/fn_registry && ./fn index
|
cd /home/lucas/fn_registry && ./fn index
|
||||||
```
|
```
|
||||||
|
|
||||||
Si el indexer reporta errores, corregirlos antes de continuar. Errores comunes:
|
Si el indexer reporta errores, corregirlos antes de continuar. Errores comunes:
|
||||||
@@ -166,7 +166,7 @@ Si el indexer reporta errores, corregirlos antes de continuar. Errores comunes:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Verificar cada funcion creada
|
# Verificar cada funcion creada
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
./fn show {id_de_cada_funcion}
|
./fn show {id_de_cada_funcion}
|
||||||
|
|
||||||
# Verificar que no hay funciones sin params_schema
|
# Verificar que no hay funciones sin params_schema
|
||||||
@@ -178,7 +178,7 @@ cd $HOME/fn_registry
|
|||||||
Para cada funcion con tests, ejecutar:
|
Para cada funcion con tests, ejecutar:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
|
|
||||||
# Go
|
# Go
|
||||||
CGO_ENABLED=1 go test -tags fts5 -v -run TestNombreDelTest ./functions/{domain}/
|
CGO_ENABLED=1 go test -tags fts5 -v -run TestNombreDelTest ./functions/{domain}/
|
||||||
@@ -197,13 +197,13 @@ bash bash/functions/{domain}/{nombre}_test.sh
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Verificar que todas las funciones nuevas estan en la BD
|
# Verificar que todas las funciones nuevas estan en la BD
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, tested FROM functions WHERE id IN ('id1','id2','id3') ORDER BY name;"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, tested FROM functions WHERE id IN ('id1','id2','id3') ORDER BY name;"
|
||||||
|
|
||||||
# Verificar que los tests estan indexados
|
# Verificar que los tests estan indexados
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, function_id, name FROM unit_tests WHERE function_id IN ('id1','id2','id3') ORDER BY function_id;"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, function_id, name FROM unit_tests WHERE function_id IN ('id1','id2','id3') ORDER BY function_id;"
|
||||||
|
|
||||||
# Verificar dependencias
|
# Verificar dependencias
|
||||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, uses_functions, uses_types FROM functions WHERE id IN ('id1','id2','id3') AND uses_functions != '[]';"
|
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, uses_functions, uses_types FROM functions WHERE id IN ('id1','id2','id3') AND uses_functions != '[]';"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6.4 Si algo fallo
|
### 6.4 Si algo fallo
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ Antes de escribir nada, repasar la conversacion y juntar:
|
|||||||
|
|
||||||
2. **Cambios concretos** desde git:
|
2. **Cambios concretos** desde git:
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
git status --short
|
git status --short
|
||||||
git diff --stat
|
git diff --stat
|
||||||
git log --since="6 hours ago" --oneline
|
git log --since="6 hours ago" --oneline
|
||||||
@@ -70,7 +70,7 @@ Si el material es solo conversacion exploratoria sin artefactos tocados, ir dire
|
|||||||
Para cada artefacto identificado, localizar su `.md` consultando `registry.db`:
|
Para cada artefacto identificado, localizar su `.md` consultando `registry.db`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
|
|
||||||
# Funcion / tipo
|
# Funcion / tipo
|
||||||
sqlite3 registry.db "SELECT id, file_path FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:NAME* OR description:NAME*');"
|
sqlite3 registry.db "SELECT id, file_path FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:NAME* OR description:NAME*');"
|
||||||
@@ -180,7 +180,7 @@ Para cada `.md` identificado:
|
|||||||
Si los cambios de la sesion incluyen creacion de funciones/tipos/apps/projects/analysis/vaults o modificacion de frontmatter:
|
Si los cambios de la sesion incluyen creacion de funciones/tipos/apps/projects/analysis/vaults o modificacion de frontmatter:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry && ./fn index
|
cd /home/lucas/fn_registry && ./fn index
|
||||||
```
|
```
|
||||||
|
|
||||||
Y verificar:
|
Y verificar:
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Suite ya instalada en `cpp/vendor/imgui_test_engine/`. Integracion en framework:
|
|||||||
### 1. Resolver app y directorio
|
### 1. Resolver app y directorio
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ROOT=$HOME/fn_registry
|
ROOT=/home/lucas/fn_registry
|
||||||
ARGS="$ARGUMENTS"
|
ARGS="$ARGUMENTS"
|
||||||
APP_ARG="${ARGS%% *}" # primera palabra
|
APP_ARG="${ARGS%% *}" # primera palabra
|
||||||
FLOW_DESC="${ARGS#* }" # resto (puede coincidir con APP_ARG si solo hay una palabra)
|
FLOW_DESC="${ARGS#* }" # resto (puede coincidir con APP_ARG si solo hay una palabra)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Wrapper sobre `append_diary_entry_bash_infra`. La función del registry maneja t
|
|||||||
|
|
||||||
2. **Llamar la función del registry**:
|
2. **Llamar la función del registry**:
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
source bash/functions/infra/append_diary_entry.sh
|
source bash/functions/infra/append_diary_entry.sh
|
||||||
append_diary_entry "<TITULO>" "$(cat <<'EOF'
|
append_diary_entry "<TITULO>" "$(cat <<'EOF'
|
||||||
<CUERPO>
|
<CUERPO>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ Issue 0085 fase autocompleta. Reemplaza el flujo manual de "veo un patron, decid
|
|||||||
### 1. AUDIT — ¿estoy siendo registrado?
|
### 1. AUDIT — ¿estoy siendo registrado?
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ROOT="$HOME/fn_registry"
|
ROOT="/home/lucas/fn_registry"
|
||||||
MON="$ROOT/projects/fn_monitoring/apps/call_monitor/operations.db"
|
MON="$ROOT/projects/fn_monitoring/apps/call_monitor/operations.db"
|
||||||
|
|
||||||
# Pre-condiciones
|
# Pre-condiciones
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Wrapper sobre el pipeline `full_git_pull_bash_pipelines`. Toda la lógica vive en el registry. Este comando solo ejecuta:
|
Wrapper sobre el pipeline `full_git_pull_bash_pipelines`. Toda la lógica vive en el registry. Este comando solo ejecuta:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
./fn run full_git_pull_bash_pipelines
|
./fn run full_git_pull_bash_pipelines
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Wrapper sobre el pipeline `full_git_push_bash_pipelines`. Toda la lógica vive en el registry. Este comando solo ejecuta:
|
Wrapper sobre el pipeline `full_git_push_bash_pipelines`. Toda la lógica vive en el registry. Este comando solo ejecuta:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd "${FN_REGISTRY_ROOT:-$HOME/fn_registry}"
|
cd /home/lucas/fn_registry
|
||||||
./fn run full_git_push_bash_pipelines "$ARGUMENTS"
|
./fn run full_git_push_bash_pipelines "$ARGUMENTS"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Wrapper sobre el pipeline `init_cpp_app_bash_pipelines`. Genera la estructura canonica que cumple `cpp/PATTERNS.md` y `.claude/rules/cpp_apps.md` (main.cpp con `cfg.about/log/panels`, sin `app_menubar` manual, dockspace via framework), registra la app en `cpp/CMakeLists.txt`, crea repo Gitea `dataforge/<name>` y ejecuta `fn index`.
|
Wrapper sobre el pipeline `init_cpp_app_bash_pipelines`. Genera la estructura canonica que cumple `cpp/PATTERNS.md` y `.claude/rules/cpp_apps.md` (main.cpp con `cfg.about/log/panels`, sin `app_menubar` manual, dockspace via framework), registra la app en `cpp/CMakeLists.txt`, crea repo Gitea `dataforge/<name>` y ejecuta `fn index`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd $HOME/fn_registry
|
cd /home/lucas/fn_registry
|
||||||
./fn run init_cpp_app $ARGUMENTS
|
./fn run init_cpp_app $ARGUMENTS
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Si vacio: detectar app desde `pwd` (si estas dentro de `apps/<X>/` o `projects/*
|
|||||||
### 1. Resolver app objetivo
|
### 1. Resolver app objetivo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ROOT=$HOME/fn_registry
|
ROOT=/home/lucas/fn_registry
|
||||||
ARG="$ARGUMENTS"
|
ARG="$ARGUMENTS"
|
||||||
|
|
||||||
if [ -z "$ARG" ]; then
|
if [ -z "$ARG" ]; then
|
||||||
|
|||||||
@@ -37,5 +37,3 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
|||||||
| 30 | [function_growth_and_self_docs.md](function_growth_and_self_docs.md) | Contrato self-doc de cada `.md` (Ejemplo + Cuando usarla + Gotchas + Growth log) + crecimiento del registry por **promocion de composiciones** a pipelines, NO por inflado de funciones. Issue 0087 |
|
| 30 | [function_growth_and_self_docs.md](function_growth_and_self_docs.md) | Contrato self-doc de cada `.md` (Ejemplo + Cuando usarla + Gotchas + Growth log) + crecimiento del registry por **promocion de composiciones** a pipelines, NO por inflado de funciones. Issue 0087 |
|
||||||
| 31 | [autonomous_loop.md](autonomous_loop.md) | Reglas para `fn-orquestador` + `/autonomous-task`: sandbox obligatorio, paths protegidos, filtro proposals auto-aplicables, watchdog, idempotencia. Issue 0069 |
|
| 31 | [autonomous_loop.md](autonomous_loop.md) | Reglas para `fn-orquestador` + `/autonomous-task`: sandbox obligatorio, paths protegidos, filtro proposals auto-aplicables, watchdog, idempotencia. Issue 0069 |
|
||||||
| 32 | [../../dev/TAXONOMY.md](../../dev/TAXONOMY.md) | Allowlist canonica para dominios/tipos/scopes/estados/prioridades + flow patterns. Aplica a `dev/issues/` y `dev/flows/`. Issues 0100 + 0103 |
|
| 32 | [../../dev/TAXONOMY.md](../../dev/TAXONOMY.md) | Allowlist canonica para dominios/tipos/scopes/estados/prioridades + flow patterns. Aplica a `dev/issues/` y `dev/flows/`. Issues 0100 + 0103 |
|
||||||
| 33 | [project_commands.md](project_commands.md) | Slash commands por project (`.claude/commands/<project>/`) expuestos via symlink. Desde fn_registry: `/<project>:foo`. Desde el project: `/foo`. Sin colision. |
|
|
||||||
| 34 | [dod_quality.md](dod_quality.md) | DoD Quality Triada: Mecanica + Cobertura (golden + edge + error path con evidencia ejecutable) + Vida util validada (>=7 dias uso real). Cierra anti-criterios contra checkbox vago. Aplica a `dev/flows/` y issues user-facing. |
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Agente trabaja en worktree del repo padre
|
# 1. Agente trabaja en worktree del repo padre
|
||||||
cd $HOME/fn_registry/worktrees/<slug>
|
cd /home/lucas/fn_registry/worktrees/<slug>
|
||||||
|
|
||||||
# 2. Scaffold la app via pipeline canonico
|
# 2. Scaffold la app via pipeline canonico
|
||||||
./fn run init_cpp_app <name> # apps C++
|
./fn run init_cpp_app <name> # apps C++
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
# DoD Quality Triada
|
|
||||||
|
|
||||||
**Definition of Done no es un checkbox que se marca a mano. Es un contrato de calidad con 3 capas obligatorias + evidencia ejecutable + uso real >=7 dias.**
|
|
||||||
|
|
||||||
Aplica a todos los `dev/flows/` y, por extension, a issues que cierran capabilities user-facing (`dev/issues/`). El registry mismo (funciones puras, tipos) queda exento: su DoD vive en sus tests unitarios.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Por que existe esta regla
|
|
||||||
|
|
||||||
El antipatron a eliminar: "tarea hecha porque pase los tests una vez". Despues:
|
|
||||||
- El flow funciona en `home-wsl` pero falla en `pc-aurgi`.
|
|
||||||
- El error path declarado nunca se ejercito y cuando ocurre en produccion no esta manejado.
|
|
||||||
- El dashboard de observabilidad lleva 30 dias sin abrirse.
|
|
||||||
- El proceso muere cada noche y nadie lo ve hasta que el operador intenta usarlo.
|
|
||||||
- El approval flow se salta porque "para test es mas comodo".
|
|
||||||
|
|
||||||
Resultado: deuda invisible. Cada flow "done" se rompe al primer uso real, el operador pierde confianza en el sistema, y el bucle reactivo no detecta nada porque la telemetria esta verde (los tests sintenticos pasan).
|
|
||||||
|
|
||||||
DoD Quality Triada cambia las reglas: cerrar = probar comportamiento + sobrevivir uso real, no = compilar verde.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Las 3 capas
|
|
||||||
|
|
||||||
### Capa 1: Mecanica (pre-requisito, NO es DoD por si misma)
|
|
||||||
|
|
||||||
Compilar verde, tests verdes, indexado limpio, `fn doctor` verde, `uses_functions` sin drift.
|
|
||||||
|
|
||||||
**Regla**: la mecanica NO basta. Es la base para empezar a probar comportamiento. Si te quedas aqui, el flow no esta hecho.
|
|
||||||
|
|
||||||
### Capa 2: Cobertura de comportamiento
|
|
||||||
|
|
||||||
Cada escenario relevante con prueba ejecutable y assert material. NO smoke "el comando no peto". Minimo:
|
|
||||||
|
|
||||||
- **1 golden path** — el caso feliz documentado con assert sobre output concreto.
|
|
||||||
- **>=2 edge cases** — inputs limite, estados raros, condiciones de borde.
|
|
||||||
- **>=1 error path** — fallo provocado intencionalmente, manejado y observable (sin crash, sin silent-fail).
|
|
||||||
|
|
||||||
Formato canonico (tabla en `## Definition of Done` del flow/issue):
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Golden: <desc> | unit / e2e | `<cmd>` | <output concreto> |
|
|
||||||
| Edge 1: <desc> | unit / e2e | `<cmd>` | <comportamiento concreto> |
|
|
||||||
| Error 1: <desc> | e2e | `<cmd que rompe>` | <fallo manejado, no crash> |
|
|
||||||
```
|
|
||||||
|
|
||||||
Cuando aplique, cada fila genera un `e2e_check` en el `app.md` correspondiente (issue 0068). `fn-analizador` los corre periodicamente y deja entry en `e2e_runs`.
|
|
||||||
|
|
||||||
### Capa 3: Vida util validada
|
|
||||||
|
|
||||||
El flow no esta hecho hasta que sobrevive **uso real durante >=7 dias** sin romperse silenciosamente. Cada metrica con umbral medible y dashboard observable.
|
|
||||||
|
|
||||||
Formato canonico:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
| Metrica | Umbral | Donde se observa | Ventana |
|
|
||||||
|---|---|---|---|
|
|
||||||
| <metrica 1> | `>=N` | `<dashboard URL / app panel>` | 7 dias |
|
|
||||||
| crashes | `0` | `journalctl -u <unit>` | 7 dias |
|
|
||||||
| huecos audit chain | `0` | `cmd: <verify>` | continuo |
|
|
||||||
```
|
|
||||||
|
|
||||||
Reglas:
|
|
||||||
- Metricas NO se auto-reportan; las lee el operador del dashboard real.
|
|
||||||
- Si el dashboard no existe o no se ha abierto en 30 dias, el item se invalida.
|
|
||||||
- Crashes del proceso = 0, huecos en audit = 0, error_rate < umbral declarado.
|
|
||||||
|
|
||||||
### Capa transversal: User-facing reforzado
|
|
||||||
|
|
||||||
- Surface concreta NO BD ni log (UI app, room Matrix, dashboard, archivo en vault).
|
|
||||||
- Usage real: humano usa en su PC, su contexto, >=N veces variadas en >=7 dias.
|
|
||||||
- Variado: >=3 capabilities/casos distintos (no solo "abre dashboard y mira").
|
|
||||||
- Onboarding: parrafo en `## Notas` que explica como usar la cosa sin leer el flow.
|
|
||||||
- Latencia medida (no declarada).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Reglas duras para marcar `status: done`
|
|
||||||
|
|
||||||
`/flow done` (y por extension cierres de issues user-facing) DEBE rechazar el cierre si:
|
|
||||||
|
|
||||||
1. Falta cualquiera de las 3 capas (mecanica + cobertura + vida).
|
|
||||||
2. Cobertura tiene <1 golden, <2 edge, o <1 error path con evidencia.
|
|
||||||
3. Vida util tiene tabla vacia o sin dashboard observable real.
|
|
||||||
4. User-facing usage real <7 dias o <N usos declarados.
|
|
||||||
5. Cualquier anti-criterio marcado como cierto.
|
|
||||||
6. `## Notas` sin parrafo onboarding.
|
|
||||||
7. Algun item de DoD sin comando/URL/log query asociado — solo texto.
|
|
||||||
|
|
||||||
Hoy parte de esta validacion es manual (revision humana del operador). La validacion programatica vive en `audit_dod_schema_go_infra` (issue 0114) + `fn doctor dod` y se ampliara hasta cubrir las 3 capas (TBD).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Antipatrones (invalidan la DoD aunque los checkboxes esten verdes)
|
|
||||||
|
|
||||||
| Antipatron | Por que es malo | Sustituir por |
|
|
||||||
|---|---|---|
|
|
||||||
| Marcar `done` porque pasa una vez | Tarea "hecha" se rompe al primer uso real | Capa 3: >=7 dias de uso real |
|
|
||||||
| Checkbox sin evidencia ejecutable | DoD se convierte en placebo | Cada item con `cmd:` / URL / log query |
|
|
||||||
| Test que solo verifica camino feliz | El error path es donde se pierden datos | Capa 2: >=1 error path ejercitado |
|
|
||||||
| Observabilidad declarada pero dashboard no abierto en 30 dias | Telemetria muerta = ceguera | Capa 3: dashboard real, operador lo abre |
|
|
||||||
| "Repetible 3 veces consecutivas" con BD efimera | No prueba sobre datos reales acumulados | Capa 3: PC real del operador, datos vivos |
|
|
||||||
| Approval saltado en algun camino | Security gate roto pero invisible | Anti-criterio explicito: `audit_log` lo prueba |
|
|
||||||
| Error path manejado solo "en teoria" | Cuando ocurra en produccion el manejo no existe | Capa 2: entry real en `e2e_runs` o audit |
|
|
||||||
| Solo-en-mi-PC | Falla en otra maquina del operador | Anti-criterio explicito, probar >=2 PCs |
|
|
||||||
| Self-test que retorna `pass` sin asserts materiales | False positive sistemico | Asserts sobre output concreto, no exit-0 |
|
|
||||||
| Silent-fail (proceso muere sin alerta) | Operador no se entera hasta intentar usar | Capa 3: crashes=0 + alerta visible |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Relacion con otras reglas
|
|
||||||
|
|
||||||
- [[e2e_validation]] — los escenarios de Capa 2 cuando aplican a apps se materializan como `e2e_checks` en `app.md`. `fn-analizador` (fase 4 del bucle reactivo) los corre.
|
|
||||||
- [[registry_calls]] — la evidencia de uso (`call_monitor.calls`) alimenta los umbrales de Capa 3.
|
|
||||||
- [[function_growth_and_self_docs]] — cada funcion del registry tiene su propio contrato self-doc (Ejemplo + Cuando usarla + Gotchas). DoD del flow NO sustituye al self-doc de la funcion; lo complementa para el nivel sistema.
|
|
||||||
- [[autonomous_loop]] — `fn-orquestador` autonomo NO puede marcar `done` sin que se cumplan las 3 capas. Su criterio de convergencia incluye DoD Quality.
|
|
||||||
- [[apps_tbd]] — TBD garantiza master desplegable; DoD garantiza que lo desplegado funciona en uso real.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TL;DR
|
|
||||||
|
|
||||||
1. **Mecanica** = compilar verde (pre-requisito, NO suficiente).
|
|
||||||
2. **Cobertura** = golden + >=2 edge + >=1 error path con evidencia ejecutable.
|
|
||||||
3. **Vida util** = >=7 dias de uso real sin romper silenciosamente, dashboard observable abierto.
|
|
||||||
4. **User-facing reforzado** = humano usa en PC real, >=N veces variadas.
|
|
||||||
5. **Anti-criterios** invalidan la DoD aunque todo este verde.
|
|
||||||
6. Sin evidencia ejecutable (cmd/URL/log), NO es DoD: es deseo.
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
## Slash commands por project (namespaced)
|
|
||||||
|
|
||||||
Cada `projects/<p>/` puede tener su propio `.claude/commands/*.md`. Para invocarlos desde la raiz de `fn_registry` sin que pisen los comandos globales, se exponen via **symlink namespaced** en `fn_registry/.claude/commands/<project>/`.
|
|
||||||
|
|
||||||
### Patron canonico
|
|
||||||
|
|
||||||
```
|
|
||||||
projects/aurgi/.claude/commands/foo.md # archivo real (viaja con el sub-repo del project)
|
|
||||||
fn_registry/.claude/commands/aurgi -> symlink -> ../../projects/aurgi/.claude/commands
|
|
||||||
```
|
|
||||||
|
|
||||||
Resultado:
|
|
||||||
|
|
||||||
| cwd | Invocacion |
|
|
||||||
|---|---|
|
|
||||||
| `cd projects/aurgi && claude` | `/foo` (sin namespace) |
|
|
||||||
| `cd fn_registry && claude` | `/aurgi:foo` (namespaced, no colisiona con `/foo` global) |
|
|
||||||
|
|
||||||
Subdirs dentro de `.claude/commands/` se exponen como namespace en el slash command. Por eso `aurgi/foo.md` -> `/aurgi:foo`.
|
|
||||||
|
|
||||||
### Como anadir un project nuevo
|
|
||||||
|
|
||||||
1. `mkdir -p projects/<p>/.claude/commands/`.
|
|
||||||
2. Crear `<comando>.md` con frontmatter `description:` + cuerpo.
|
|
||||||
3. Symlink: `ln -sf ../../projects/<p>/.claude/commands /home/egutierrez/fn_registry/.claude/commands/<p>`.
|
|
||||||
4. Versionar el `.claude/commands/` del project en su propio sub-repo (NO en fn_registry — projects estan gitignored).
|
|
||||||
5. Versionar SOLO el symlink en fn_registry (`git add .claude/commands/<p>`).
|
|
||||||
|
|
||||||
### Reglas
|
|
||||||
|
|
||||||
- Cada project mantiene autonomia: sus commands viajan con el sub-repo y funcionan tanto en `cd projects/<p>` como desde la raiz.
|
|
||||||
- El symlink en fn_registry da acceso global con namespace — sin colision con commands del registry.
|
|
||||||
- NO duplicar contenido: archivo real solo en `projects/<p>/.claude/commands/`. fn_registry solo guarda el symlink.
|
|
||||||
- Si el project se mueve/elimina, borrar el symlink en fn_registry.
|
|
||||||
|
|
||||||
### Listado actual
|
|
||||||
|
|
||||||
| Project | Symlink | Commands disponibles desde fn_registry |
|
|
||||||
|---|---|---|
|
|
||||||
| aurgi | `.claude/commands/aurgi` | `/aurgi:aumentar_task`, `/aurgi:contexto_aurgi`, `/aurgi:anadir_contexto_aurgi` |
|
|
||||||
|
|
||||||
Anadir filas aqui al introducir un project nuevo con commands.
|
|
||||||
|
|
||||||
### Catalogo dinamico
|
|
||||||
|
|
||||||
Para listado en tiempo real (sin tener que actualizar esta tabla a mano): `/commands` escanea `.claude/commands/` recursivo y agrupa por namespace. Filtros: `/commands <substring>`, `/commands --ns <ns>`, `/commands --json`.
|
|
||||||
|
|
||||||
### Gotchas
|
|
||||||
|
|
||||||
- Claude Code lista los commands disponibles al inicio de sesion. Si un symlink apunta a un directorio inexistente, los commands no aparecen — verificar con `ls -L .claude/commands/<project>/`.
|
|
||||||
- El namespace usa el nombre del subdirectorio (`aurgi/`), no del project en `projects/`. Mantenerlos iguales para evitar confusion.
|
|
||||||
- Los commands del project se ejecutan con el cwd de la sesion actual. Un `/aurgi:aumentar_task` invocado desde `fn_registry/` corre con cwd `fn_registry/` — paths relativos en el `.md` deben asumir esto (siempre usar paths relativos al repo, ej. `projects/aurgi/vaults/...`).
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"hooks": {
|
|
||||||
"PreToolUse": [
|
|
||||||
{
|
|
||||||
"matcher": "Bash",
|
|
||||||
"hooks": [
|
|
||||||
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_mcp.sh" },
|
|
||||||
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_fn_match.sh" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"PostToolUse": [
|
|
||||||
{
|
|
||||||
"matcher": "Bash|Edit|Write|MultiEdit|mcp__registry__.*",
|
|
||||||
"hooks": [
|
|
||||||
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_call_monitor.sh" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"matcher": "Edit|Write|MultiEdit|mcp__registry__fn_create_function",
|
|
||||||
"hooks": [
|
|
||||||
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capability_tag_gate.sh" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"UserPromptSubmit": [
|
|
||||||
{
|
|
||||||
"hooks": [
|
|
||||||
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capabilities_inject.sh" },
|
|
||||||
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_first_reminder.sh" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -69,7 +69,6 @@ temp/
|
|||||||
|
|
||||||
# C++ build artifacts
|
# C++ build artifacts
|
||||||
cpp/build/
|
cpp/build/
|
||||||
/build/
|
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -3,10 +3,6 @@
|
|||||||
"registry": {
|
"registry": {
|
||||||
"command": "./apps/registry_mcp/registry_mcp",
|
"command": "./apps/registry_mcp/registry_mcp",
|
||||||
"args": ["--enable-run", "--enable-write"]
|
"args": ["--enable-run", "--enable-write"]
|
||||||
},
|
|
||||||
"jupyter": {
|
|
||||||
"command": "bash",
|
|
||||||
"args": ["/home/enmanuel/fn_registry/bash/functions/infra/jupyter_mcp_serve.sh"]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
[2026-05-22 23:18:14.872] [INFO] app start: Agents Dashboard
|
|
||||||
[2026-05-22 23:24:12.811] [INFO] app start: Agents Dashboard
|
|
||||||
[2026-05-22 23:24:14.628] [INFO] [connect] testing https://agents.organic-machine.com...
|
|
||||||
[2026-05-22 23:24:14.758] [INFO] [connect] OK
|
|
||||||
[2026-05-22 23:24:14.765] [INFO] [db] base_url saved
|
|
||||||
[2026-05-22 23:24:14.765] [INFO] [fetch_agents] starting
|
|
||||||
[2026-05-22 23:24:14.766] [INFO] [fetch_agents] requesting https://agents.organic-machine.com/agents
|
|
||||||
[2026-05-22 23:24:14.903] [INFO] [fetch_agents] response status=200 err= body_len=3146
|
|
||||||
[2026-05-22 23:24:14.904] [INFO] [fetch_agents] parsed 11 rows
|
|
||||||
[2026-05-22 23:24:14.904] [INFO] [fetch_agents] done
|
|
||||||
[2026-05-22 23:24:14.910] [INFO] [agents_panel] render n_rows=11 cells=121 specs=11
|
|
||||||
[2026-05-22 23:27:07.469] [INFO] app start: Agents Dashboard
|
|
||||||
[2026-05-22 23:27:08.242] [INFO] [agents_panel] render n_rows=11 cells=121 specs=11
|
|
||||||
[2026-05-22 23:27:36.670] [INFO] app start: Agents Dashboard
|
|
||||||
[2026-05-22 23:27:37.446] [INFO] [agents_panel] render n_rows=11 cells=121 specs=11
|
|
||||||
[2026-05-22 23:28:07.068] [INFO] app start: Agents Dashboard
|
|
||||||
[2026-05-22 23:30:03.025] [INFO] app start: Agents Dashboard
|
|
||||||
[2026-05-22 23:30:38.605] [INFO] app start: Agents Dashboard
|
|
||||||
[2026-05-22 23:30:48.267] [INFO] app start: Agents Dashboard
|
|
||||||
[2026-05-22 23:40:58.931] [INFO] app start: Agents Dashboard
|
|
||||||
[2026-05-22 23:41:16.455] [INFO] app start: Agents Dashboard
|
|
||||||
[2026-05-22 23:42:35.646] [INFO] app start: Agents Dashboard
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Build output
|
||||||
|
dag_engine
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# Frontend build
|
||||||
|
frontend/dist/
|
||||||
|
frontend/node_modules/
|
||||||
|
|
||||||
|
# Go
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
# dag_engine — Guia de uso
|
||||||
|
|
||||||
|
Motor de DAGs propio del fn_registry. **Scheduler oficial** del ecosistema (issue 0007a-e + flow 0001). Backend Go + frontend web (Vite/React) + frontend C++ ImGui (`cpp/apps/dag_engine_ui`).
|
||||||
|
|
||||||
|
Doc canonica para **anadir DAGs**, **formato YAML**, **comandos CLI**, y **diagnostico de fallos**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Donde viven los DAGs
|
||||||
|
|
||||||
|
| Path | Que |
|
||||||
|
|---|---|
|
||||||
|
| `apps/dag_engine/dags_migrated/` | DAGs activos servidos por `dag_engine.service` (systemd user unit). |
|
||||||
|
| `apps/dag_engine/dags_migrated/archive/` | DAGs deshabilitados (no se cargan por el scheduler). |
|
||||||
|
|
||||||
|
Por defecto el systemd unit apunta a `apps/dag_engine/dags_migrated/`. Para usar otro dir, edita `~/.config/systemd/user/dag_engine.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
ExecStart=/home/lucas/fn_registry/apps/dag_engine/dag_engine server \
|
||||||
|
--port 8090 \
|
||||||
|
--dags-dir /home/lucas/fn_registry/apps/dag_engine/dags_migrated \
|
||||||
|
--db /home/lucas/fn_registry/apps/dag_engine/dag_engine.db \
|
||||||
|
--scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
Y reload + restart:
|
||||||
|
```bash
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user restart dag_engine.service
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Anadir un DAG nuevo (workflow)
|
||||||
|
|
||||||
|
### Paso a paso
|
||||||
|
|
||||||
|
1. **Crear YAML** en `apps/dag_engine/dags_migrated/<nombre>.yaml` (ver formato en seccion 3).
|
||||||
|
2. **Validar** sin ejecutar:
|
||||||
|
```bash
|
||||||
|
./apps/dag_engine/dag_engine validate apps/dag_engine/dags_migrated/<nombre>.yaml
|
||||||
|
```
|
||||||
|
Salida esperada: `Validation: PASS`. Si falla, ver seccion 5 (diagnostico).
|
||||||
|
3. **Probar ejecucion manual** una vez:
|
||||||
|
```bash
|
||||||
|
./apps/dag_engine/dag_engine run apps/dag_engine/dags_migrated/<nombre>.yaml
|
||||||
|
```
|
||||||
|
4. **Recargar scheduler** (toma el YAML automaticamente al iterar el dir):
|
||||||
|
```bash
|
||||||
|
systemctl --user restart dag_engine.service
|
||||||
|
journalctl --user-unit dag_engine.service -n 30 --no-pager
|
||||||
|
```
|
||||||
|
Busca la linea `[scheduler] ticker started for <nombre> (<cron>)` en los logs.
|
||||||
|
5. **Verificar en frontend**:
|
||||||
|
- C++ ImGui: panel `DAGs` muestra el nuevo DAG. Pulsa `Refresh` si no aparece.
|
||||||
|
- Web: `http://localhost:8090`.
|
||||||
|
|
||||||
|
### Disparo manual desde curl o frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8090/api/dags/<nombre>/run
|
||||||
|
```
|
||||||
|
|
||||||
|
Devuelve `{"dag":"<nombre>","run_id":"...","status":"accepted"}` y dispara el WS broadcast — los frontends ven la run en `<1s`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Formato YAML
|
||||||
|
|
||||||
|
Formato YAML propio de dag_engine. Schema: `name`, `description`, `schedule`, `env`, `tags`, `working_dir`, `steps[]`, `handlers` (alias `handler_on`).
|
||||||
|
|
||||||
|
### Ejemplo completo
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: my_pipeline
|
||||||
|
description: "Pipeline diario que importa CSV y actualiza Metabase."
|
||||||
|
group: finanzas # opcional, agrupa DAGs en listados
|
||||||
|
type: graph # opcional: graph (default) | chain
|
||||||
|
tags: [daily, csv, metabase] # opcional, filtros en la UI
|
||||||
|
|
||||||
|
# Variables de entorno (heredadas por todos los steps).
|
||||||
|
env:
|
||||||
|
- DATA_DIR: /home/lucas/data
|
||||||
|
- SLACK_HOOK: ${SLACK_HOOK_PROD} # interpolacion de ENV del host
|
||||||
|
|
||||||
|
# Cron schedule. Puede ser string o lista.
|
||||||
|
schedule:
|
||||||
|
- "0 9 * * *" # 09:00 todos los dias
|
||||||
|
- "0 21 * * 5" # 21:00 viernes (segundo trigger)
|
||||||
|
|
||||||
|
# Working dir + shell por defecto para todos los steps.
|
||||||
|
working_dir: /home/lucas/fn_registry
|
||||||
|
shell: /bin/bash
|
||||||
|
timeout_sec: 1800 # 30 min para todo el DAG
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: ingest
|
||||||
|
description: "Descarga CSV."
|
||||||
|
command: ./bash/functions/pipelines/ingest_csv.sh
|
||||||
|
timeout_sec: 300 # 5 min para este step
|
||||||
|
env:
|
||||||
|
- SOURCE_URL: https://example.com/data.csv
|
||||||
|
|
||||||
|
- name: transform
|
||||||
|
description: "Limpieza y agregacion."
|
||||||
|
script: |
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import pandas as pd
|
||||||
|
df = pd.read_csv("$DATA_DIR/raw.csv")
|
||||||
|
df.to_parquet("$DATA_DIR/clean.parquet")
|
||||||
|
depends: [ingest] # debe terminar OK antes
|
||||||
|
retry_policy:
|
||||||
|
limit: 2 # reintentos en caso de fallo
|
||||||
|
interval_sec: 60
|
||||||
|
|
||||||
|
- name: load_metabase
|
||||||
|
command: ./bash/functions/metabase/refresh_dashboard.sh
|
||||||
|
depends: [transform]
|
||||||
|
continue_on:
|
||||||
|
failure: true # no aborta el DAG aunque falle
|
||||||
|
|
||||||
|
- name: notify
|
||||||
|
command: ./bash/functions/io/slack_send.sh "pipeline OK"
|
||||||
|
depends: [load_metabase]
|
||||||
|
|
||||||
|
# Hooks de ciclo de vida.
|
||||||
|
handler_on:
|
||||||
|
success: ./bash/functions/io/notify_success.sh
|
||||||
|
failure: ./bash/functions/io/notify_failure.sh
|
||||||
|
exit: ./bash/functions/io/cleanup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Campos del DAG (top-level)
|
||||||
|
|
||||||
|
| Campo | Tipo | Default | Que |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `name` | string | (obligatorio) | Identificador unico. Debe matchear el filename sin extension. |
|
||||||
|
| `description` | string | "" | Texto libre, aparece en la UI. |
|
||||||
|
| `group` | string | "" | Agrupa DAGs en listados. |
|
||||||
|
| `type` | string | `""` (graph) | `graph` o `chain`. graph = grafo dirigido por `depends`. chain = ejecucion secuencial implicita. |
|
||||||
|
| `working_dir` | string | cwd del server | Path absoluto desde donde lanzar los steps. |
|
||||||
|
| `shell` | string | `/bin/sh` | Shell para `command:`. |
|
||||||
|
| `env` | list/map | [] | Variables de entorno DAG-wide. |
|
||||||
|
| `schedule` | string/list | "" | Cron expressions (5 campos: min hour dom mon dow). Vacio = solo manual. |
|
||||||
|
| `steps` | list | (obligatorio) | Pasos del DAG (>=1). |
|
||||||
|
| `handler_on` | map | null | Hooks `init/success/failure/exit`. Alias: `handlers`. |
|
||||||
|
| `tags` | list[string] | [] | Filtros en la UI. |
|
||||||
|
| `timeout_sec` | int | 0 (sin timeout) | Timeout global del DAG en segundos. |
|
||||||
|
|
||||||
|
### Campos de cada step
|
||||||
|
|
||||||
|
| Campo | Tipo | Default | Que |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `name` | string | (obligatorio) | Identificador del step dentro del DAG. |
|
||||||
|
| `id` | string | "" | Override del id auto-generado. |
|
||||||
|
| `description` | string | "" | Texto libre. |
|
||||||
|
| `command` | string | "" | Comando shell (mutuamente excluyente con `script`/`function`). |
|
||||||
|
| `script` | string | "" | Bloque heredoc. Util para Python/Lua inline. |
|
||||||
|
| `function` | string | "" | ID de funcion del registry (ej `audit_capability_groups_go_infra`). Si set, executor invoca `${FN_REGISTRY_ROOT}/fn run <id> <args...>` y captura `function_id` en `dag_step_results`. Mutuamente exclusivo con `command`/`script`; si convive, gana `function`. |
|
||||||
|
| `args` | list[string] | [] | Args extra para `command` o para la `function`. |
|
||||||
|
| `shell` | string | hereda | Override del shell. |
|
||||||
|
| `dir` / `working_dir` | string | hereda | Working dir para este step. |
|
||||||
|
| `depends` | list[string] | [] | Steps que deben terminar OK antes. Si vacio + `type:graph`, arranca en paralelo. |
|
||||||
|
| `env` | list/map | hereda | Env del step (sobrescribe el del DAG). |
|
||||||
|
| `continue_on.failure` | bool | false | Si true, el DAG sigue aunque este step falle. |
|
||||||
|
| `continue_on.skipped` | bool | false | Si true, dependientes corren aunque este step quede skipped. |
|
||||||
|
| `retry_policy.limit` | int | 0 | Reintentos. |
|
||||||
|
| `retry_policy.interval_sec` | int | 0 | Segundos entre reintentos. |
|
||||||
|
| `timeout_sec` | int | 0 (sin timeout) | Timeout del step. |
|
||||||
|
| `output` | string | "" | Nombre de variable donde guardar stdout (consumible por dependientes). |
|
||||||
|
| `tags` | list[string] | [] | Tags por step (UI). |
|
||||||
|
|
||||||
|
### Function steps (coherencia con el registry)
|
||||||
|
|
||||||
|
Un DAG idiomatico llama funciones del registry, no scripts ad-hoc. Cada step `function:` queda trazado en `call_monitor.calls` por el hook PostToolUse del agente y en `dag_step_results.function_id` del propio dag_engine — el bucle reactivo (issue 0085) tiene visibilidad end-to-end.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
steps:
|
||||||
|
- name: audit_capabilities
|
||||||
|
function: audit_capability_groups_go_infra
|
||||||
|
args: ["--json"]
|
||||||
|
description: "Audita drift entre tags de capability group y paginas madre"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ventajas vs `command: ./fn run ...`:
|
||||||
|
|
||||||
|
- `function_id` se persiste como columna dedicada en `dag_step_results` (filtrable, agrupable).
|
||||||
|
- El frontend `dag_engine_ui` muestra badge + panel lateral con `uses_functions` (subfunciones que el step va a usar transitivamente).
|
||||||
|
- API: `GET /api/functions/{id}` devuelve `{id, name, description, signature, purity, domain, lang, uses_functions[], uses_types[]}` leyendo `registry.db` read-only. La UI consume este endpoint al expandir un step.
|
||||||
|
- Validator regex en `dag_validate`: `^[a-z0-9_]+_[a-z]+_[a-z]+$`. ID invalido = error.
|
||||||
|
- Variables de entorno: `FN_REGISTRY_ROOT` (default `/home/lucas/fn_registry`) localiza el binario `fn`. Override con `FN_BIN=/path/al/fn`.
|
||||||
|
- **`FN_REGISTRY_ROOT` obligatorio cuando el servicio corre via systemd** con `WorkingDirectory` fuera del root del registry. El binario `fn` resuelve `registry.db` por (1) env var, (2) walk-up buscando `go.mod`, (3) exe dir. Si (1) no esta y (2) encuentra el `go.mod` del propio servicio (ej. `apps/dag_engine/go.mod`), devuelve un dir donde `registry.db` no existe o esta stale, fallando con `error: function "<id>" not found`. Bug historico: `apps/dag_engine/registry.db` stale (May 15) tumbo 3 noches `fn_backup` + `daily-registry-audit`. Defensa en profundidad: el executor exporta `FN_REGISTRY_ROOT` y hace `cd $FN_REGISTRY_ROOT` antes del spawn de steps `function:` (executor.go), pero el `Environment=FN_REGISTRY_ROOT=...` del systemd unit sigue siendo la fuente de verdad.
|
||||||
|
- **`PATH` en el systemd unit**: si steps `function:` invocan funciones Go sin tests (`go vet`) o Python (`python3`), el `PATH` del entorno systemd debe incluir esos binarios — declarar `Environment=PATH=/usr/local/go/bin:/home/lucas/go/bin:/home/lucas/.local/bin:/usr/local/bin:/usr/bin:/bin`.
|
||||||
|
|
||||||
|
Ejemplo completo: `apps/dag_engine/dags_migrated/daily-registry-audit.yaml`.
|
||||||
|
|
||||||
|
### Cron schedule
|
||||||
|
|
||||||
|
5 campos clasicos: `min hour dom mon dow`. Ejemplos:
|
||||||
|
|
||||||
|
| Expresion | Significado |
|
||||||
|
|---|---|
|
||||||
|
| `0 9 * * *` | Todos los dias a las 09:00 |
|
||||||
|
| `*/15 * * * *` | Cada 15 minutos |
|
||||||
|
| `0 */6 * * *` | Cada 6 horas en punto |
|
||||||
|
| `0 9 * * 1-5` | 09:00 lunes-viernes |
|
||||||
|
| `0 21 * * 5` | 21:00 viernes |
|
||||||
|
|
||||||
|
Multiples cron en `schedule:` -> el DAG dispara por cada uno.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Comandos CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dag_engine run <path.yaml> # ejecuta un DAG ad-hoc
|
||||||
|
./dag_engine list [dir] # lista DAGs con schedule + ultimo status
|
||||||
|
./dag_engine status [dag_name] # historial de ejecuciones
|
||||||
|
./dag_engine validate <path.yaml> # parse + validate (no ejecuta)
|
||||||
|
./dag_engine server # arranca HTTP + WS hub + frontend embebido
|
||||||
|
```
|
||||||
|
|
||||||
|
Flags del `server`:
|
||||||
|
|
||||||
|
| Flag | Default | Que |
|
||||||
|
|---|---|---|
|
||||||
|
| `--port` | 8090 | Puerto HTTP. |
|
||||||
|
| `--dags-dir` | `apps/dag_engine/dags_migrated` (via systemd unit) | Dir scaneado para YAMLs. |
|
||||||
|
| `--db` | `dag_engine.db` | SQLite con `dag_runs` + `dag_step_results`. |
|
||||||
|
| `--scheduler` | false | Si presente, arranca cron tickers automaticamente. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Que hacer si algo falla
|
||||||
|
|
||||||
|
### 5.1. El DAG no aparece en la UI
|
||||||
|
|
||||||
|
**Sintoma:** anadiste un YAML pero `GET /api/dags` no lo lista.
|
||||||
|
|
||||||
|
| Causa | Diagnostico | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| YAML invalido | `./dag_engine validate <path>` muestra el error. | Corregir segun el mensaje (campo desconocido, indentacion, type wrong). |
|
||||||
|
| Filename con extension fuera de `.yaml`/`.yml` | `ls apps/dag_engine/dags_migrated/` | Renombrar a `.yaml`. |
|
||||||
|
| El servidor apunta a otro dir | `systemctl --user cat dag_engine.service` -> ver `--dags-dir`. | Ajustar unit y `daemon-reload + restart`. |
|
||||||
|
| Cache UI antiguo | C++: pulsa `Refresh`. Web: `Ctrl+F5`. | — |
|
||||||
|
|
||||||
|
### 5.2. Validation: FAIL
|
||||||
|
|
||||||
|
`validate` muestra `parse error: ...` o `Validation: FAIL`. Causas tipicas:
|
||||||
|
|
||||||
|
| Mensaje | Causa | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| `yaml unmarshal: ...` | Sintaxis YAML rota (indentacion, tab vs espacios). | Usar 2 espacios consistentes. Validar online con `yamllint`. |
|
||||||
|
| `dag_parse: step[N]: name is required` | Step sin `name:`. | Anadir `name:`. |
|
||||||
|
| `dag_parse: step[N]: command or script required` | Step sin `command` ni `script`. | Anadir uno de los dos. |
|
||||||
|
| `cycle detected: A -> B -> A` | `depends` forma ciclo. | Romper la dependencia o convertir uno de los nodos en step distinto. |
|
||||||
|
| `unknown depends: <step>` | `depends:` referencia un step inexistente. | Comprobar nombres exactos (case-sensitive). |
|
||||||
|
| `invalid cron: <expr>` | Cron mal formado (4 o 6 campos en vez de 5). | Verificar `0 9 * * *` (5 campos). |
|
||||||
|
|
||||||
|
### 5.3. El DAG corre pero un step falla
|
||||||
|
|
||||||
|
**Sintoma:** `status: failed` en la UI.
|
||||||
|
|
||||||
|
1. Abre `DAG Detail` y haz doble-click en el run rojo -> `Run Detail`.
|
||||||
|
2. Expande el step que fallo (CollapsingHeader). Muestra `stdout` + `stderr`.
|
||||||
|
3. Errores tipicos:
|
||||||
|
|
||||||
|
| stderr | Causa | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| `command not found` | `command:` apunta a un binario fuera de `PATH`. | Path absoluto o setear `env: [PATH: ...]`. |
|
||||||
|
| `permission denied` | Script sin `chmod +x`. | `chmod +x <script>` (o usar `bash <script>`). |
|
||||||
|
| `no such file or directory` | `working_dir:` mal o ruta relativa rota. | Path absoluto en `working_dir:`. |
|
||||||
|
| Timeout | Step duro mas que `timeout_sec`. | Subir el limite o partir el step. |
|
||||||
|
| Exit 137 / OOM kill | Out-of-memory. | Reducir batch o anadir swap. |
|
||||||
|
|
||||||
|
### 5.4. El scheduler no dispara
|
||||||
|
|
||||||
|
**Sintoma:** Hay `schedule:` valido pero el DAG no corre solo.
|
||||||
|
|
||||||
|
1. Verifica que el server arranco con `--scheduler`:
|
||||||
|
```bash
|
||||||
|
systemctl --user cat dag_engine.service | grep scheduler
|
||||||
|
```
|
||||||
|
2. Logs:
|
||||||
|
```bash
|
||||||
|
journalctl --user-unit dag_engine.service -n 50 --no-pager | grep -E "scheduler|ticker"
|
||||||
|
```
|
||||||
|
Debes ver `[scheduler] ticker started for <name> (<cron>), next: <ISO8601>`.
|
||||||
|
3. Si `next:` es muy lejano (ej. en una semana) y necesitas probar -> dispara manual:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8090/api/dags/<name>/run
|
||||||
|
```
|
||||||
|
4. Hora del sistema descalibrada:
|
||||||
|
```bash
|
||||||
|
timedatectl status
|
||||||
|
```
|
||||||
|
Si difiere de la hora real, `sudo timedatectl set-ntp true`.
|
||||||
|
|
||||||
|
### 5.5. El frontend C++ no conecta WS
|
||||||
|
|
||||||
|
**Sintoma:** Panel `Live (WS)` muestra `disconnected`.
|
||||||
|
|
||||||
|
| Causa | Fix |
|
||||||
|
|---|---|
|
||||||
|
| Servidor caido | `systemctl --user status dag_engine.service`, `restart` si `inactive`. |
|
||||||
|
| Puerto cambiado | El cliente apunta a `127.0.0.1:8090` por codigo (constante `g_ws_port`). Reedificar si cambiaste el puerto del server. |
|
||||||
|
| Firewall Windows -> WSL | WSL2 expone `localhost`, normalmente OK. Si falla: `wsl --shutdown` y reabrir. |
|
||||||
|
|
||||||
|
### 5.6. Cleanup de runs viejos
|
||||||
|
|
||||||
|
`dag_runs` y `dag_step_results` crecen sin limite. Para limpiar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 apps/dag_engine/dag_engine.db <<'SQL'
|
||||||
|
DELETE FROM dag_step_results WHERE run_id IN (
|
||||||
|
SELECT id FROM dag_runs WHERE started_at < datetime('now', '-30 days')
|
||||||
|
);
|
||||||
|
DELETE FROM dag_runs WHERE started_at < datetime('now', '-30 days');
|
||||||
|
VACUUM;
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.7. Restaurar desde backup
|
||||||
|
|
||||||
|
Si rompes `dags_migrated/`, recupera desde el snapshot de `backup_all_bash_pipelines` (BACKUP_ROOT por defecto `~/backups/fn_registry`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp ~/backups/fn_registry/registry/daily.0/dags_migrated/*.yaml \
|
||||||
|
apps/dag_engine/dags_migrated/ 2>/dev/null || \
|
||||||
|
git checkout HEAD -- apps/dag_engine/dags_migrated/
|
||||||
|
systemctl --user restart dag_engine.service
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Endpoints HTTP
|
||||||
|
|
||||||
|
| Metodo | Path | Que |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/dags` | Lista DAGs + last_run + last_runs[5]. |
|
||||||
|
| GET | `/api/dags/{name}` | Detalle + validation. |
|
||||||
|
| POST | `/api/dags/{name}/run` | Dispara ejecucion (trigger=`api`). Devuelve `run_id`. |
|
||||||
|
| GET | `/api/runs` | Historial. Query: `dag`, `limit`, `offset`. |
|
||||||
|
| GET | `/api/runs/{id}` | Detalle de un run + sus step_results. |
|
||||||
|
| GET | `/api/ws/dagruns` | WebSocket. Snapshot + deltas en vivo (issue 0095). |
|
||||||
|
| GET | `/api/scheduler/status` | Tickers activos. |
|
||||||
|
| POST | `/api/scheduler/start` | Arranca scheduler (si no estaba). |
|
||||||
|
| POST | `/api/scheduler/stop` | Para scheduler. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Referencias
|
||||||
|
|
||||||
|
- Schema parser: `functions/core/dag_parse.go` (frontmatter en `dag_parse_go_core`).
|
||||||
|
- Validator: `functions/core/dag_validate.go` (`dag_validate_go_core`).
|
||||||
|
- Topo sort: `functions/core/dag_topo_sort.go` (`dag_topo_sort_go_core`).
|
||||||
|
- Cron: `functions/core/parse_cron_expr.go` + `next_cron_time.go`.
|
||||||
|
- Frontend C++: `cpp/apps/dag_engine_ui/` (issue 0095).
|
||||||
|
- WS hub: `apps/dag_engine/events.go`.
|
||||||
|
- dag_engine es el scheduler oficial del ecosistema. Single-binary Go + SQLite, sin dependencias externas.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterAPI sets up all HTTP routes on the given mux.
|
||||||
|
func RegisterAPI(mux *http.ServeMux, executor *Executor, scheduler *Scheduler, hub *DagRunHub, frontendFS fs.FS) {
|
||||||
|
// API routes.
|
||||||
|
mux.HandleFunc("GET /api/dags", handleListDags(executor))
|
||||||
|
mux.HandleFunc("GET /api/dags/{name}", handleGetDag(executor))
|
||||||
|
mux.HandleFunc("POST /api/dags/{name}/run", handleRunDag(executor))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /api/runs", handleListRuns(executor))
|
||||||
|
mux.HandleFunc("GET /api/runs/{id}", handleGetRun(executor))
|
||||||
|
|
||||||
|
// Function lookup proxy a registry.db (read-only).
|
||||||
|
mux.HandleFunc("GET /api/functions/{id}", handleGetFunction())
|
||||||
|
|
||||||
|
mux.HandleFunc("POST /api/scheduler/start", handleSchedulerStart(scheduler))
|
||||||
|
mux.HandleFunc("POST /api/scheduler/stop", handleSchedulerStop(scheduler))
|
||||||
|
mux.HandleFunc("GET /api/scheduler/status", handleSchedulerStatus(scheduler))
|
||||||
|
|
||||||
|
// Live updates (WS hub).
|
||||||
|
if hub != nil {
|
||||||
|
mux.HandleFunc("GET /api/ws/dagruns", handleDagRunsWS(hub))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frontend SPA fallback.
|
||||||
|
if frontendFS != nil {
|
||||||
|
mux.Handle("/", spaHandler(frontendFS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// spaHandler serves static files from the embedded FS, falling back to index.html
|
||||||
|
// for unknown paths (SPA client-side routing).
|
||||||
|
func spaHandler(fsys fs.FS) http.Handler {
|
||||||
|
fileServer := http.FileServer(http.FS(fsys))
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Try to serve the file directly.
|
||||||
|
path := r.URL.Path
|
||||||
|
if path == "/" {
|
||||||
|
path = "index.html"
|
||||||
|
} else {
|
||||||
|
path = path[1:] // strip leading /
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fs.Stat(fsys, path); err != nil {
|
||||||
|
// File not found — serve index.html for SPA routing.
|
||||||
|
r.URL.Path = "/"
|
||||||
|
}
|
||||||
|
fileServer.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
---
|
||||||
|
name: dag_engine
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: 0.1.0
|
||||||
|
description: "Motor de ejecucion de DAGs del fn_registry: CLI + servidor HTTP + scheduler cron. Schema YAML propio con `function:` para invocar funciones del registry (`fn run <id>`) y `command:` para shell. Historial en SQLite. Scheduler oficial del ecosistema."
|
||||||
|
tags: [service, dag, workflow, scheduler, web, cron]
|
||||||
|
uses_functions:
|
||||||
|
- dag_parse_go_core
|
||||||
|
- dag_validate_go_core
|
||||||
|
- dag_topo_sort_go_core
|
||||||
|
- dag_resolve_env_go_core
|
||||||
|
- parse_cron_expr_go_core
|
||||||
|
- next_cron_time_go_core
|
||||||
|
- cron_ticker_go_infra
|
||||||
|
- find_go_core
|
||||||
|
- process_spawn_go_infra
|
||||||
|
- process_wait_go_infra
|
||||||
|
uses_types:
|
||||||
|
- dag_definition_go_core
|
||||||
|
- dag_step_go_core
|
||||||
|
- dag_validation_result_go_core
|
||||||
|
- cron_schedule_go_core
|
||||||
|
- process_handle_go_infra
|
||||||
|
- process_result_go_infra
|
||||||
|
- DagRun_go_infra
|
||||||
|
- DagStepResult_go_infra
|
||||||
|
framework: "net/http + vite + react"
|
||||||
|
entry_point: "main.go"
|
||||||
|
dir_path: "apps/dag_engine"
|
||||||
|
service:
|
||||||
|
port: 8090
|
||||||
|
health_endpoint: /api/dags
|
||||||
|
health_timeout_s: 3
|
||||||
|
systemd_unit: dag_engine.service
|
||||||
|
systemd_scope: user
|
||||||
|
restart_policy: always
|
||||||
|
runtime: systemd-user
|
||||||
|
pc_targets:
|
||||||
|
- aurgi-pc
|
||||||
|
- home-wsl
|
||||||
|
is_local_only: false
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
CLI + servidor web en un unico binario:
|
||||||
|
|
||||||
|
```
|
||||||
|
dag-engine run <path.yaml> # ejecuta un DAG desde terminal
|
||||||
|
dag-engine list [dir] # lista DAGs con schedule y estado
|
||||||
|
dag-engine status [dag_name] # historial de ejecuciones
|
||||||
|
dag-engine validate <path.yaml> # valida sin ejecutar
|
||||||
|
dag-engine server # arranca HTTP + frontend web
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend (Go)
|
||||||
|
|
||||||
|
- `net/http` con `ServeMux` (Go 1.22+ pattern routing)
|
||||||
|
- SQLite via `go-sqlite3` para historial de runs
|
||||||
|
- Executor: parse -> validate -> topo_sort -> spawn/wait por nivel -> store
|
||||||
|
- Scheduler: cron_ticker por cada DAG con schedule
|
||||||
|
|
||||||
|
### Frontend (Vite + React + Mantine)
|
||||||
|
|
||||||
|
- DagList: tabla de DAGs con schedule, tags, ultimo status
|
||||||
|
- DagDetail: metadata + "Run Now" + historial
|
||||||
|
- RunDetail: timeline de steps con stdout/stderr expandible
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
SQLite `dag_engine.db`:
|
||||||
|
- `dag_runs`: id, dag_name, status, trigger, started_at, finished_at, error
|
||||||
|
- `dag_step_results`: id, run_id, step_name, status, exit_code, stdout, stderr, duration_ms
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && pnpm install && pnpm build
|
||||||
|
cd .. && CGO_ENABLED=1 go build -tags fts5 -o dag-engine .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Uso
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CLI
|
||||||
|
./dag-engine run apps/dag_engine/dags_migrated/fn_backup.yaml
|
||||||
|
./dag-engine list apps/dag_engine/dags_migrated/
|
||||||
|
|
||||||
|
# Servidor web (production: gestionado por dag_engine.service systemd user unit)
|
||||||
|
./dag-engine server --port 8090 --dags-dir apps/dag_engine/dags_migrated/ --scheduler
|
||||||
|
# Browser: http://localhost:8090
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Schema YAML propio (ver `README.md` seccion 3 + ejemplos en `dags_migrated/`). Steps tipo `function:` invocan `fn run <id>` y propagan `function_id` a `dag_step_results` para el bucle reactivo. Puerto default 8090.
|
||||||
|
|
||||||
|
### 2026-05-16 — Fix function-not-found en steps `function:` + panel Logs en RunDetail `[done]`
|
||||||
|
|
||||||
|
Sintoma: `fn_backup` y `daily-registry-audit` fallaron 3 noches seguidas con `error: function "<id>" not found (tried as ID and name)` aunque las funciones existen en `registry.db` raiz.
|
||||||
|
|
||||||
|
Raiz: servicio systemd `dag_engine.service` tiene `WorkingDirectory=/home/lucas/fn_registry/apps/dag_engine`. Binario `fn` resuelve `registry.db` por (1) `FN_REGISTRY_ROOT`, (2) `root()` walk-up buscando `go.mod`, (3) exe dir (`cmd/fn/ops.go:1597-1628`). Sin `FN_REGISTRY_ROOT` seteado, (2) encuentra el `go.mod` de `apps/dag_engine/` y devuelve ese dir — donde habia una copia stale `apps/dag_engine/registry.db` (262 KB, May 15) sin las funciones recien creadas. Viola regla `.claude/rules/db_locations.md` (registry.db SOLO en raiz).
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
- Borrado `apps/dag_engine/registry.db` stale.
|
||||||
|
- `~/.config/systemd/user/dag_engine.service`: anadido `Environment=FN_REGISTRY_ROOT=/home/lucas/fn_registry`, `FN_BIN=/home/lucas/fn_registry/fn`, `PATH=/usr/local/go/bin:/home/lucas/go/bin:...`, `HOME=/home/lucas`. Sin PATH el step `go vet` fallaba con `exec: "go": executable file not found in $PATH`.
|
||||||
|
- `apps/dag_engine/executor.go`: para steps `function:` el spawn exporta `FN_REGISTRY_ROOT=<root>` en env y, si `step.dir`/`working_dir` vacios, fija `dir = fnRegistryRoot`. Belt-and-suspenders: aunque alguien lance el binario sin systemd, los `function:` steps usan el root canonico.
|
||||||
|
|
||||||
|
Verificacion: `POST /api/dags/daily-registry-audit/run` -> step `audit_capabilities` pasa (387 ms) en vez de fallar con not-found. Restantes failures (`audit_artefacts` exit 1, `fn_backup` exit 4 sin respetar `continue_on.exit_code`) son bugs reales independientes — fuera de scope.
|
||||||
|
|
||||||
|
### 2026-05-16 — Panel Logs en RunDetail (frontend) `[done]`
|
||||||
|
|
||||||
|
- `apps/dag_engine/frontend/src/pages/RunDetail.tsx`: nuevo `<Paper>` "Logs" al final con `<Code block>` scrollable (max-h 480) + `CopyButton` de Mantine (icono toggle copy/check teal).
|
||||||
|
- Helper `buildLogText(run, steps)` compone texto plano: metadata del run (dag, path, status, trigger, started/finished ISO, duration ms, error) + por step (`[status] name exit=N Nms`, started, finished, error, stdout, stderr indentado 4 espacios).
|
||||||
|
- Permite pegar log entero al LLM para debugging sin abrir N collapses del `StepTimeline`.
|
||||||
|
- Build frontend pendiente: `pnpm build` rompe por errores preexistentes (`StepTimeline.tsx:49` usa API legacy `<Collapse in={opened}>`; `main.tsx:1` importa `@mantine/core/styles.css` sin tipos). Edit de RunDetail type-checkea limpio.
|
||||||
|
|
||||||
|
### 2026-05-16 — BBDDs canonicas (referencia rapida)
|
||||||
|
|
||||||
|
- `dag_engine.db`: `apps/dag_engine/dag_engine.db` (+ WAL sidecars). Migrations en `apps/dag_engine/store/migrations/` (`001_init.sql`, `002_step_function_id.sql`). Tablas `dag_runs`, `dag_step_results`.
|
||||||
|
- NO debe coexistir copia de `registry.db` en este dir (viola `db_locations.md`). Si reaparece: borrarla.
|
||||||
|
|
||||||
|
## Lo siguiente que pega
|
||||||
|
|
||||||
|
- `audit_artefacts` falla con exit 1 en `daily-registry-audit` — investigar stderr real (probablemente artefacto huerfano o git drift). Step independiente, no bloquea el resto del DAG.
|
||||||
|
- `fn_backup` step `run_backup_all` sale con exit 4 y el DAG no respeta `continue_on.exit_code: [4]`. Bug en executor: parsear `step.ContinueOn.ExitCode []int` y comparar con `result.ExitCode`. Hoy solo se mira `step.ContinueOn.Failure` (bool).
|
||||||
|
- Frontend `pnpm build` roto por API drift de Mantine en `StepTimeline.tsx` (`<Collapse in={opened}>`) y CSS type import en `main.tsx`. Fix junto con un refresh general de tipos.
|
||||||
|
|
||||||
|
## Documentacion de usuario
|
||||||
|
|
||||||
|
Guia completa (formato YAML, anadir DAGs, troubleshooting, endpoints HTTP):
|
||||||
|
**[apps/dag_engine/README.md](README.md)**.
|
||||||
|
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
Una linea por bump SemVer. Bump-type segun `.claude/commands/version.md`:
|
||||||
|
- `major`: breaking observable (CLI args, schema BBDD propia, formato wire).
|
||||||
|
- `minor`: feature aditiva (nuevo panel, endpoint, opcion).
|
||||||
|
- `patch`: bugfix sin cambio observable.
|
||||||
|
|
||||||
|
- v0.1.0 (2026-05-18) — baseline.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds the runtime configuration for the DAG engine.
|
||||||
|
type Config struct {
|
||||||
|
Port int
|
||||||
|
DagsDir string
|
||||||
|
DBPath string
|
||||||
|
AutoScheduler bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns sensible defaults.
|
||||||
|
func DefaultConfig() Config {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return Config{
|
||||||
|
Port: 8090,
|
||||||
|
DagsDir: filepath.Join(home, "dagu", "dags"),
|
||||||
|
DBPath: "dag_engine.db",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFlags populates config from CLI flags for the "server" subcommand.
|
||||||
|
func (c *Config) ParseFlags(fs *flag.FlagSet, args []string) error {
|
||||||
|
fs.IntVar(&c.Port, "port", c.Port, "HTTP server port")
|
||||||
|
fs.StringVar(&c.DagsDir, "dags-dir", c.DagsDir, "directory containing DAG YAML files")
|
||||||
|
fs.StringVar(&c.DBPath, "db", c.DBPath, "path to SQLite database")
|
||||||
|
fs.BoolVar(&c.AutoScheduler, "scheduler", c.AutoScheduler, "auto-start cron scheduler")
|
||||||
|
return fs.Parse(args)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: fn_backup
|
||||||
|
description: Backup diario de fn_registry (registry.db + operations.db + vaults) via funcion del registry
|
||||||
|
|
||||||
|
schedule:
|
||||||
|
- "0 3 * * *"
|
||||||
|
|
||||||
|
tags: [backup, registry, daily]
|
||||||
|
|
||||||
|
env:
|
||||||
|
- BACKUP_ROOT: /home/lucas/backups/fn_registry
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: ensure_dirs
|
||||||
|
command: mkdir -p ${BACKUP_ROOT}
|
||||||
|
|
||||||
|
- name: run_backup_all
|
||||||
|
description: "Snapshot atomico de registry.db + operations.db + vaults con retention 7/4/12"
|
||||||
|
function: backup_all_bash_pipelines
|
||||||
|
args: ["${BACKUP_ROOT}"]
|
||||||
|
continue_on:
|
||||||
|
exit_code: [4]
|
||||||
|
depends: [ensure_dirs]
|
||||||
|
|
||||||
|
- name: report_status
|
||||||
|
command: bash -c 'ls -lh ${BACKUP_ROOT}/registry/daily.0 ${BACKUP_ROOT}/operations/*/daily.0 2>/dev/null | tail -20'
|
||||||
|
depends: [run_backup_all]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
name: revision-viernes-finanzas
|
||||||
|
description: Revisión semanal de finanzas personales - ingesta, informe y push a Gitea
|
||||||
|
tags: [finanzas, semanal]
|
||||||
|
type: graph
|
||||||
|
|
||||||
|
schedule: "0 9 * * 5"
|
||||||
|
|
||||||
|
env:
|
||||||
|
- PROJECT_DIR: /home/lucas/analysis/finanzas_personales
|
||||||
|
- PYTHON: /home/lucas/analysis/finanzas_personales/.venv/bin/python
|
||||||
|
|
||||||
|
handler_on:
|
||||||
|
failure:
|
||||||
|
command: echo "[$(date)] FALLÓ revision-viernes-finanzas" >> /home/lucas/dagu/logs/failures.log
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: ingest
|
||||||
|
description: Procesar archivos nuevos del inbox (BBVA xlsx + Revolut csv)
|
||||||
|
working_dir: ${PROJECT_DIR}
|
||||||
|
command: ./bin/ingest -skip-notebooks
|
||||||
|
continue_on:
|
||||||
|
failure: true
|
||||||
|
|
||||||
|
- id: informe
|
||||||
|
description: Generar informe semanal de cumplimiento del presupuesto
|
||||||
|
command: ${PYTHON} /home/lucas/dagu/scripts/informe_finanzas.py
|
||||||
|
depends: [ingest]
|
||||||
|
|
||||||
|
- id: git_push
|
||||||
|
description: Commit y push del informe a Gitea
|
||||||
|
working_dir: ${PROJECT_DIR}
|
||||||
|
script: |
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if git diff --quiet data/04_output/informe_semanal.md 2>/dev/null && \
|
||||||
|
! git ls-files --others --exclude-standard | grep -q informe_semanal.md; then
|
||||||
|
echo "Sin cambios en el informe, skip push"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git add data/04_output/informe_semanal.md
|
||||||
|
git add data/03_processed/ 2>/dev/null || true
|
||||||
|
|
||||||
|
git commit -m "Informe semanal $(date +%Y-%m-%d)
|
||||||
|
|
||||||
|
Co-Authored-By: Dagu Automation <noreply@dagu.dev>"
|
||||||
|
|
||||||
|
git push origin master:main
|
||||||
|
echo "Push completado"
|
||||||
|
depends: [informe]
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// WebSocket hub para live updates de dag_runs + dag_step_results.
|
||||||
|
// Patron: sqlite_api/events.go (CallMonitorHub) — issue 0095.
|
||||||
|
//
|
||||||
|
// Diseño:
|
||||||
|
// - Hub global con N subscribers WS.
|
||||||
|
// - Ticker arranca solo con >=1 subscriber. Cero overhead si nadie mira.
|
||||||
|
// - Cada tick (500ms): query rowid>watermark + activos (status running/pending)
|
||||||
|
// + recientes finished (ultimos 5s) -> broadcast upsert.
|
||||||
|
// - Snapshot inicial: lista de DAGs + ultimos 50 runs + step_results.
|
||||||
|
// - El cliente trata `runs` y `steps` como upserts por id.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nhooyr.io/websocket"
|
||||||
|
"nhooyr.io/websocket/wsjson"
|
||||||
|
|
||||||
|
"dag-engine/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dagWSTickInterval = 500 * time.Millisecond
|
||||||
|
dagWSTickIntervalIdle = 2 * time.Second
|
||||||
|
dagWSIdleThreshold = 30 * time.Second
|
||||||
|
dagWSSnapshotRuns = 50
|
||||||
|
dagWSBroadcastTimeout = 2 * time.Second
|
||||||
|
dagWSRecentFinishedS = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
type wsRun struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DagName string `json:"dag_name"`
|
||||||
|
DagPath string `json:"dag_path"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
StartedAt string `json:"started_at"`
|
||||||
|
FinishedAt string `json:"finished_at,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wsStep struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
StepName string `json:"step_name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ExitCode int `json:"exit_code"`
|
||||||
|
Stdout string `json:"stdout,omitempty"`
|
||||||
|
Stderr string `json:"stderr,omitempty"`
|
||||||
|
StartedAt string `json:"started_at,omitempty"`
|
||||||
|
FinishedAt string `json:"finished_at,omitempty"`
|
||||||
|
DurationMs int64 `json:"duration_ms"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wsWatermark struct {
|
||||||
|
Runs int64 `json:"runs"`
|
||||||
|
Steps int64 `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wsDagMessage struct {
|
||||||
|
Type string `json:"type"` // snapshot|delta|ping
|
||||||
|
Watermark wsWatermark `json:"watermark"`
|
||||||
|
Dags []DagInfo `json:"dags,omitempty"`
|
||||||
|
Runs []wsRun `json:"runs,omitempty"`
|
||||||
|
Steps []wsStep `json:"steps,omitempty"`
|
||||||
|
ServerTime int64 `json:"server_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wsDagClientCmd struct {
|
||||||
|
Watermark wsWatermark `json:"watermark,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dagSubscriber struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
out chan wsDagMessage
|
||||||
|
watermark wsWatermark
|
||||||
|
}
|
||||||
|
|
||||||
|
// DagRunHub broadcastea cambios de dag_runs + dag_step_results a clientes WS.
|
||||||
|
type DagRunHub struct {
|
||||||
|
db *store.DB
|
||||||
|
executor *Executor
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
subscribers map[*dagSubscriber]struct{}
|
||||||
|
tickerStop chan struct{}
|
||||||
|
tickerOn bool
|
||||||
|
watermark wsWatermark
|
||||||
|
lastEventAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDagRunHub(db *store.DB, executor *Executor) *DagRunHub {
|
||||||
|
return &DagRunHub{
|
||||||
|
db: db,
|
||||||
|
executor: executor,
|
||||||
|
subscribers: make(map[*dagSubscriber]struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DagRunHub) register(s *dagSubscriber) {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.subscribers[s] = struct{}{}
|
||||||
|
shouldStart := !h.tickerOn
|
||||||
|
if shouldStart {
|
||||||
|
h.tickerStop = make(chan struct{})
|
||||||
|
h.tickerOn = true
|
||||||
|
h.lastEventAt = time.Now()
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
if shouldStart {
|
||||||
|
go h.tickerLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DagRunHub) unregister(s *dagSubscriber) {
|
||||||
|
h.mu.Lock()
|
||||||
|
if _, ok := h.subscribers[s]; !ok {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(h.subscribers, s)
|
||||||
|
close(s.out)
|
||||||
|
shouldStop := h.tickerOn && len(h.subscribers) == 0
|
||||||
|
if shouldStop {
|
||||||
|
close(h.tickerStop)
|
||||||
|
h.tickerOn = false
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DagRunHub) tickerLoop() {
|
||||||
|
interval := dagWSTickInterval
|
||||||
|
t := time.NewTimer(interval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-h.tickerStop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
runs, steps, wm, err := h.fetchDelta(h.getWatermark())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[dagws] fetchDelta: %v", err)
|
||||||
|
} else if len(runs) > 0 || len(steps) > 0 {
|
||||||
|
h.setWatermark(wm)
|
||||||
|
h.recordActivity()
|
||||||
|
h.broadcast(wsDagMessage{
|
||||||
|
Type: "delta",
|
||||||
|
Watermark: wm,
|
||||||
|
Runs: runs,
|
||||||
|
Steps: steps,
|
||||||
|
ServerTime: time.Now().Unix(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(h.lastActivityAt()) > dagWSIdleThreshold {
|
||||||
|
interval = dagWSTickIntervalIdle
|
||||||
|
} else {
|
||||||
|
interval = dagWSTickInterval
|
||||||
|
}
|
||||||
|
t.Reset(interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DagRunHub) getWatermark() wsWatermark {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
return h.watermark
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DagRunHub) setWatermark(v wsWatermark) {
|
||||||
|
h.mu.Lock()
|
||||||
|
if v.Runs > h.watermark.Runs {
|
||||||
|
h.watermark.Runs = v.Runs
|
||||||
|
}
|
||||||
|
if v.Steps > h.watermark.Steps {
|
||||||
|
h.watermark.Steps = v.Steps
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DagRunHub) recordActivity() {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.lastEventAt = time.Now()
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DagRunHub) lastActivityAt() time.Time {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
return h.lastEventAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchDelta devuelve runs/steps con (rowid > watermark) OR (status in-flight)
|
||||||
|
// OR (recently finished). Watermark devuelto = max rowid visto.
|
||||||
|
func (h *DagRunHub) fetchDelta(since wsWatermark) ([]wsRun, []wsStep, wsWatermark, error) {
|
||||||
|
conn := h.db.Conn()
|
||||||
|
if conn == nil {
|
||||||
|
return nil, nil, since, nil
|
||||||
|
}
|
||||||
|
cutoff := time.Now().Add(-time.Duration(dagWSRecentFinishedS) * time.Second).Format(time.RFC3339)
|
||||||
|
|
||||||
|
runs, maxRuns, err := scanRuns(conn, `
|
||||||
|
SELECT rowid, id, dag_name, dag_path, status, trigger, started_at,
|
||||||
|
COALESCE(finished_at,''), error
|
||||||
|
FROM dag_runs
|
||||||
|
WHERE rowid > ?
|
||||||
|
OR status IN ('running','pending')
|
||||||
|
OR (finished_at IS NOT NULL AND finished_at >= ?)
|
||||||
|
ORDER BY rowid ASC`, since.Runs, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, since, err
|
||||||
|
}
|
||||||
|
|
||||||
|
steps, maxSteps, err := scanSteps(conn, `
|
||||||
|
SELECT rowid, id, run_id, step_name, status, exit_code, stdout, stderr,
|
||||||
|
COALESCE(started_at,''), COALESCE(finished_at,''), duration_ms, error
|
||||||
|
FROM dag_step_results
|
||||||
|
WHERE rowid > ?
|
||||||
|
OR status IN ('running','pending')
|
||||||
|
OR (finished_at IS NOT NULL AND finished_at >= ?)
|
||||||
|
ORDER BY rowid ASC`, since.Steps, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return runs, nil, since, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := wsWatermark{Runs: maxRuns, Steps: maxSteps}
|
||||||
|
if out.Runs < since.Runs {
|
||||||
|
out.Runs = since.Runs
|
||||||
|
}
|
||||||
|
if out.Steps < since.Steps {
|
||||||
|
out.Steps = since.Steps
|
||||||
|
}
|
||||||
|
return runs, steps, out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchSnapshot devuelve DAGs + ultimos N runs + sus step_results + watermark.
|
||||||
|
func (h *DagRunHub) fetchSnapshot() ([]DagInfo, []wsRun, []wsStep, wsWatermark, error) {
|
||||||
|
dags, err := h.executor.ListDAGs()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[dagws] list dags: %v", err)
|
||||||
|
dags = nil
|
||||||
|
}
|
||||||
|
conn := h.db.Conn()
|
||||||
|
if conn == nil {
|
||||||
|
return dags, nil, nil, wsWatermark{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
runs, maxRuns, err := scanRuns(conn, `
|
||||||
|
SELECT rowid, id, dag_name, dag_path, status, trigger, started_at,
|
||||||
|
COALESCE(finished_at,''), error
|
||||||
|
FROM dag_runs
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT ?`, dagWSSnapshotRuns)
|
||||||
|
if err != nil {
|
||||||
|
return dags, nil, nil, wsWatermark{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
steps, maxSteps, err := scanSteps(conn, `
|
||||||
|
SELECT rowid, id, run_id, step_name, status, exit_code, stdout, stderr,
|
||||||
|
COALESCE(started_at,''), COALESCE(finished_at,''), duration_ms, error
|
||||||
|
FROM dag_step_results
|
||||||
|
WHERE run_id IN (SELECT id FROM dag_runs ORDER BY started_at DESC LIMIT ?)
|
||||||
|
ORDER BY rowid ASC`, dagWSSnapshotRuns)
|
||||||
|
if err != nil {
|
||||||
|
return dags, runs, nil, wsWatermark{Runs: maxRuns}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dags, runs, steps, wsWatermark{Runs: maxRuns, Steps: maxSteps}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRuns(conn *sql.DB, q string, args ...any) ([]wsRun, int64, error) {
|
||||||
|
rows, err := conn.Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []wsRun
|
||||||
|
var max int64
|
||||||
|
for rows.Next() {
|
||||||
|
var r wsRun
|
||||||
|
var rowid int64
|
||||||
|
if err := rows.Scan(&rowid, &r.ID, &r.DagName, &r.DagPath, &r.Status,
|
||||||
|
&r.Trigger, &r.StartedAt, &r.FinishedAt, &r.Error); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if rowid > max {
|
||||||
|
max = rowid
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, max, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanSteps(conn *sql.DB, q string, args ...any) ([]wsStep, int64, error) {
|
||||||
|
rows, err := conn.Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []wsStep
|
||||||
|
var max int64
|
||||||
|
for rows.Next() {
|
||||||
|
var s wsStep
|
||||||
|
var rowid int64
|
||||||
|
if err := rows.Scan(&rowid, &s.ID, &s.RunID, &s.StepName, &s.Status,
|
||||||
|
&s.ExitCode, &s.Stdout, &s.Stderr, &s.StartedAt, &s.FinishedAt,
|
||||||
|
&s.DurationMs, &s.Error); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if rowid > max {
|
||||||
|
max = rowid
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, max, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DagRunHub) broadcast(msg wsDagMessage) {
|
||||||
|
h.mu.Lock()
|
||||||
|
subs := make([]*dagSubscriber, 0, len(h.subscribers))
|
||||||
|
for s := range h.subscribers {
|
||||||
|
subs = append(subs, s)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
for _, s := range subs {
|
||||||
|
select {
|
||||||
|
case s.out <- msg:
|
||||||
|
default:
|
||||||
|
log.Printf("[dagws] dropping frame for slow subscriber")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDagRunsWS upgrade WS y gestiona lifecycle.
|
||||||
|
// Endpoint: GET /api/ws/dagruns
|
||||||
|
func handleDagRunsWS(hub *DagRunHub) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[dagws] accept: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close(websocket.StatusInternalError, "closing")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(r.Context())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
sub := &dagSubscriber{
|
||||||
|
conn: conn,
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
out: make(chan wsDagMessage, 64),
|
||||||
|
}
|
||||||
|
hub.register(sub)
|
||||||
|
defer hub.unregister(sub)
|
||||||
|
|
||||||
|
dags, runs, steps, wm, err := hub.fetchSnapshot()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[dagws] snapshot: %v", err)
|
||||||
|
conn.Close(websocket.StatusInternalError, "snapshot failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hub.setWatermark(wm)
|
||||||
|
initial := wsDagMessage{
|
||||||
|
Type: "snapshot",
|
||||||
|
Watermark: wm,
|
||||||
|
Dags: dags,
|
||||||
|
Runs: runs,
|
||||||
|
Steps: steps,
|
||||||
|
ServerTime: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
if err := wsjson.Write(ctx, conn, initial); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
readErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
var cmd wsDagClientCmd
|
||||||
|
if err := wsjson.Read(ctx, conn, &cmd); err != nil {
|
||||||
|
readErr <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cmd.Watermark.Runs > 0 || cmd.Watermark.Steps > 0 {
|
||||||
|
runs, steps, wm, err := hub.fetchDelta(cmd.Watermark)
|
||||||
|
if err == nil && (len(runs) > 0 || len(steps) > 0) {
|
||||||
|
hub.setWatermark(wm)
|
||||||
|
select {
|
||||||
|
case sub.out <- wsDagMessage{
|
||||||
|
Type: "delta",
|
||||||
|
Watermark: wm,
|
||||||
|
Runs: runs,
|
||||||
|
Steps: steps,
|
||||||
|
ServerTime: time.Now().Unix(),
|
||||||
|
}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case err := <-readErr:
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case msg, ok := <-sub.out:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wctx, wcancel := context.WithTimeout(ctx, dagWSBroadcastTimeout)
|
||||||
|
err := wsjson.Write(wctx, conn, msg)
|
||||||
|
wcancel()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fn-registry/functions/core"
|
||||||
|
"fn-registry/functions/infra"
|
||||||
|
|
||||||
|
"dag-engine/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Executor orchestrates DAG parsing, validation, and execution.
|
||||||
|
type Executor struct {
|
||||||
|
store *store.DB
|
||||||
|
dagsDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExecutor creates a new executor.
|
||||||
|
func NewExecutor(s *store.DB, dagsDir string) *Executor {
|
||||||
|
return &Executor{store: s, dagsDir: dagsDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteDAG runs a DAG from a YAML file path and returns the run ID.
|
||||||
|
// It runs asynchronously: steps execute in topological order with parallel levels.
|
||||||
|
func (e *Executor) ExecuteDAG(ctx context.Context, dagPath string, trigger string) (string, error) {
|
||||||
|
data, err := os.ReadFile(dagPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read dag: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dag, err := core.DagParse(data)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse dag: %w", err)
|
||||||
|
}
|
||||||
|
dag.FilePath = dagPath
|
||||||
|
|
||||||
|
// Resolve env variables.
|
||||||
|
dag = core.DagResolveEnv(dag, os.Environ())
|
||||||
|
|
||||||
|
// Validate.
|
||||||
|
result := core.DagValidate(dag)
|
||||||
|
if !result.Valid {
|
||||||
|
return "", fmt.Errorf("validate dag: %s", strings.Join(result.Errors, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create run record.
|
||||||
|
runID := generateID()
|
||||||
|
now := time.Now()
|
||||||
|
run := &store.DagRun{
|
||||||
|
ID: runID,
|
||||||
|
DagName: dag.Name,
|
||||||
|
DagPath: dagPath,
|
||||||
|
Status: "running",
|
||||||
|
Trigger: trigger,
|
||||||
|
StartedAt: now,
|
||||||
|
}
|
||||||
|
if err := e.store.CreateRun(run); err != nil {
|
||||||
|
return "", fmt.Errorf("create run: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Topological sort.
|
||||||
|
levels, err := core.DagTopoSort(dag.Steps)
|
||||||
|
if err != nil {
|
||||||
|
e.failRun(runID, err)
|
||||||
|
return runID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup DAGU_ENV temp file for inter-step communication.
|
||||||
|
daguEnvFile, err := os.CreateTemp("", "dagu_env_*")
|
||||||
|
if err != nil {
|
||||||
|
e.failRun(runID, err)
|
||||||
|
return runID, err
|
||||||
|
}
|
||||||
|
daguEnvPath := daguEnvFile.Name()
|
||||||
|
daguEnvFile.Close()
|
||||||
|
defer os.Remove(daguEnvPath)
|
||||||
|
|
||||||
|
// Track step outputs for ${step_id.stdout} references.
|
||||||
|
stepOutputs := make(map[string]string)
|
||||||
|
|
||||||
|
// Execute levels.
|
||||||
|
runFailed := false
|
||||||
|
var runErr error
|
||||||
|
|
||||||
|
for _, level := range levels {
|
||||||
|
if runFailed {
|
||||||
|
// Skip remaining levels, mark steps as skipped.
|
||||||
|
for _, step := range level {
|
||||||
|
e.recordStepSkipped(runID, step)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
levelFailed := false
|
||||||
|
|
||||||
|
for _, step := range level {
|
||||||
|
step := step
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
if levelFailed {
|
||||||
|
mu.Unlock()
|
||||||
|
e.recordStepSkipped(runID, step)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
err := e.executeStep(ctx, runID, dag, step, daguEnvPath, stepOutputs, &mu)
|
||||||
|
if err != nil && !step.ContinueOn.Failure {
|
||||||
|
mu.Lock()
|
||||||
|
levelFailed = true
|
||||||
|
runFailed = true
|
||||||
|
runErr = fmt.Errorf("step %q failed: %w", stepName(step), err)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run handlers.
|
||||||
|
if runFailed {
|
||||||
|
e.runHandlers(ctx, runID, dag, dag.HandlerOn.Failure, daguEnvPath, stepOutputs)
|
||||||
|
} else {
|
||||||
|
e.runHandlers(ctx, runID, dag, dag.HandlerOn.Success, daguEnvPath, stepOutputs)
|
||||||
|
}
|
||||||
|
e.runHandlers(ctx, runID, dag, dag.HandlerOn.Exit, daguEnvPath, stepOutputs)
|
||||||
|
|
||||||
|
// Finalize run.
|
||||||
|
fin := time.Now()
|
||||||
|
status := "success"
|
||||||
|
errMsg := ""
|
||||||
|
if runFailed {
|
||||||
|
status = "failed"
|
||||||
|
if runErr != nil {
|
||||||
|
errMsg = runErr.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.store.UpdateRunStatus(runID, status, &fin, errMsg)
|
||||||
|
|
||||||
|
return runID, runErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeStep runs a single step, recording results in the store.
|
||||||
|
func (e *Executor) executeStep(ctx context.Context, runID string, dag core.DagDefinition, step core.DagStep, daguEnvPath string, outputs map[string]string, mu *sync.Mutex) error {
|
||||||
|
stepID := generateID()
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Resolve command source: function (registry) takes precedence over command/script.
|
||||||
|
var command string
|
||||||
|
var stepFunctionID string
|
||||||
|
var fnRegistryRoot string
|
||||||
|
if step.Function != "" {
|
||||||
|
stepFunctionID = step.Function
|
||||||
|
fnRegistryRoot = os.Getenv("FN_REGISTRY_ROOT")
|
||||||
|
if fnRegistryRoot == "" {
|
||||||
|
fnRegistryRoot = "/home/lucas/fn_registry"
|
||||||
|
}
|
||||||
|
fnBin := os.Getenv("FN_BIN")
|
||||||
|
if fnBin == "" {
|
||||||
|
fnBin = fnRegistryRoot + "/fn"
|
||||||
|
}
|
||||||
|
parts := []string{fnBin, "run", step.Function}
|
||||||
|
parts = append(parts, step.Args...)
|
||||||
|
command = strings.Join(parts, " ")
|
||||||
|
} else if step.Command != "" {
|
||||||
|
command = step.Command
|
||||||
|
} else if step.Script != "" {
|
||||||
|
command = step.Script
|
||||||
|
}
|
||||||
|
|
||||||
|
e.store.InsertStepResult(&store.DagStepResult{
|
||||||
|
ID: stepID,
|
||||||
|
RunID: runID,
|
||||||
|
StepName: stepName(step),
|
||||||
|
FunctionID: stepFunctionID,
|
||||||
|
Status: "running",
|
||||||
|
StartedAt: &now,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build environment.
|
||||||
|
env := buildStepEnv(dag, step, daguEnvPath, outputs)
|
||||||
|
|
||||||
|
// For function: steps, force FN_REGISTRY_ROOT into env so `fn run`
|
||||||
|
// resolves the canonical registry.db (not whatever lives at the spawn cwd).
|
||||||
|
// Prevents the apps/dag_engine/registry.db stale-shadow bug (2026-05-16).
|
||||||
|
if stepFunctionID != "" {
|
||||||
|
env = append(env, "FN_REGISTRY_ROOT="+fnRegistryRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
if command == "" {
|
||||||
|
e.store.UpdateStepResult(stepID, "skipped", 0, "", "", nil, 0, "no command or script")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve step-level ${VAR} references and ${step_id.stdout} patterns.
|
||||||
|
mu.Lock()
|
||||||
|
command = resolveStepRefs(command, outputs)
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
// Determine working directory. function: steps default to FN_REGISTRY_ROOT
|
||||||
|
// so `fn` resolves registry.db correctly via go.mod walk-up.
|
||||||
|
dir := step.Dir
|
||||||
|
if dir == "" {
|
||||||
|
dir = dag.WorkingDir
|
||||||
|
}
|
||||||
|
if dir == "" && stepFunctionID != "" {
|
||||||
|
dir = fnRegistryRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
shell := step.Shell
|
||||||
|
if shell == "" {
|
||||||
|
shell = dag.Shell
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn process.
|
||||||
|
handle, err := infra.ProcessSpawn(command, dir, env, shell)
|
||||||
|
if err != nil {
|
||||||
|
fin := time.Now()
|
||||||
|
e.store.UpdateStepResult(stepID, "failed", -1, "", "", &fin, time.Since(now).Milliseconds(), err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for process.
|
||||||
|
result, err := infra.ProcessWait(handle, step.TimeoutSec)
|
||||||
|
fin := time.Now()
|
||||||
|
duration := time.Since(now).Milliseconds()
|
||||||
|
|
||||||
|
if err != nil && result.ExitCode == 0 {
|
||||||
|
result.ExitCode = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "success"
|
||||||
|
errMsg := ""
|
||||||
|
if result.ExitCode != 0 || err != nil {
|
||||||
|
status = "failed"
|
||||||
|
if err != nil {
|
||||||
|
errMsg = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.store.UpdateStepResult(stepID, status, result.ExitCode, result.Stdout, result.Stderr, &fin, duration, errMsg)
|
||||||
|
|
||||||
|
// Store output for ${step_id.stdout} references.
|
||||||
|
if step.ID != "" || step.Output != "" {
|
||||||
|
mu.Lock()
|
||||||
|
key := step.ID
|
||||||
|
if key == "" {
|
||||||
|
key = step.Output
|
||||||
|
}
|
||||||
|
outputs[key] = strings.TrimSpace(result.Stdout)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read DAGU_ENV for inter-step env propagation.
|
||||||
|
readDaguEnv(daguEnvPath, outputs)
|
||||||
|
|
||||||
|
if status == "failed" {
|
||||||
|
return fmt.Errorf("exit code %d", result.ExitCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) runHandlers(ctx context.Context, runID string, dag core.DagDefinition, handlers []core.DagStep, daguEnvPath string, outputs map[string]string) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
for _, step := range handlers {
|
||||||
|
e.executeStep(ctx, runID, dag, step, daguEnvPath, outputs, &mu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) failRun(runID string, err error) {
|
||||||
|
fin := time.Now()
|
||||||
|
e.store.UpdateRunStatus(runID, "failed", &fin, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) recordStepSkipped(runID string, step core.DagStep) {
|
||||||
|
now := time.Now()
|
||||||
|
e.store.InsertStepResult(&store.DagStepResult{
|
||||||
|
ID: generateID(),
|
||||||
|
RunID: runID,
|
||||||
|
StepName: stepName(step),
|
||||||
|
Status: "skipped",
|
||||||
|
StartedAt: &now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func stepName(s core.DagStep) string {
|
||||||
|
if s.Name != "" {
|
||||||
|
return s.Name
|
||||||
|
}
|
||||||
|
return s.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStepEnv(dag core.DagDefinition, step core.DagStep, daguEnvPath string, outputs map[string]string) []string {
|
||||||
|
env := os.Environ()
|
||||||
|
|
||||||
|
// Add DAG-level env.
|
||||||
|
for k, v := range dag.Env {
|
||||||
|
env = append(env, k+"="+v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add step-level env.
|
||||||
|
for k, v := range step.Env {
|
||||||
|
env = append(env, k+"="+v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add DAGU_ENV path.
|
||||||
|
env = append(env, "DAGU_ENV="+daguEnvPath)
|
||||||
|
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveStepRefs(command string, outputs map[string]string) string {
|
||||||
|
for k, v := range outputs {
|
||||||
|
command = strings.ReplaceAll(command, "${"+k+".stdout}", v)
|
||||||
|
command = strings.ReplaceAll(command, "$"+k+".stdout", v)
|
||||||
|
}
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDaguEnv(path string, outputs map[string]string) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
outputs[parts[0]] = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateID creates a simple time-based unique ID.
|
||||||
|
func generateID() string {
|
||||||
|
return fmt.Sprintf("%d-%04x", time.Now().UnixNano(), time.Now().Nanosecond()%0xFFFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DAG listing helpers ---
|
||||||
|
|
||||||
|
// DagInfo summarizes a DAG file for listing.
|
||||||
|
type DagInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Schedule []string `json:"schedule,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
FilePath string `json:"file_path"`
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
LastRun *store.DagRun `json:"last_run,omitempty"`
|
||||||
|
LastRuns []store.DagRun `json:"last_runs,omitempty"` // 5 mas recientes (mas reciente primero)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDAGs scans a directory for YAML files and returns parsed DAG info.
|
||||||
|
func (e *Executor) ListDAGs() ([]DagInfo, error) {
|
||||||
|
entries, err := os.ReadDir(e.dagsDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read dags dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dags []DagInfo
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := filepath.Ext(entry.Name())
|
||||||
|
if ext != ".yaml" && ext != ".yml" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(e.dagsDir, entry.Name())
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dag, err := core.DagParse(data)
|
||||||
|
if err != nil {
|
||||||
|
dags = append(dags, DagInfo{
|
||||||
|
Name: strings.TrimSuffix(entry.Name(), ext),
|
||||||
|
FilePath: path,
|
||||||
|
Valid: false,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
info := DagInfo{
|
||||||
|
Name: dag.Name,
|
||||||
|
Description: dag.Description,
|
||||||
|
Schedule: dag.Schedule,
|
||||||
|
Tags: dag.Tags,
|
||||||
|
Type: dag.Type,
|
||||||
|
FilePath: path,
|
||||||
|
Valid: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach last 5 runs (most recent first).
|
||||||
|
runs, _, _ := e.store.ListRuns(dag.Name, 5, 0)
|
||||||
|
if len(runs) > 0 {
|
||||||
|
info.LastRun = &runs[0]
|
||||||
|
info.LastRuns = runs
|
||||||
|
}
|
||||||
|
|
||||||
|
dags = append(dags, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDAG returns detailed info for a specific DAG by name.
|
||||||
|
func (e *Executor) GetDAG(name string) (*DagInfo, *core.DagDefinition, *core.DagValidationResult, error) {
|
||||||
|
// Find the YAML file.
|
||||||
|
entries, err := os.ReadDir(e.dagsDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
ext := filepath.Ext(entry.Name())
|
||||||
|
base := strings.TrimSuffix(entry.Name(), ext)
|
||||||
|
if (ext != ".yaml" && ext != ".yml") || base != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(e.dagsDir, entry.Name())
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dag, err := core.DagParse(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, fmt.Errorf("parse: %w", err)
|
||||||
|
}
|
||||||
|
dag.FilePath = path
|
||||||
|
|
||||||
|
validationResult := core.DagValidate(dag)
|
||||||
|
|
||||||
|
info := &DagInfo{
|
||||||
|
Name: dag.Name,
|
||||||
|
Description: dag.Description,
|
||||||
|
Schedule: dag.Schedule,
|
||||||
|
Tags: dag.Tags,
|
||||||
|
Type: dag.Type,
|
||||||
|
FilePath: path,
|
||||||
|
Valid: validationResult.Valid,
|
||||||
|
}
|
||||||
|
|
||||||
|
runs, _, _ := e.store.ListRuns(dag.Name, 1, 0)
|
||||||
|
if len(runs) > 0 {
|
||||||
|
info.LastRun = &runs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, &dag, &validationResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil, nil, fmt.Errorf("dag %q not found in %s", name, e.dagsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDAG parses and validates a DAG file, printing results.
|
||||||
|
func ValidateDAG(path string) error {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dag, err := core.DagParse(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := core.DagValidate(dag)
|
||||||
|
|
||||||
|
log.Printf("DAG: %s", dag.Name)
|
||||||
|
log.Printf("Steps: %d", len(dag.Steps))
|
||||||
|
log.Printf("Schedule: %v", dag.Schedule)
|
||||||
|
|
||||||
|
if result.Valid {
|
||||||
|
log.Printf("Validation: PASS")
|
||||||
|
log.Printf("Topological levels: %d", len(result.Levels))
|
||||||
|
for i, level := range result.Levels {
|
||||||
|
log.Printf(" Level %d: %v", i, level)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("Validation: FAIL")
|
||||||
|
for _, e := range result.Errors {
|
||||||
|
log.Printf(" ERROR: %s", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, w := range result.Warnings {
|
||||||
|
log.Printf(" WARNING: %s", w)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Valid {
|
||||||
|
return fmt.Errorf("validation failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>DAG Engine</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "dag-engine-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mantine/core": "^9.0.2",
|
||||||
|
"@mantine/hooks": "^9.0.2",
|
||||||
|
"@tabler/icons-react": "^3.31.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router-dom": "^7.6.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.1.6",
|
||||||
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@vitejs/plugin-react": "^4.5.2",
|
||||||
|
"postcss": "^8.5.4",
|
||||||
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "^6.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
"postcss-preset-mantine": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import { AppShell, Container, Title, Group, Text } from "@mantine/core";
|
||||||
|
import { IconTopologyRing } from "@tabler/icons-react";
|
||||||
|
import { DagList } from "./pages/DagList";
|
||||||
|
import { DagDetail } from "./pages/DagDetail";
|
||||||
|
import { RunDetail } from "./pages/RunDetail";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<AppShell header={{ height: 50 }} padding="md">
|
||||||
|
<AppShell.Header>
|
||||||
|
<Group h="100%" px="md">
|
||||||
|
<IconTopologyRing size={24} />
|
||||||
|
<Title order={4}>DAG Engine</Title>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
fn_registry workflow executor
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</AppShell.Header>
|
||||||
|
|
||||||
|
<AppShell.Main>
|
||||||
|
<Container size="lg">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<DagList />} />
|
||||||
|
<Route path="/dags/:name" element={<DagDetail />} />
|
||||||
|
<Route path="/runs/:id" element={<RunDetail />} />
|
||||||
|
</Routes>
|
||||||
|
</Container>
|
||||||
|
</AppShell.Main>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type {
|
||||||
|
DagSummary,
|
||||||
|
DagDetail,
|
||||||
|
DagRun,
|
||||||
|
RunDetail,
|
||||||
|
SchedulerStatus,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const BASE = "/api";
|
||||||
|
|
||||||
|
async function fetchJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, init);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
throw new Error(err.error || res.statusText);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDags(): Promise<DagSummary[]> {
|
||||||
|
return fetchJSON("/dags");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDag(name: string): Promise<DagDetail> {
|
||||||
|
return fetchJSON(`/dags/${encodeURIComponent(name)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerDag(
|
||||||
|
name: string
|
||||||
|
): Promise<{ status: string; dag: string; message: string }> {
|
||||||
|
return fetchJSON(`/dags/${encodeURIComponent(name)}/run`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRuns(params?: {
|
||||||
|
dag?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<{ runs: DagRun[]; total: number }> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params?.dag) search.set("dag", params.dag);
|
||||||
|
if (params?.limit) search.set("limit", String(params.limit));
|
||||||
|
if (params?.offset) search.set("offset", String(params.offset));
|
||||||
|
const qs = search.toString();
|
||||||
|
return fetchJSON(`/runs${qs ? "?" + qs : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRun(id: string): Promise<RunDetail> {
|
||||||
|
return fetchJSON(`/runs/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startScheduler(): Promise<void> {
|
||||||
|
return fetchJSON("/scheduler/start", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopScheduler(): Promise<void> {
|
||||||
|
return fetchJSON("/scheduler/stop", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSchedulerStatus(): Promise<SchedulerStatus> {
|
||||||
|
return fetchJSON("/scheduler/status");
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Badge } from "@mantine/core";
|
||||||
|
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
success: "green",
|
||||||
|
failed: "red",
|
||||||
|
running: "blue",
|
||||||
|
pending: "gray",
|
||||||
|
cancelled: "yellow",
|
||||||
|
skipped: "dimmed",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: string }) {
|
||||||
|
return (
|
||||||
|
<Badge color={colorMap[status] || "gray"} variant="light" size="sm">
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Timeline, Text, Code, Collapse, Box, Group } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconCircleCheck,
|
||||||
|
IconCircleX,
|
||||||
|
IconLoader,
|
||||||
|
IconCircleMinus,
|
||||||
|
IconClock,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import type { DagStepResult } from "../types";
|
||||||
|
|
||||||
|
const iconMap: Record<string, React.ReactNode> = {
|
||||||
|
success: <IconCircleCheck size={16} color="var(--mantine-color-green-6)" />,
|
||||||
|
failed: <IconCircleX size={16} color="var(--mantine-color-red-6)" />,
|
||||||
|
running: <IconLoader size={16} color="var(--mantine-color-blue-6)" />,
|
||||||
|
skipped: <IconCircleMinus size={16} color="var(--mantine-color-dimmed)" />,
|
||||||
|
pending: <IconClock size={16} color="var(--mantine-color-gray-6)" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
function StepItem({ step }: { step: DagStepResult }) {
|
||||||
|
const [opened, { toggle }] = useDisclosure(step.Status === "failed");
|
||||||
|
const hasOutput = step.Stdout || step.Stderr;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline.Item
|
||||||
|
bullet={iconMap[step.Status] || iconMap.pending}
|
||||||
|
title={
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={500}
|
||||||
|
onClick={hasOutput ? toggle : undefined}
|
||||||
|
style={hasOutput ? { cursor: "pointer" } : undefined}
|
||||||
|
>
|
||||||
|
{step.StepName}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{step.DurationMs}ms
|
||||||
|
</Text>
|
||||||
|
{step.ExitCode !== 0 && step.ExitCode !== -1 && (
|
||||||
|
<Text size="xs" c="red">
|
||||||
|
exit {step.ExitCode}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hasOutput && (
|
||||||
|
<Collapse in={opened}>
|
||||||
|
<Box mt="xs">
|
||||||
|
{step.Stdout && (
|
||||||
|
<Code block mb="xs" style={{ maxHeight: 200, overflow: "auto" }}>
|
||||||
|
{step.Stdout}
|
||||||
|
</Code>
|
||||||
|
)}
|
||||||
|
{step.Stderr && (
|
||||||
|
<Code
|
||||||
|
block
|
||||||
|
color="red"
|
||||||
|
style={{ maxHeight: 200, overflow: "auto" }}
|
||||||
|
>
|
||||||
|
{step.Stderr}
|
||||||
|
</Code>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
</Timeline.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepTimeline({ steps }: { steps: DagStepResult[] }) {
|
||||||
|
const activeIndex = steps.findIndex((s) => s.Status === "running");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline
|
||||||
|
active={activeIndex >= 0 ? activeIndex : steps.length - 1}
|
||||||
|
bulletSize={24}
|
||||||
|
>
|
||||||
|
{steps.map((step) => (
|
||||||
|
<StepItem key={step.ID} step={step} />
|
||||||
|
))}
|
||||||
|
</Timeline>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import "@mantine/core/styles.css";
|
||||||
|
import { MantineProvider, createTheme } from "@mantine/core";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { App } from "./App";
|
||||||
|
|
||||||
|
const theme = createTheme({
|
||||||
|
primaryColor: "blue",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
});
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<MantineProvider theme={theme} defaultColorScheme="dark">
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Title,
|
||||||
|
Text,
|
||||||
|
Group,
|
||||||
|
Button,
|
||||||
|
Badge,
|
||||||
|
Stack,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
Alert,
|
||||||
|
Loader,
|
||||||
|
Code,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconPlayerPlay, IconArrowLeft } from "@tabler/icons-react";
|
||||||
|
import { getDag, triggerDag } from "../api";
|
||||||
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
|
import type { DagDetail as DagDetailType } from "../types";
|
||||||
|
|
||||||
|
export function DagDetail() {
|
||||||
|
const { name } = useParams<{ name: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [data, setData] = useState<DagDetailType | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [triggering, setTriggering] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (!name) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setData(await getDag(name));
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
const handleRun = async () => {
|
||||||
|
if (!name) return;
|
||||||
|
setTriggering(true);
|
||||||
|
try {
|
||||||
|
await triggerDag(name);
|
||||||
|
setTimeout(load, 1000);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setTriggering(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <Loader />;
|
||||||
|
if (error) return <Alert color="red">{error}</Alert>;
|
||||||
|
if (!data) return <Text>Not found</Text>;
|
||||||
|
|
||||||
|
const { dag, validation, runs } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<IconArrowLeft size={14} />}
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>{dag.Name}</Title>
|
||||||
|
{dag.Description && (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{dag.Description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconPlayerPlay size={16} />}
|
||||||
|
onClick={handleRun}
|
||||||
|
loading={triggering}
|
||||||
|
>
|
||||||
|
Run Now
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="xs">
|
||||||
|
{dag.Schedule?.map((s: string) => (
|
||||||
|
<Badge key={s} variant="light" ff="monospace">
|
||||||
|
{s}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
<Badge variant="light">{dag.Type || "chain"}</Badge>
|
||||||
|
{dag.Tags?.map((t: string) => (
|
||||||
|
<Badge key={t} variant="dot">
|
||||||
|
{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!validation.Valid && (
|
||||||
|
<Alert color="red" title="Validation errors">
|
||||||
|
{validation.Errors.map((e: string, i: number) => (
|
||||||
|
<Text key={i} size="sm">
|
||||||
|
{e}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper p="md" withBorder>
|
||||||
|
<Title order={4} mb="sm">
|
||||||
|
Steps ({dag.Steps?.length || 0})
|
||||||
|
</Title>
|
||||||
|
{validation.Levels?.map((level: string[], i: number) => (
|
||||||
|
<Group key={i} gap="xs" mb="xs">
|
||||||
|
<Text size="xs" c="dimmed" w={60}>
|
||||||
|
Level {i}:
|
||||||
|
</Text>
|
||||||
|
{level.map((name: string) => {
|
||||||
|
const step = dag.Steps?.find(
|
||||||
|
(s) => s.Name === name || s.ID === name
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Badge key={name} variant="outline" size="sm">
|
||||||
|
{name}
|
||||||
|
{step?.Depends?.length
|
||||||
|
? ` (after ${step.Depends.join(",")})`
|
||||||
|
: ""}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{dag.Env && Object.keys(dag.Env).length > 0 && (
|
||||||
|
<>
|
||||||
|
<Title order={5} mt="md" mb="xs">
|
||||||
|
Environment
|
||||||
|
</Title>
|
||||||
|
<Code block>
|
||||||
|
{Object.entries(dag.Env)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join("\n")}
|
||||||
|
</Code>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper p="md" withBorder>
|
||||||
|
<Title order={4} mb="sm">
|
||||||
|
Run History
|
||||||
|
</Title>
|
||||||
|
{runs?.length ? (
|
||||||
|
<Table striped>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th>Trigger</Table.Th>
|
||||||
|
<Table.Th>Started</Table.Th>
|
||||||
|
<Table.Th>Duration</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{runs.map((r) => (
|
||||||
|
<Table.Tr
|
||||||
|
key={r.ID}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(`/runs/${r.ID}`)}
|
||||||
|
>
|
||||||
|
<Table.Td>
|
||||||
|
<StatusBadge status={r.Status} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{r.Trigger}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{new Date(r.StartedAt).toLocaleString()}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{r.FinishedAt
|
||||||
|
? `${Math.round((new Date(r.FinishedAt).getTime() - new Date(r.StartedAt).getTime()) / 1000)}s`
|
||||||
|
: "running..."}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No runs yet
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Title,
|
||||||
|
Group,
|
||||||
|
Button,
|
||||||
|
Badge,
|
||||||
|
Text,
|
||||||
|
Loader,
|
||||||
|
Stack,
|
||||||
|
Alert,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconPlayerPlay,
|
||||||
|
IconPlayerStop,
|
||||||
|
IconRefresh,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { listDags, getSchedulerStatus, startScheduler, stopScheduler } from "../api";
|
||||||
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
|
import type { DagSummary, SchedulerStatus } from "../types";
|
||||||
|
|
||||||
|
export function DagList() {
|
||||||
|
const [dags, setDags] = useState<DagSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [scheduler, setScheduler] = useState<SchedulerStatus | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [d, s] = await Promise.all([listDags(), getSchedulerStatus()]);
|
||||||
|
setDags(d || []);
|
||||||
|
setScheduler(s);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
const interval = setInterval(load, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleScheduler = async () => {
|
||||||
|
if (scheduler?.running) {
|
||||||
|
await stopScheduler();
|
||||||
|
} else {
|
||||||
|
await startScheduler();
|
||||||
|
}
|
||||||
|
const s = await getSchedulerStatus();
|
||||||
|
setScheduler(s);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Title order={2}>DAGs</Title>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconRefresh size={14} />}
|
||||||
|
onClick={load}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant={scheduler?.running ? "filled" : "light"}
|
||||||
|
color={scheduler?.running ? "green" : "gray"}
|
||||||
|
leftSection={
|
||||||
|
scheduler?.running ? (
|
||||||
|
<IconPlayerStop size={14} />
|
||||||
|
) : (
|
||||||
|
<IconPlayerPlay size={14} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={toggleScheduler}
|
||||||
|
>
|
||||||
|
Scheduler {scheduler?.running ? "ON" : "OFF"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{error && <Alert color="red">{error}</Alert>}
|
||||||
|
|
||||||
|
{loading && !dags.length ? (
|
||||||
|
<Loader />
|
||||||
|
) : (
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Name</Table.Th>
|
||||||
|
<Table.Th>Schedule</Table.Th>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Tags</Table.Th>
|
||||||
|
<Table.Th>Last Status</Table.Th>
|
||||||
|
<Table.Th>Last Run</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{dags.map((d) => (
|
||||||
|
<Table.Tr
|
||||||
|
key={d.file_path}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(`/dags/${d.name}`)}
|
||||||
|
>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={500}>{d.name}</Text>
|
||||||
|
{d.description && (
|
||||||
|
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||||
|
{d.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs" ff="monospace">
|
||||||
|
{d.schedule?.join(", ") || "-"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge variant="light" size="xs">
|
||||||
|
{d.type || "chain"}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={4}>
|
||||||
|
{d.tags?.map((t) => (
|
||||||
|
<Badge key={t} variant="dot" size="xs">
|
||||||
|
{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{d.last_run ? (
|
||||||
|
<StatusBadge status={d.last_run.Status} />
|
||||||
|
) : (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
-
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs">
|
||||||
|
{d.last_run
|
||||||
|
? new Date(d.last_run.StartedAt).toLocaleString()
|
||||||
|
: "-"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Title,
|
||||||
|
Text,
|
||||||
|
Group,
|
||||||
|
Button,
|
||||||
|
Stack,
|
||||||
|
Paper,
|
||||||
|
Alert,
|
||||||
|
Loader,
|
||||||
|
CopyButton,
|
||||||
|
Tooltip,
|
||||||
|
ActionIcon,
|
||||||
|
Code,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconArrowLeft, IconCopy, IconCheck } from "@tabler/icons-react";
|
||||||
|
import { getRun } from "../api";
|
||||||
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
|
import { StepTimeline } from "../components/StepTimeline";
|
||||||
|
import type { RunDetail as RunDetailType, DagStepResult, DagRun } from "../types";
|
||||||
|
|
||||||
|
function buildLogText(run: DagRun, steps: DagStepResult[]): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const started = run.StartedAt ? new Date(run.StartedAt) : null;
|
||||||
|
const finished = run.FinishedAt ? new Date(run.FinishedAt) : null;
|
||||||
|
const durationMs =
|
||||||
|
started && finished ? finished.getTime() - started.getTime() : null;
|
||||||
|
|
||||||
|
lines.push(`=== DAG run ${run.ID} ===`);
|
||||||
|
lines.push(`dag: ${run.DagName}`);
|
||||||
|
lines.push(`path: ${run.DagPath}`);
|
||||||
|
lines.push(`status: ${run.Status}`);
|
||||||
|
lines.push(`trigger: ${run.Trigger}`);
|
||||||
|
lines.push(`started: ${started ? started.toISOString() : "-"}`);
|
||||||
|
lines.push(`finished: ${finished ? finished.toISOString() : "-"}`);
|
||||||
|
lines.push(
|
||||||
|
`duration: ${durationMs !== null ? `${durationMs} ms` : "running..."}`
|
||||||
|
);
|
||||||
|
if (run.Error) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("run error:");
|
||||||
|
lines.push(run.Error);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`--- steps (${steps.length}) ---`);
|
||||||
|
for (const s of steps) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push(
|
||||||
|
`[${s.Status}] ${s.StepName} exit=${s.ExitCode} ${s.DurationMs}ms`
|
||||||
|
);
|
||||||
|
if (s.StartedAt) lines.push(` started: ${s.StartedAt}`);
|
||||||
|
if (s.FinishedAt) lines.push(` finished: ${s.FinishedAt}`);
|
||||||
|
if (s.Error) {
|
||||||
|
lines.push(" error:");
|
||||||
|
lines.push(s.Error.split("\n").map((l) => " " + l).join("\n"));
|
||||||
|
}
|
||||||
|
if (s.Stdout) {
|
||||||
|
lines.push(" stdout:");
|
||||||
|
lines.push(s.Stdout.split("\n").map((l) => " " + l).join("\n"));
|
||||||
|
}
|
||||||
|
if (s.Stderr) {
|
||||||
|
lines.push(" stderr:");
|
||||||
|
lines.push(s.Stderr.split("\n").map((l) => " " + l).join("\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [data, setData] = useState<RunDetailType | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
setData(await getRun(id));
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
// Auto-refresh while running.
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (data?.run.Status === "running") {
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [id, data?.run.Status]);
|
||||||
|
|
||||||
|
if (loading) return <Loader />;
|
||||||
|
if (error) return <Alert color="red">{error}</Alert>;
|
||||||
|
if (!data) return <Text>Not found</Text>;
|
||||||
|
|
||||||
|
const { run, steps } = data;
|
||||||
|
const duration = run.FinishedAt
|
||||||
|
? `${Math.round((new Date(run.FinishedAt).getTime() - new Date(run.StartedAt).getTime()) / 1000)}s`
|
||||||
|
: "running...";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<IconArrowLeft size={14} />}
|
||||||
|
onClick={() => navigate(`/dags/${run.DagName}`)}
|
||||||
|
>
|
||||||
|
Back to {run.DagName}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Run {run.ID.substring(0, 16)}...</Title>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{run.DagName} · {run.Trigger} ·{" "}
|
||||||
|
{new Date(run.StartedAt).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Group gap="xs">
|
||||||
|
<StatusBadge status={run.Status} />
|
||||||
|
<Text size="sm">{duration}</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{run.Error && (
|
||||||
|
<Alert color="red" title="Error">
|
||||||
|
{run.Error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper p="md" withBorder>
|
||||||
|
<Title order={4} mb="md">
|
||||||
|
Steps ({steps?.length || 0})
|
||||||
|
</Title>
|
||||||
|
{steps?.length ? (
|
||||||
|
<StepTimeline steps={steps} />
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No steps recorded
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper p="md" withBorder>
|
||||||
|
<Group justify="space-between" mb="sm">
|
||||||
|
<Title order={4}>Logs</Title>
|
||||||
|
<CopyButton value={buildLogText(run, steps || [])} timeout={1500}>
|
||||||
|
{({ copied, copy }) => (
|
||||||
|
<Tooltip
|
||||||
|
label={copied ? "Copiado" : "Copiar log completo"}
|
||||||
|
withArrow
|
||||||
|
position="left"
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
variant={copied ? "filled" : "light"}
|
||||||
|
color={copied ? "teal" : "blue"}
|
||||||
|
onClick={copy}
|
||||||
|
aria-label="Copiar logs"
|
||||||
|
>
|
||||||
|
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</CopyButton>
|
||||||
|
</Group>
|
||||||
|
<Code
|
||||||
|
block
|
||||||
|
style={{
|
||||||
|
maxHeight: 480,
|
||||||
|
overflow: "auto",
|
||||||
|
whiteSpace: "pre",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{buildLogText(run, steps || [])}
|
||||||
|
</Code>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
export interface DagSummary {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
schedule?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
type?: string;
|
||||||
|
file_path: string;
|
||||||
|
valid: boolean;
|
||||||
|
last_run?: DagRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DagRun {
|
||||||
|
ID: string;
|
||||||
|
DagName: string;
|
||||||
|
DagPath: string;
|
||||||
|
Status: string;
|
||||||
|
Trigger: string;
|
||||||
|
StartedAt: string;
|
||||||
|
FinishedAt?: string;
|
||||||
|
Error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DagStepResult {
|
||||||
|
ID: string;
|
||||||
|
RunID: string;
|
||||||
|
StepName: string;
|
||||||
|
Status: string;
|
||||||
|
ExitCode: number;
|
||||||
|
Stdout: string;
|
||||||
|
Stderr: string;
|
||||||
|
StartedAt?: string;
|
||||||
|
FinishedAt?: string;
|
||||||
|
DurationMs: number;
|
||||||
|
Error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DagDetail {
|
||||||
|
info: DagSummary;
|
||||||
|
dag: {
|
||||||
|
Name: string;
|
||||||
|
Description: string;
|
||||||
|
Type: string;
|
||||||
|
Schedule: string[];
|
||||||
|
Steps: { Name: string; ID: string; Command: string; Script: string; Depends: string[] }[];
|
||||||
|
Env: Record<string, string>;
|
||||||
|
Tags: string[];
|
||||||
|
HandlerOn: { Failure: unknown[]; Success: unknown[] };
|
||||||
|
};
|
||||||
|
validation: {
|
||||||
|
Valid: boolean;
|
||||||
|
Errors: string[];
|
||||||
|
Warnings: string[];
|
||||||
|
Levels: string[][];
|
||||||
|
};
|
||||||
|
runs: DagRun[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunDetail {
|
||||||
|
run: DagRun;
|
||||||
|
steps: DagStepResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulerStatus {
|
||||||
|
running: boolean;
|
||||||
|
dags: { name: string; path: string; schedule: string; next_run: string }[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5175,
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:8090",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
module dag-engine
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
fn-registry v0.0.0-00010101000000-000000000000
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.37
|
||||||
|
nhooyr.io/websocket v1.8.17
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/ClickHouse/ch-go v0.71.0 // indirect
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 // indirect
|
||||||
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
|
github.com/apache/arrow-go/v18 v18.1.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/go-faster/city v1.0.1 // indirect
|
||||||
|
github.com/go-faster/errors v0.7.1 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/google/flatbuffers v25.1.24+incompatible // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.9.1 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.3 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||||
|
github.com/marcboeker/go-duckdb v1.8.5 // indirect
|
||||||
|
github.com/paulmach/orb v0.12.0 // indirect
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.25 // indirect
|
||||||
|
github.com/segmentio/asm v1.2.1 // indirect
|
||||||
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/crypto v0.50.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect
|
||||||
|
golang.org/x/mod v0.34.0 // indirect
|
||||||
|
golang.org/x/net v0.53.0 // indirect
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
|
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
|
||||||
|
golang.org/x/text v0.36.0 // indirect
|
||||||
|
golang.org/x/tools v0.43.0 // indirect
|
||||||
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace fn-registry => /home/lucas/fn_registry
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
|
||||||
|
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ=
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
|
||||||
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
|
github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y=
|
||||||
|
github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0=
|
||||||
|
github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE=
|
||||||
|
github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||||
|
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||||
|
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||||
|
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||||
|
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLKOaJN/AC6puCca6Jm7o=
|
||||||
|
github.com/google/flatbuffers v25.1.24+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||||
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
|
||||||
|
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
|
||||||
|
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||||
|
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||||
|
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0=
|
||||||
|
github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
|
||||||
|
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
|
||||||
|
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
|
||||||
|
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
|
||||||
|
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||||
|
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
|
||||||
|
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
||||||
|
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||||
|
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
|
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||||
|
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||||
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
|
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||||
|
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||||
|
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||||
|
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||||
|
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
|
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||||
|
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=
|
||||||
|
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
|
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||||
|
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
|
||||||
|
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
|
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||||
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||||
|
gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0=
|
||||||
|
gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
|
||||||
|
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleListDags(executor *Executor) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
dags, err := executor.ListDAGs()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetDag(executor *Executor) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
info, dag, validation, err := executor.GetDAG(name)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recent runs.
|
||||||
|
runs, _, _ := executor.store.ListRuns(dag.Name, 10, 0)
|
||||||
|
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"info": info,
|
||||||
|
"dag": dag,
|
||||||
|
"validation": validation,
|
||||||
|
"recent_runs": runs,
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRunDag(executor *Executor) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
info, _, _, err := executor.GetDAG(name)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute asynchronously.
|
||||||
|
go func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
executor.ExecuteDAG(ctx, info.FilePath, "api")
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Return run acknowledgment.
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]string{
|
||||||
|
"status": "accepted",
|
||||||
|
"dag": name,
|
||||||
|
"message": "DAG execution started",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- JSON helpers ---
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleListRuns(executor *Executor) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
dagName := r.URL.Query().Get("dag")
|
||||||
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
runs, total, err := executor.store.ListRuns(dagName, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"runs": runs,
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetRun(executor *Executor) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
run, err := executor.store.GetRun(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if run == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "run not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
steps, err := executor.store.ListStepResults(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"run": run,
|
||||||
|
"steps": steps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func handleSchedulerStart(scheduler *Scheduler) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := scheduler.Start(); err != nil {
|
||||||
|
writeError(w, http.StatusConflict, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "started"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSchedulerStop(scheduler *Scheduler) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
scheduler.Stop()
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "stopped"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSchedulerStatus(scheduler *Scheduler) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := scheduler.Status()
|
||||||
|
writeJSON(w, http.StatusOK, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
iofs "io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fn-registry/functions/core"
|
||||||
|
|
||||||
|
"dag-engine/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:frontend/dist
|
||||||
|
var frontendDist embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
printUsage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := os.Args[1]
|
||||||
|
args := os.Args[2:]
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "run":
|
||||||
|
cmdRun(args)
|
||||||
|
case "list":
|
||||||
|
cmdList(args)
|
||||||
|
case "status":
|
||||||
|
cmdStatus(args)
|
||||||
|
case "validate":
|
||||||
|
cmdValidate(args)
|
||||||
|
case "server":
|
||||||
|
cmdServer(args)
|
||||||
|
case "help", "-h", "--help":
|
||||||
|
printUsage()
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd)
|
||||||
|
printUsage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printUsage() {
|
||||||
|
fmt.Println(`dag-engine — DAG workflow executor
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
dag-engine <command> [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
run <path.yaml> Execute a DAG and show results
|
||||||
|
list [dir] List DAGs with schedule and last status
|
||||||
|
status [dag_name] Show execution history
|
||||||
|
validate <path.yaml> Parse and validate without executing
|
||||||
|
server Start HTTP server with web frontend
|
||||||
|
|
||||||
|
Server options:
|
||||||
|
--port <port> HTTP port (default: 8090)
|
||||||
|
--dags-dir <dir> DAGs directory (default: ~/dagu/dags)
|
||||||
|
--db <path> SQLite database path (default: dag_engine.db)
|
||||||
|
--scheduler Auto-start cron scheduler`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CLI Commands ---
|
||||||
|
|
||||||
|
func cmdRun(args []string) {
|
||||||
|
if len(args) < 1 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: dag-engine run <path.yaml>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
dagPath := args[0]
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
// Parse optional flags after the path.
|
||||||
|
fs := flag.NewFlagSet("run", flag.ExitOnError)
|
||||||
|
fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path")
|
||||||
|
fs.Parse(args[1:])
|
||||||
|
|
||||||
|
db, err := store.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
executor := NewExecutor(db, filepath.Dir(dagPath))
|
||||||
|
|
||||||
|
fmt.Printf("Executing %s...\n", dagPath)
|
||||||
|
ctx := context.Background()
|
||||||
|
runID, err := executor.ExecuteDAG(ctx, dagPath, "manual")
|
||||||
|
|
||||||
|
// Print results.
|
||||||
|
if runID != "" {
|
||||||
|
run, _ := db.GetRun(runID)
|
||||||
|
steps, _ := db.ListStepResults(runID)
|
||||||
|
|
||||||
|
if run != nil {
|
||||||
|
fmt.Println()
|
||||||
|
for _, s := range steps {
|
||||||
|
icon := " "
|
||||||
|
switch s.Status {
|
||||||
|
case "success":
|
||||||
|
icon = "OK"
|
||||||
|
case "failed":
|
||||||
|
icon = "!!"
|
||||||
|
case "skipped":
|
||||||
|
icon = "--"
|
||||||
|
case "running":
|
||||||
|
icon = ".."
|
||||||
|
}
|
||||||
|
fmt.Printf("[%s] %s (%dms)\n", icon, s.StepName, s.DurationMs)
|
||||||
|
if s.Status == "failed" && s.Stderr != "" {
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(s.Stderr), "\n") {
|
||||||
|
fmt.Printf(" %s\n", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
dur := ""
|
||||||
|
if run.FinishedAt != nil {
|
||||||
|
dur = fmt.Sprintf(" (%s)", run.FinishedAt.Sub(run.StartedAt).Round(time.Millisecond))
|
||||||
|
}
|
||||||
|
fmt.Printf("Run %s: %s%s\n", runID, strings.ToUpper(run.Status), dur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdList(args []string) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
||||||
|
cfg.DagsDir = args[0]
|
||||||
|
args = args[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
fs := flag.NewFlagSet("list", flag.ExitOnError)
|
||||||
|
fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path")
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
db, err := store.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
executor := NewExecutor(db, cfg.DagsDir)
|
||||||
|
dags, err := executor.ListDAGs()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("list dags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "NAME\tSCHEDULE\tTYPE\tTAGS\tLAST STATUS\tLAST RUN")
|
||||||
|
for _, d := range dags {
|
||||||
|
sched := strings.Join(d.Schedule, ", ")
|
||||||
|
tags := strings.Join(d.Tags, ", ")
|
||||||
|
lastStatus := "-"
|
||||||
|
lastRun := "-"
|
||||||
|
if d.LastRun != nil {
|
||||||
|
lastStatus = d.LastRun.Status
|
||||||
|
lastRun = d.LastRun.StartedAt.Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
typ := d.Type
|
||||||
|
if typ == "" {
|
||||||
|
typ = "chain"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", d.Name, sched, typ, tags, lastStatus, lastRun)
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdStatus(args []string) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
fs := flag.NewFlagSet("status", flag.ExitOnError)
|
||||||
|
fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path")
|
||||||
|
limit := fs.Int("limit", 10, "number of runs to show")
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
dagName := ""
|
||||||
|
if fs.NArg() > 0 {
|
||||||
|
dagName = fs.Arg(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := store.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
runs, total, err := db.ListRuns(dagName, *limit, 0)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("list runs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintf(w, "Showing %d of %d runs", len(runs), total)
|
||||||
|
if dagName != "" {
|
||||||
|
fmt.Fprintf(w, " for %s", dagName)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
fmt.Fprintln(w, "RUN_ID\tDAG\tSTATUS\tTRIGGER\tSTARTED\tDURATION")
|
||||||
|
for _, r := range runs {
|
||||||
|
dur := "-"
|
||||||
|
if r.FinishedAt != nil {
|
||||||
|
dur = r.FinishedAt.Sub(r.StartedAt).Round(time.Millisecond).String()
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||||
|
r.ID, r.DagName, r.Status, r.Trigger,
|
||||||
|
r.StartedAt.Format("2006-01-02 15:04:05"), dur)
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdValidate(args []string) {
|
||||||
|
if len(args) < 1 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: dag-engine validate <path.yaml>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(args[0])
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dag, err := core.DagParse(data)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("parse error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := core.DagValidate(dag)
|
||||||
|
|
||||||
|
fmt.Printf("DAG: %s\n", dag.Name)
|
||||||
|
fmt.Printf("Steps: %d\n", len(dag.Steps))
|
||||||
|
fmt.Printf("Schedule: %v\n", dag.Schedule)
|
||||||
|
fmt.Printf("Type: %s\n", dag.Type)
|
||||||
|
|
||||||
|
if result.Valid {
|
||||||
|
fmt.Println("Validation: PASS")
|
||||||
|
for i, level := range result.Levels {
|
||||||
|
fmt.Printf(" Level %d: %v\n", i, level)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("Validation: FAIL")
|
||||||
|
for _, e := range result.Errors {
|
||||||
|
fmt.Printf(" ERROR: %s\n", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, w := range result.Warnings {
|
||||||
|
fmt.Printf(" WARNING: %s\n", w)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Valid {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Server Command ---
|
||||||
|
|
||||||
|
func cmdServer(args []string) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
fs := flag.NewFlagSet("server", flag.ExitOnError)
|
||||||
|
cfg.ParseFlags(fs, args)
|
||||||
|
|
||||||
|
db, err := store.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
executor := NewExecutor(db, cfg.DagsDir)
|
||||||
|
scheduler := NewScheduler(executor, cfg.DagsDir)
|
||||||
|
dagRunHub := NewDagRunHub(db, executor)
|
||||||
|
|
||||||
|
// Prepare frontend FS.
|
||||||
|
var feFS iofs.FS
|
||||||
|
distFS, err := iofs.Sub(frontendDist, "frontend/dist")
|
||||||
|
if err == nil {
|
||||||
|
// Check if dist has content (built frontend exists).
|
||||||
|
entries, _ := iofs.ReadDir(distFS, ".")
|
||||||
|
if len(entries) > 0 {
|
||||||
|
feFS = distFS
|
||||||
|
log.Printf("serving frontend from embedded dist/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if feFS == nil {
|
||||||
|
log.Printf("no frontend build found, API-only mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
RegisterAPI(mux, executor, scheduler, dagRunHub, feFS)
|
||||||
|
|
||||||
|
handler := corsMiddleware(loggingMiddleware(mux))
|
||||||
|
|
||||||
|
if cfg.AutoScheduler {
|
||||||
|
if err := scheduler.Start(); err != nil {
|
||||||
|
log.Printf("scheduler start: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||||
|
log.Printf("dag-engine server starting on http://0.0.0.0%s", addr)
|
||||||
|
log.Printf("dags dir: %s", cfg.DagsDir)
|
||||||
|
log.Printf("database: %s", cfg.DBPath)
|
||||||
|
|
||||||
|
srv := &http.Server{Addr: addr, Handler: handler}
|
||||||
|
|
||||||
|
// Graceful shutdown.
|
||||||
|
go func() {
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sigCh
|
||||||
|
log.Println("shutting down...")
|
||||||
|
scheduler.Stop()
|
||||||
|
srv.Shutdown(context.Background())
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// corsMiddleware adds permissive CORS headers for development.
|
||||||
|
func corsMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// loggingMiddleware logs each HTTP request with method, path and duration.
|
||||||
|
func loggingMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fn-registry/functions/core"
|
||||||
|
"fn-registry/functions/infra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScheduledDAG represents a DAG with a parsed cron schedule.
|
||||||
|
type ScheduledDAG struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Schedule string `json:"schedule"`
|
||||||
|
NextRun time.Time `json:"next_run"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scheduler manages cron-triggered DAG execution.
|
||||||
|
type Scheduler struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
running bool
|
||||||
|
cancel context.CancelFunc
|
||||||
|
dagsDir string
|
||||||
|
executor *Executor
|
||||||
|
dags []ScheduledDAG
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewScheduler creates a new scheduler.
|
||||||
|
func NewScheduler(executor *Executor, dagsDir string) *Scheduler {
|
||||||
|
return &Scheduler{
|
||||||
|
executor: executor,
|
||||||
|
dagsDir: dagsDir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start scans for DAGs with schedules and starts cron tickers for each.
|
||||||
|
func (s *Scheduler) Start() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.running {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return fmt.Errorf("scheduler already running")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
s.cancel = cancel
|
||||||
|
s.running = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
scheduled, err := s.scanDAGs()
|
||||||
|
if err != nil {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.running = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
cancel()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.dags = scheduled
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("[scheduler] started with %d DAGs", len(scheduled))
|
||||||
|
|
||||||
|
for _, dag := range scheduled {
|
||||||
|
dag := dag
|
||||||
|
go s.runTicker(ctx, dag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop cancels all tickers and stops the scheduler.
|
||||||
|
func (s *Scheduler) Stop() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if !s.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.cancel()
|
||||||
|
s.running = false
|
||||||
|
s.dags = nil
|
||||||
|
log.Printf("[scheduler] stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunning returns true if the scheduler is active.
|
||||||
|
func (s *Scheduler) IsRunning() bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.running
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns the list of scheduled DAGs with their next run time.
|
||||||
|
type SchedulerStatus struct {
|
||||||
|
Running bool `json:"running"`
|
||||||
|
DAGs []ScheduledDAG `json:"dags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) Status() SchedulerStatus {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return SchedulerStatus{
|
||||||
|
Running: s.running,
|
||||||
|
DAGs: s.dags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanDAGs reads the dags directory and returns DAGs that have cron schedules.
|
||||||
|
func (s *Scheduler) scanDAGs() ([]ScheduledDAG, error) {
|
||||||
|
entries, err := os.ReadDir(s.dagsDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var scheduled []ScheduledDAG
|
||||||
|
for _, entry := range entries {
|
||||||
|
ext := filepath.Ext(entry.Name())
|
||||||
|
if ext != ".yaml" && ext != ".yml" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(s.dagsDir, entry.Name())
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dag, err := core.DagParse(data)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, expr := range dag.Schedule {
|
||||||
|
sched, err := core.ParseCronExpr(strings.TrimSpace(expr))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[scheduler] invalid cron %q in %s: %v", expr, dag.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next := core.NextCronTime(sched, time.Now())
|
||||||
|
scheduled = append(scheduled, ScheduledDAG{
|
||||||
|
Name: dag.Name,
|
||||||
|
Path: path,
|
||||||
|
Schedule: expr,
|
||||||
|
NextRun: next,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheduled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTicker starts a cron ticker for a single DAG schedule.
|
||||||
|
func (s *Scheduler) runTicker(ctx context.Context, dag ScheduledDAG) {
|
||||||
|
sched, err := core.ParseCronExpr(strings.TrimSpace(dag.Schedule))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert core.CronSchedule to infra.CronTickerSchedule.
|
||||||
|
tickerSched := infra.CronTickerSchedule{
|
||||||
|
Minute: sched.Minute,
|
||||||
|
Hour: sched.Hour,
|
||||||
|
DayOfMonth: sched.DayOfMonth,
|
||||||
|
Month: sched.Month,
|
||||||
|
DayOfWeek: sched.DayOfWeek,
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := infra.CronTicker(tickerSched, ctx)
|
||||||
|
log.Printf("[scheduler] ticker started for %s (%s), next: %s", dag.Name, dag.Schedule, dag.NextRun.Format(time.RFC3339))
|
||||||
|
|
||||||
|
for t := range ch {
|
||||||
|
log.Printf("[scheduler] triggered %s at %s", dag.Name, t.Format(time.RFC3339))
|
||||||
|
go func() {
|
||||||
|
runID, err := s.executor.ExecuteDAG(ctx, dag.Path, "cron")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[scheduler] %s failed: %v (run: %s)", dag.Name, err, runID)
|
||||||
|
} else {
|
||||||
|
log.Printf("[scheduler] %s completed (run: %s)", dag.Name, runID)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS dag_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
dag_name TEXT NOT NULL,
|
||||||
|
dag_path TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','success','failed','cancelled')),
|
||||||
|
trigger TEXT NOT NULL DEFAULT 'manual' CHECK(trigger IN ('manual','cron','api')),
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
finished_at TEXT,
|
||||||
|
error TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dag_step_results (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
run_id TEXT NOT NULL REFERENCES dag_runs(id) ON DELETE CASCADE,
|
||||||
|
step_name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','success','failed','skipped')),
|
||||||
|
exit_code INTEGER NOT NULL DEFAULT -1,
|
||||||
|
stdout TEXT NOT NULL DEFAULT '',
|
||||||
|
stderr TEXT NOT NULL DEFAULT '',
|
||||||
|
started_at TEXT,
|
||||||
|
finished_at TEXT,
|
||||||
|
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_runs_dag_name ON dag_runs(dag_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_runs_status ON dag_runs(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_runs_started ON dag_runs(started_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_step_results_run ON dag_step_results(run_id);
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
|
// applyMigrations executes every embedded migrations/*.sql in order.
|
||||||
|
// Each statement is idempotent (IF NOT EXISTS / ADD COLUMN). Duplicate-column
|
||||||
|
// errors from re-running ALTER TABLE ADD COLUMN are tolerated.
|
||||||
|
func applyMigrations(conn *sql.DB) error {
|
||||||
|
files, err := fs.Glob(migrationsFS, "migrations/*.sql")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
for _, f := range files {
|
||||||
|
b, err := migrationsFS.ReadFile(f)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: read: %w", f, err)
|
||||||
|
}
|
||||||
|
if _, err := conn.Exec(string(b)); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "duplicate column") ||
|
||||||
|
strings.Contains(err.Error(), "already exists") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s: %w", f, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB wraps a SQLite connection for DAG run persistence.
|
||||||
|
type DB struct {
|
||||||
|
conn *sql.DB
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens or creates a DAG engine database at the given path.
|
||||||
|
func Open(path string) (*DB, error) {
|
||||||
|
conn, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=on")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: open %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := applyMigrations(conn); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("store: migrate: %w", err)
|
||||||
|
}
|
||||||
|
return &DB{conn: conn, path: path}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection.
|
||||||
|
func (db *DB) Close() error {
|
||||||
|
return db.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conn exposes the underlying *sql.DB for read-only queries from other
|
||||||
|
// packages (e.g. WS hub in events.go). Do not Close() the returned conn.
|
||||||
|
func (db *DB) Conn() *sql.DB {
|
||||||
|
return db.conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DagRun CRUD ---
|
||||||
|
|
||||||
|
// DagRun mirrors infra.DagRun for the store layer.
|
||||||
|
type DagRun struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DagName string `json:"dag_name"`
|
||||||
|
DagPath string `json:"dag_path"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRun inserts a new run record.
|
||||||
|
func (db *DB) CreateRun(run *DagRun) error {
|
||||||
|
_, err := db.conn.Exec(
|
||||||
|
`INSERT INTO dag_runs (id, dag_name, dag_path, status, trigger, started_at, error)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
run.ID, run.DagName, run.DagPath, run.Status, run.Trigger,
|
||||||
|
run.StartedAt.Format(time.RFC3339), run.Error,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRunStatus updates a run's status and optionally its finished_at and error.
|
||||||
|
func (db *DB) UpdateRunStatus(id, status string, finishedAt *time.Time, errMsg string) error {
|
||||||
|
var fin *string
|
||||||
|
if finishedAt != nil {
|
||||||
|
s := finishedAt.Format(time.RFC3339)
|
||||||
|
fin = &s
|
||||||
|
}
|
||||||
|
_, err := db.conn.Exec(
|
||||||
|
`UPDATE dag_runs SET status=?, finished_at=?, error=? WHERE id=?`,
|
||||||
|
status, fin, errMsg, id,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRun retrieves a single run by ID.
|
||||||
|
func (db *DB) GetRun(id string) (*DagRun, error) {
|
||||||
|
row := db.conn.QueryRow(
|
||||||
|
`SELECT id, dag_name, dag_path, status, trigger, started_at, finished_at, error
|
||||||
|
FROM dag_runs WHERE id=?`, id,
|
||||||
|
)
|
||||||
|
return scanRun(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRuns returns runs, newest first, with optional dag name filter.
|
||||||
|
func (db *DB) ListRuns(dagName string, limit, offset int) ([]DagRun, int, error) {
|
||||||
|
var total int
|
||||||
|
var args []interface{}
|
||||||
|
where := ""
|
||||||
|
if dagName != "" {
|
||||||
|
where = " WHERE dag_name=?"
|
||||||
|
args = append(args, dagName)
|
||||||
|
}
|
||||||
|
err := db.conn.QueryRow("SELECT COUNT(*) FROM dag_runs"+where, args...).Scan(&total)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "SELECT id, dag_name, dag_path, status, trigger, started_at, finished_at, error FROM dag_runs" +
|
||||||
|
where + " ORDER BY started_at DESC LIMIT ? OFFSET ?"
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
|
rows, err := db.conn.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var runs []DagRun
|
||||||
|
for rows.Next() {
|
||||||
|
r, err := scanRunRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
runs = append(runs, *r)
|
||||||
|
}
|
||||||
|
return runs, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DagStepResult CRUD ---
|
||||||
|
|
||||||
|
// DagStepResult mirrors infra.DagStepResult for the store layer.
|
||||||
|
type DagStepResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
StepName string `json:"step_name"`
|
||||||
|
FunctionID string `json:"function_id,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ExitCode int `json:"exit_code"`
|
||||||
|
Stdout string `json:"stdout,omitempty"`
|
||||||
|
Stderr string `json:"stderr,omitempty"`
|
||||||
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||||
|
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||||
|
DurationMs int64 `json:"duration_ms"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertStepResult inserts a new step result.
|
||||||
|
func (db *DB) InsertStepResult(r *DagStepResult) error {
|
||||||
|
var startedAt, finishedAt *string
|
||||||
|
if r.StartedAt != nil {
|
||||||
|
s := r.StartedAt.Format(time.RFC3339)
|
||||||
|
startedAt = &s
|
||||||
|
}
|
||||||
|
if r.FinishedAt != nil {
|
||||||
|
s := r.FinishedAt.Format(time.RFC3339)
|
||||||
|
finishedAt = &s
|
||||||
|
}
|
||||||
|
_, err := db.conn.Exec(
|
||||||
|
`INSERT INTO dag_step_results (id, run_id, step_name, function_id, status, exit_code, stdout, stderr, started_at, finished_at, duration_ms, error)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
r.ID, r.RunID, r.StepName, r.FunctionID, r.Status, r.ExitCode, r.Stdout, r.Stderr,
|
||||||
|
startedAt, finishedAt, r.DurationMs, r.Error,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStepResult updates a step result by ID.
|
||||||
|
func (db *DB) UpdateStepResult(id, status string, exitCode int, stdout, stderr string, finishedAt *time.Time, durationMs int64, errMsg string) error {
|
||||||
|
var fin *string
|
||||||
|
if finishedAt != nil {
|
||||||
|
s := finishedAt.Format(time.RFC3339)
|
||||||
|
fin = &s
|
||||||
|
}
|
||||||
|
_, err := db.conn.Exec(
|
||||||
|
`UPDATE dag_step_results SET status=?, exit_code=?, stdout=?, stderr=?, finished_at=?, duration_ms=?, error=? WHERE id=?`,
|
||||||
|
status, exitCode, stdout, stderr, fin, durationMs, errMsg, id,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListStepResults returns all step results for a given run.
|
||||||
|
func (db *DB) ListStepResults(runID string) ([]DagStepResult, error) {
|
||||||
|
rows, err := db.conn.Query(
|
||||||
|
`SELECT id, run_id, step_name, function_id, status, exit_code, stdout, stderr, started_at, finished_at, duration_ms, error
|
||||||
|
FROM dag_step_results WHERE run_id=? ORDER BY started_at ASC`, runID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []DagStepResult
|
||||||
|
for rows.Next() {
|
||||||
|
var r DagStepResult
|
||||||
|
var startedAt, finishedAt sql.NullString
|
||||||
|
if err := rows.Scan(&r.ID, &r.RunID, &r.StepName, &r.FunctionID, &r.Status, &r.ExitCode,
|
||||||
|
&r.Stdout, &r.Stderr, &startedAt, &finishedAt, &r.DurationMs, &r.Error); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if startedAt.Valid {
|
||||||
|
t, _ := time.Parse(time.RFC3339, startedAt.String)
|
||||||
|
r.StartedAt = &t
|
||||||
|
}
|
||||||
|
if finishedAt.Valid {
|
||||||
|
t, _ := time.Parse(time.RFC3339, finishedAt.String)
|
||||||
|
r.FinishedAt = &t
|
||||||
|
}
|
||||||
|
results = append(results, r)
|
||||||
|
}
|
||||||
|
return results, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- scan helpers ---
|
||||||
|
|
||||||
|
type scanner interface {
|
||||||
|
Scan(dest ...interface{}) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRun(s scanner) (*DagRun, error) {
|
||||||
|
var r DagRun
|
||||||
|
var startedAt string
|
||||||
|
var finishedAt sql.NullString
|
||||||
|
if err := s.Scan(&r.ID, &r.DagName, &r.DagPath, &r.Status, &r.Trigger, &startedAt, &finishedAt, &r.Error); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.StartedAt, _ = time.Parse(time.RFC3339, startedAt)
|
||||||
|
if finishedAt.Valid {
|
||||||
|
t, _ := time.Parse(time.RFC3339, finishedAt.String)
|
||||||
|
r.FinishedAt = &t
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRunRows(rows *sql.Rows) (*DagRun, error) {
|
||||||
|
return scanRun(rows)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
---
|
||||||
|
name: shaders_lab
|
||||||
|
lang: cpp
|
||||||
|
domain: gfx
|
||||||
|
version: 0.1.0
|
||||||
|
description: "Live GLSL shader playground con DAG pipeline. Editor de codigo con compilacion en caliente, panel DAG con paleta de generadores/filtros/output, dos canvas (Code y DAG), parseo de uniforms anotados (// @slider, @color, @xy) que se convierten en controles, persistencia de generators en shaders_lab.db, y guardado/carga de layouts ImGui."
|
||||||
|
tags: [imgui, opengl, glsl, shaders, dag, live-coding, playground, sqlite]
|
||||||
|
uses_functions:
|
||||||
|
# gfx
|
||||||
|
- gl_loader_cpp_gfx
|
||||||
|
- gl_shader_cpp_gfx
|
||||||
|
- gl_framebuffer_cpp_gfx
|
||||||
|
- fullscreen_quad_cpp_gfx
|
||||||
|
- shader_canvas_cpp_gfx
|
||||||
|
- uniform_parser_cpp_gfx
|
||||||
|
- uniform_panel_cpp_gfx
|
||||||
|
- dag_catalog_cpp_gfx
|
||||||
|
- dag_compile_cpp_gfx
|
||||||
|
- dag_uniforms_cpp_gfx
|
||||||
|
- dag_panel_cpp_gfx
|
||||||
|
- dag_node_editor_cpp_gfx
|
||||||
|
- dag_palette_cpp_gfx
|
||||||
|
- dag_node_previews_cpp_gfx
|
||||||
|
- shaderlab_db_cpp_gfx
|
||||||
|
- code_to_generator_cpp_gfx
|
||||||
|
# core (modal Save-as-generator)
|
||||||
|
- modal_dialog_cpp_core
|
||||||
|
- text_input_cpp_core
|
||||||
|
- button_cpp_core
|
||||||
|
uses_types:
|
||||||
|
- dag_types_cpp_gfx
|
||||||
|
framework: "imgui"
|
||||||
|
entry_point: "main.cpp"
|
||||||
|
dir_path: "apps/shaders_lab"
|
||||||
|
repo_url: ""
|
||||||
|
icon:
|
||||||
|
phosphor: "palette"
|
||||||
|
accent: "#ea580c"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
App ImGui de live-coding GLSL con dos modos en paralelo:
|
||||||
|
|
||||||
|
1. **Code panel** — editor de fragment shader libre. Las anotaciones en
|
||||||
|
uniforms (`// @slider`, `// @color`, `// @xy`, `// @toggle`) se parsean y
|
||||||
|
convierten en controles del panel **Controls** que escriben en un
|
||||||
|
`UniformStore` aplicado al programa cada frame.
|
||||||
|
2. **DAG panel** — pipeline node-based con catalogo de generadores
|
||||||
|
(plasma, voronoi, etc.) y filtros (blur, threshold, etc.) que se
|
||||||
|
compilan a un fragment shader unificado y se renderizan en **Canvas DAG**.
|
||||||
|
|
||||||
|
Al guardar un Code shader como "generator" se traduce a un `DagNodeDef` y se
|
||||||
|
persiste en `shaders_lab.db` (tabla via `shaderlab_db`), apareciendo en la
|
||||||
|
paleta del DAG junto a los builtins.
|
||||||
|
|
||||||
|
## Capas
|
||||||
|
|
||||||
|
| Archivo | Responsabilidad |
|
||||||
|
|---|---|
|
||||||
|
| `main.cpp` | UI shell, paneles, modal save-as, layouts, AppConfig |
|
||||||
|
| `compiler.cpp` | `compile_code()`, `compile_dag()`, `mark_code_dirty()` con debounce 250ms |
|
||||||
|
|
||||||
|
`main.cpp` mantiene estado global de sesion (g_source, g_pipeline, g_descs,
|
||||||
|
g_store, g_layouts...) — ImGui retained-mode obliga a que persista entre
|
||||||
|
frames. Toda la logica pura de compilacion vive en `compiler.cpp` y en las
|
||||||
|
funciones `dag_compile`, `code_to_generator`, `uniform_parser` del registry.
|
||||||
|
|
||||||
|
## Persistencia
|
||||||
|
|
||||||
|
- **`shaders_lab.db`** (junto al .exe) — tabla de generators de usuario via
|
||||||
|
`shaderlab_db_*`, ademas de `imgui_layouts` (creada por `layout_storage`).
|
||||||
|
- `imgui.ini` y `app_settings.ini` — gestionados por `fn::run_app` en
|
||||||
|
`<exe_dir>/local_files/`.
|
||||||
|
|
||||||
|
## Paneles
|
||||||
|
|
||||||
|
| Panel | Atajo | Que muestra |
|
||||||
|
|---|---|---|
|
||||||
|
| Code | Ctrl+1 | Editor del fragment shader + boton "Save as generator" |
|
||||||
|
| DAG Pipeline | Ctrl+2 | Node editor con la pipeline |
|
||||||
|
| Canvas Code | Ctrl+3 | Render del Code shader |
|
||||||
|
| Canvas DAG | Ctrl+4 | Render del shader compilado del DAG |
|
||||||
|
| Controls | Ctrl+5 | Sliders/color pickers de uniforms anotados |
|
||||||
|
| Functions | Ctrl+6 | Paleta del DAG (generators + filters + output) |
|
||||||
|
| Generated GLSL | Ctrl+7 | GLSL final del DAG con uniforms baked como const array |
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux
|
||||||
|
cd cpp && cmake -B build/linux -S . && cmake --build build/linux --target shaders_lab
|
||||||
|
|
||||||
|
# Windows (cross-compile)
|
||||||
|
cd cpp && cmake -B build/windows -S . -DCMAKE_TOOLCHAIN_FILE=toolchains/mingw-w64.cmake \
|
||||||
|
&& cmake --build build/windows --target shaders_lab
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decisiones
|
||||||
|
|
||||||
|
- `init_gl_loader = true` (via `fn::run_app` por default cuando se enlaza
|
||||||
|
con OpenGL) — `shader_canvas`, `gl_shader`, `gl_framebuffer` llaman gl*.
|
||||||
|
- `viewports = true` — los Canvas se pueden arrastrar fuera del main.
|
||||||
|
- DAG default: arranca con un nodo "plasma" + "output" si la paleta los
|
||||||
|
encuentra; persiste el INI con `layout_storage`.
|
||||||
|
- El boton "Save as generator" valida snake_case, evita colisionar con
|
||||||
|
builtins, traduce con `code_to_generator`, persiste con `shaderlab_db_save_generator`,
|
||||||
|
y registra el nodo nuevo en el catalogo en vivo (`dag_register_node`).
|
||||||
|
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
Una linea por bump SemVer. Bump-type segun `.claude/commands/version.md`:
|
||||||
|
- `major`: breaking observable (CLI args, schema BBDD propia, formato wire).
|
||||||
|
- `minor`: feature aditiva (nuevo panel, endpoint, opcion).
|
||||||
|
- `patch`: bugfix sin cambio observable.
|
||||||
|
|
||||||
|
- v0.1.0 (2026-05-18) — baseline.
|
||||||
Executable
BIN
Binary file not shown.
@@ -1,80 +0,0 @@
|
|||||||
---
|
|
||||||
name: chrome_load_extensions
|
|
||||||
kind: function
|
|
||||||
lang: bash
|
|
||||||
domain: browser
|
|
||||||
version: "1.0.0"
|
|
||||||
purity: impure
|
|
||||||
signature: "chrome_load_extensions [--port N] [--profile DIR] --ext PATH [--ext PATH ...] [--proxy URL] [--url URL]"
|
|
||||||
description: "Lanza Chrome con extensiones unpacked via --load-extension (WSL2→Windows chrome.exe, paths traducidos, join sin echo, setsid anti-exit-144). OJO: --load-extension SOLO funciona en Chrome for Testing/Chromium/Dev. En Chrome STABLE 138+ esta DESACTIVADO (feature DisableLoadExtensionCommandLineSwitch + bloqueo duro en 148) y carga 0 extensiones aunque el cmdline sea correcto. Para Chrome stable usar install via Web Store (1-clic, persiste en perfil) o enterprise policy ExtensionInstallForcelist (requiere HKLM/HKCU Policies escribible — denegado en maquinas gestionadas)."
|
|
||||||
tags: [chrome, cdp, browser, extensions, wsl2, navegator]
|
|
||||||
uses_functions: []
|
|
||||||
uses_types: []
|
|
||||||
returns: []
|
|
||||||
returns_optional: false
|
|
||||||
error_type: "error_go_core"
|
|
||||||
imports: []
|
|
||||||
tested: false
|
|
||||||
tests: []
|
|
||||||
test_file_path: ""
|
|
||||||
params:
|
|
||||||
- name: "--port N"
|
|
||||||
desc: "Puerto de remote debugging CDP. Default: 9222."
|
|
||||||
- name: "--profile DIR"
|
|
||||||
desc: "Chrome user-data-dir. Acepta ruta Windows (C:\\...) o ruta WSL/Linux (se traduce via wslpath -w). Default: C:\\Users\\<USERNAME>\\AppData\\Local\\fn-chrome-cdp-profile (WSL2) o /tmp/fn-chrome-cdp-profile (Linux nativo)."
|
|
||||||
- name: "--ext PATH"
|
|
||||||
desc: "Ruta a un directorio de extensión unpacked. Repetible. Acepta ruta Windows (se pasa intacta) o ruta WSL/Linux (se traduce via wslpath -w). Obligatorio al menos uno."
|
|
||||||
- name: "--proxy URL"
|
|
||||||
desc: "Proxy opcional, ej. http://127.0.0.1:8889. Agrega --proxy-server=URL a Chrome."
|
|
||||||
- name: "--url URL"
|
|
||||||
desc: "URL inicial opcional para abrir con --new-window."
|
|
||||||
output: "PID del proceso Chrome lanzado (stdout). Mensajes de estado en stderr. CDP listo en 127.0.0.1:<port>."
|
|
||||||
file_path: "bash/functions/browser/chrome_load_extensions.sh"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ejemplo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
source bash/functions/browser/chrome_load_extensions.sh
|
|
||||||
|
|
||||||
chrome_load_extensions \
|
|
||||||
--port 9222 \
|
|
||||||
--profile 'C:\Users\lucas\AppData\Local\fn-chrome-cdp-profile' \
|
|
||||||
--ext 'C:\Users\lucas\hls-dl-ext' \
|
|
||||||
--ext 'C:\Users\lucas\ubol' \
|
|
||||||
--proxy http://127.0.0.1:8889 \
|
|
||||||
--url https://www.gnularetro.cc/
|
|
||||||
```
|
|
||||||
|
|
||||||
Sin proxy ni URL, sólo extensiones:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
source bash/functions/browser/chrome_load_extensions.sh
|
|
||||||
|
|
||||||
pid=$(chrome_load_extensions \
|
|
||||||
--ext '/home/lucas/dev/hls-dl-ext' \
|
|
||||||
--ext '/home/lucas/dev/ubol')
|
|
||||||
# Paths WSL traducidos automáticamente a Windows.
|
|
||||||
# CDP listo en 127.0.0.1:9222.
|
|
||||||
echo "Chrome PID: $pid"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cuando usarla
|
|
||||||
|
|
||||||
Cuando necesites Chrome CDP con extensiones unpacked cargadas (HLS downloader, uBlock Origin, extensiones en desarrollo) y `chrome_launch_go_browser` no sirve porque hardcodea `--disable-extensions`. WSL2→Windows. Ideal para sesiones de navegator con proxy + extensión activa.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **MUERTO en Chrome STABLE 138+ (validado 2026-05-30, Chrome 148)**: `--load-extension` NO carga nada en el canal stable, ni con `--disable-extensions-except` ni con `--disable-features=DisableLoadExtensionCommandLineSwitch`. `chrome://version` muestra el flag correcto pero `chrome://extensions` sale vacío. Google lo bloqueó duro en stable. La función SOLO sirve en **Chrome for Testing / Chromium / Dev/Canary**, donde el switch sigue activo. Para stable: ver opciones abajo.
|
|
||||||
- **Instalar en Chrome STABLE (las que SÍ funcionan)**:
|
|
||||||
1. **Web Store 1-clic** — abre la página del store en el perfil CDP, el humano da "Añadir a Chrome". Persiste en el perfil para siempre (futuros lanzamientos ya con la extensión, sin flags). El popup de confirmación es UI del navegador (no DOM) → NO es CDP-clickable, requiere gesto humano. Único método no-admin que persiste por-perfil.
|
|
||||||
2. **Enterprise policy** `ExtensionInstallForcelist` (HKCU/HKLM `\Software\Policies\Google\Chrome`) — force-install sin clic desde el store, browser-wide. El key `Policies\Google\Chrome` puede dar "Access denied" al escribir (visto 2026-05-30 incluso en máquina personal vía reg.exe/PowerShell desde WSL — Chrome/Windows protege el subárbol Policies). Si funciona, requiere relanzar Chrome para que descargue del store. Método global (afecta todos los perfiles).
|
|
||||||
3. Extensiones **unpacked custom** (no en store, ej. un HLS downloader propio) en stable: no hay vía no-admin. Empaquetar a CRX + self-host `update_url` + policy, o usar Chrome for Testing. A menudo innecesario si la lógica vive fuera (ej. `grab_stream.py` descarga sin extensión).
|
|
||||||
- **Combo flags (solo Chrome for Testing/dev)**: requiere AMBOS `--load-extension=p1,p2` Y `--disable-extensions-except=p1,p2` juntos + `--disable-features=DisableLoadExtensionCommandLineSwitch`. **NUNCA `--disable-extensions`** (desactiva todo).
|
|
||||||
- **join sin `echo`**: rutas Windows `C:\Users\...` tienen `\U`; el `echo` de zsh (o sh con xpg_echo) lo interpreta como escape unicode y trunca la ruta a `C:`. La función usa acumulador `+=`, no `echo`. Verificable en `chrome://version` (debe verse el path completo, no `--load-extension=C:`).
|
|
||||||
- **exit 144 en Bash tool**: si el proceso Chrome retiene el pipe stdout, la herramienta devuelve exit 144. Esta función lanza con `setsid ... </dev/null >log 2>&1 &` + `disown` para desacoplar completamente. El log queda en `/tmp/chrome_ext_<port>.log`.
|
|
||||||
- **WSL2: traducir paths con `wslpath -w`**: los paths de `--ext` y `--profile` que sean rutas Linux se traducen automáticamente. Las rutas Windows (`C:\...`) se pasan intactas. `wslpath` debe estar disponible (estándar en WSL2 desde Windows 10 1903+).
|
|
||||||
- **Perfil ya abierto**: si Chrome ya tiene ese perfil abierto, relanzar añade una ventana extra a la misma instancia. La función detecta si CDP ya responde en el puerto y avisa por stderr, pero procede igualmente.
|
|
||||||
- **Web Store vs unpacked**: instalar extensiones desde la Web Store (un clic) persiste en el perfil sin necesidad de flags y sobrevive reinicios. Esta función es para extensiones unpacked en desarrollo o que no están en la Web Store. Si usas ambas, los flags no interfieren con las instaladas del store.
|
|
||||||
- **zsh globbing**: `--remote-allow-origins=*` está dentro de comillas en la función, no se expande. Si lo pasas desde la línea de comandos, entrecomillarlo.
|
|
||||||
- **Proxy + extensión**: si usas proxy para captura de tráfico (Burp, mitmproxy, gost), el proxy se aplica a toda la sesión Chrome, incluyendo el tráfico de las extensiones.
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# chrome_load_extensions — lanza Chrome (WSL2→Windows chrome.exe) con extensiones unpacked cargadas en un perfil CDP.
|
|
||||||
# Chrome 148+: requiere --load-extension=<paths> Y --disable-extensions-except=<same paths> juntos.
|
|
||||||
# NUNCA pasar --disable-extensions (desactiva todo, incluyendo las que quieres cargar).
|
|
||||||
|
|
||||||
chrome_load_extensions() {
|
|
||||||
local port=9222
|
|
||||||
local profile=""
|
|
||||||
local proxy=""
|
|
||||||
local url=""
|
|
||||||
local -a ext_paths=()
|
|
||||||
|
|
||||||
# --- Parse args ---
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--port)
|
|
||||||
port="$2"; shift 2 ;;
|
|
||||||
--profile)
|
|
||||||
profile="$2"; shift 2 ;;
|
|
||||||
--ext)
|
|
||||||
ext_paths+=("$2"); shift 2 ;;
|
|
||||||
--proxy)
|
|
||||||
proxy="$2"; shift 2 ;;
|
|
||||||
--url)
|
|
||||||
url="$2"; shift 2 ;;
|
|
||||||
--*)
|
|
||||||
echo "chrome_load_extensions: flag desconocido: $1" >&2; return 1 ;;
|
|
||||||
*)
|
|
||||||
# Positional = extra ext path
|
|
||||||
ext_paths+=("$1"); shift ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ ${#ext_paths[@]} -eq 0 ]]; then
|
|
||||||
echo "chrome_load_extensions: se requiere al menos un --ext PATH de extension unpacked" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Detectar chrome.exe ---
|
|
||||||
local chrome_bin=""
|
|
||||||
if command -v chrome.exe &>/dev/null; then
|
|
||||||
chrome_bin="chrome.exe"
|
|
||||||
elif [[ -f "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe" ]]; then
|
|
||||||
chrome_bin="/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"
|
|
||||||
elif [[ -f "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe" ]]; then
|
|
||||||
chrome_bin="/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe"
|
|
||||||
else
|
|
||||||
echo "chrome_load_extensions: chrome.exe no encontrado en PATH ni en rutas conocidas" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Detectar WSL2 ---
|
|
||||||
local wsl2=0
|
|
||||||
if grep -qi 'microsoft\|wsl' /proc/version 2>/dev/null; then
|
|
||||||
wsl2=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Traducir paths de extensiones a Windows si hace falta ---
|
|
||||||
local -a win_ext_paths=()
|
|
||||||
for p in "${ext_paths[@]}"; do
|
|
||||||
if [[ $wsl2 -eq 1 ]] && [[ "$p" != [A-Za-z]:\\* ]]; then
|
|
||||||
# Path Linux → traducir a Windows
|
|
||||||
local win_p
|
|
||||||
win_p=$(wslpath -w "$p" 2>/dev/null) || {
|
|
||||||
echo "chrome_load_extensions: wslpath -w '$p' falló" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
win_ext_paths+=("$win_p")
|
|
||||||
else
|
|
||||||
win_ext_paths+=("$p")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- Resolver perfil ---
|
|
||||||
if [[ -z "$profile" ]]; then
|
|
||||||
# Default: perfil canónico fn-chrome-cdp-profile en Windows
|
|
||||||
local win_user="${USERNAME:-${USER:-lucas}}"
|
|
||||||
if [[ $wsl2 -eq 1 ]]; then
|
|
||||||
profile="C:\\Users\\${win_user}\\AppData\\Local\\fn-chrome-cdp-profile"
|
|
||||||
else
|
|
||||||
profile="/tmp/fn-chrome-cdp-profile"
|
|
||||||
fi
|
|
||||||
elif [[ $wsl2 -eq 1 ]] && [[ "$profile" != [A-Za-z]:\\* ]]; then
|
|
||||||
# Path Linux del perfil → traducir a Windows
|
|
||||||
profile=$(wslpath -w "$profile" 2>/dev/null) || {
|
|
||||||
echo "chrome_load_extensions: wslpath -w '$profile' falló" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Construir lista de paths separada por coma (para Chrome) ---
|
|
||||||
# Chrome usa coma como separador en --load-extension y --disable-extensions-except.
|
|
||||||
# NO usar `echo` para el join: rutas Windows como C:\Users tienen \U, y el echo de
|
|
||||||
# zsh (o sh con xpg_echo) interpreta \U como escape unicode y trunca la ruta a "C:".
|
|
||||||
# Acumulador con += y printf-safe, sin interpretacion de backslashes.
|
|
||||||
local ext_list=""
|
|
||||||
local p
|
|
||||||
for p in "${win_ext_paths[@]}"; do
|
|
||||||
ext_list+="${ext_list:+,}${p}"
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- Construir args de Chrome ---
|
|
||||||
local -a args=(
|
|
||||||
"--remote-debugging-port=${port}"
|
|
||||||
"--user-data-dir=${profile}"
|
|
||||||
"--no-first-run"
|
|
||||||
"--no-default-browser-check"
|
|
||||||
"--remote-allow-origins=*"
|
|
||||||
"--load-extension=${ext_list}"
|
|
||||||
"--disable-extensions-except=${ext_list}"
|
|
||||||
# Chrome 137+ activa por defecto el feature DisableLoadExtensionCommandLineSwitch,
|
|
||||||
# que IGNORA silenciosamente --load-extension. Hay que desactivarlo o las
|
|
||||||
# extensiones unpacked no cargan (chrome://extensions sale vacio).
|
|
||||||
"--disable-features=DisableLoadExtensionCommandLineSwitch"
|
|
||||||
)
|
|
||||||
|
|
||||||
# WSL2: bind en 0.0.0.0 para que sea accesible desde la red WSL
|
|
||||||
if [[ $wsl2 -eq 1 ]]; then
|
|
||||||
args+=("--remote-debugging-address=0.0.0.0")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$proxy" ]]; then
|
|
||||||
args+=("--proxy-server=${proxy}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$url" ]]; then
|
|
||||||
args+=("--new-window" "$url")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Revisar si CDP ya responde en el puerto ---
|
|
||||||
if curl -sf --max-time 1 "http://127.0.0.1:${port}/json/version" &>/dev/null; then
|
|
||||||
echo "chrome_load_extensions: CDP ya activo en puerto ${port}; lanzando ventana extra" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Lanzar Chrome desacoplado del proceso padre ---
|
|
||||||
# setsid + redirección evita el exit 144 en el Bash tool (el pipe no queda retenido).
|
|
||||||
setsid "$chrome_bin" "${args[@]}" </dev/null >"/tmp/chrome_ext_${port}.log" 2>&1 &
|
|
||||||
local chrome_pid=$!
|
|
||||||
disown "$chrome_pid"
|
|
||||||
|
|
||||||
echo "chrome_load_extensions: Chrome lanzado PID=${chrome_pid} puerto=${port}" >&2
|
|
||||||
|
|
||||||
# --- Esperar a que CDP esté listo (hasta 15 segundos) ---
|
|
||||||
local deadline=$(( $(date +%s) + 15 ))
|
|
||||||
local ready=0
|
|
||||||
while [[ $(date +%s) -lt $deadline ]]; do
|
|
||||||
if curl -sf --max-time 1 "http://127.0.0.1:${port}/json/version" &>/dev/null; then
|
|
||||||
ready=1
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 0.5
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ $ready -eq 1 ]]; then
|
|
||||||
echo "chrome_load_extensions: CDP listo en 127.0.0.1:${port}"
|
|
||||||
else
|
|
||||||
echo "chrome_load_extensions: advertencia — CDP no respondió en 15s en puerto ${port}; Chrome puede estar iniciando lentamente" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$chrome_pid"
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
---
|
|
||||||
name: install_chromium_proxy_extension
|
|
||||||
kind: function
|
|
||||||
lang: bash
|
|
||||||
domain: browser
|
|
||||||
version: 1.0.0
|
|
||||||
purity: impure
|
|
||||||
signature: install_chromium_proxy_extension --ext-dir DIR [--name NAME] [--stable-dir DIR] [--uninstall]
|
|
||||||
description: "Instala una extension desempaquetada de Chromium en todos los perfiles del usuario de forma persistente, escribiendo un fragmento en /etc/chromium.d/ que el wrapper de Chromium carga en cada arranque. Pensado para distribuir la extension de toggle de proxy de web_proxy sin Web Store, pero sirve para cualquier extension desempaquetada."
|
|
||||||
tags: [web-proxy, chromium, extension, browser, proxy, install]
|
|
||||||
uses_functions: []
|
|
||||||
uses_types: []
|
|
||||||
returns: []
|
|
||||||
returns_optional: false
|
|
||||||
error_type: "error_go_core"
|
|
||||||
params:
|
|
||||||
- name: --ext-dir
|
|
||||||
desc: "Directorio de la extension desempaquetada de origen (debe contener manifest.json). Obligatorio salvo en --uninstall."
|
|
||||||
- name: --name
|
|
||||||
desc: "Nombre del fragmento en /etc/chromium.d/ (default web_proxy_ext). Identifica esta instalacion para poder desinstalarla."
|
|
||||||
- name: --stable-dir
|
|
||||||
desc: "Ruta estable donde se copia la extension, independiente del repo (default ~/.web_proxy/extension). --load-extension apunta aqui."
|
|
||||||
- name: --uninstall
|
|
||||||
desc: "Elimina el fragmento de /etc/chromium.d/ y la copia estable. No requiere --ext-dir."
|
|
||||||
output: "JSON en stdout: {installed|uninstalled, name, stable_dir, chromiumd, ext_id}. Requiere sudo para escribir en /etc/chromium.d/."
|
|
||||||
file_path: bash/functions/browser/install_chromium_proxy_extension.sh
|
|
||||||
---
|
|
||||||
|
|
||||||
# install_chromium_proxy_extension
|
|
||||||
|
|
||||||
Instala una extension desempaquetada de Chromium en **todos los perfiles** del
|
|
||||||
usuario, de forma persistente, sin pasar por la Chrome Web Store.
|
|
||||||
|
|
||||||
## Ejemplo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Instalar la extension de toggle de proxy de web_proxy en todos los perfiles
|
|
||||||
install_chromium_proxy_extension --ext-dir /home/enmanuel/fn_registry/apps/web_proxy/extension
|
|
||||||
|
|
||||||
# Desinstalarla
|
|
||||||
install_chromium_proxy_extension --uninstall
|
|
||||||
|
|
||||||
# Otra extension, con nombre y ruta estable propios
|
|
||||||
install_chromium_proxy_extension --ext-dir ~/mis-extensiones/foo --name foo_ext --stable-dir ~/.local/share/foo_ext
|
|
||||||
```
|
|
||||||
|
|
||||||
Tras instalar, cierra y vuelve a abrir Chromium: la extension aparece en todos
|
|
||||||
los perfiles, incluidos los que se creen despues.
|
|
||||||
|
|
||||||
## Cuando usarla
|
|
||||||
|
|
||||||
Cuando necesitas que una extension desempaquetada este presente en todos los
|
|
||||||
perfiles de Chromium de una maquina (por ejemplo, un toggle de proxy de captura
|
|
||||||
preconfigurado) y no quieres publicarla en la Web Store ni cargarla a mano en
|
|
||||||
cada perfil. Es la pieza que hace que `web_proxy` quede "a un clic" en cualquier
|
|
||||||
ventana de Chromium.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **Requiere sudo** para escribir en `/etc/chromium.d/`. Ten las credenciales
|
|
||||||
cacheadas (`sudo -v`) antes de invocarla de forma no interactiva.
|
|
||||||
- **Solo para el wrapper de Chromium de Debian/Ubuntu** (paquete `chromium`,
|
|
||||||
no snap ni Google Chrome). El wrapper hace `source /etc/chromium.d/*` en cada
|
|
||||||
arranque. Comprueba con `head -1 $(command -v chromium)` que es un script.
|
|
||||||
- **`--enable-remote-extensions` es imprescindible** en estos builds: sin el,
|
|
||||||
el wrapper anade `--disable-extensions-except` y `--disable-background-networking`,
|
|
||||||
que deshabilitan toda extension que no venga por `--load-extension`. El
|
|
||||||
fragmento generado lo incluye; por eso las demas extensiones del usuario
|
|
||||||
siguen funcionando.
|
|
||||||
- La extension se carga **desempaquetada** (`--load-extension`), no como `.crx`
|
|
||||||
firmado. Chromium puede mostrar un aviso de "extensiones en modo desarrollador".
|
|
||||||
El force-install via managed policy con `.crx` local + `update_url file://`
|
|
||||||
no funciona con este wrapper (lo bloquea `--disable-extensions-except`).
|
|
||||||
- El ID de la extension depende de `--stable-dir` (se deriva del path). Si
|
|
||||||
cambias la ruta estable, el ID cambia.
|
|
||||||
- No reinicia Chromium: los cambios aplican en el siguiente arranque del
|
|
||||||
navegador.
|
|
||||||
|
|
||||||
## Capability growth log
|
|
||||||
|
|
||||||
- v1.0.0 (2026-06-02) — version inicial. Instala/desinstala extension global via /etc/chromium.d con --enable-remote-extensions + --load-extension.
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# install_chromium_proxy_extension — instala una extension desempaquetada de
|
|
||||||
# Chromium en TODOS los perfiles del usuario, de forma persistente, escribiendo
|
|
||||||
# un fragmento en /etc/chromium.d/ que el wrapper de Chromium carga en cada
|
|
||||||
# arranque.
|
|
||||||
#
|
|
||||||
# Por que /etc/chromium.d en vez de managed policy con .crx force_installed:
|
|
||||||
# el wrapper de Chromium de Debian/Ubuntu (xtradeb y derivados), cuando NO se
|
|
||||||
# pasa --enable-remote-extensions, anade --disable-extensions-except=<solo las
|
|
||||||
# de --load-extension> y --disable-background-networking. Eso deshabilita
|
|
||||||
# cualquier extension force_installed por policy y bloquea su update check. La
|
|
||||||
# via fiable es habilitar --enable-remote-extensions y cargar la extension
|
|
||||||
# desempaquetada con --load-extension, ambos inyectados de forma global desde
|
|
||||||
# /etc/chromium.d/, que el wrapper hace `source` en cada lanzamiento.
|
|
||||||
|
|
||||||
install_chromium_proxy_extension() {
|
|
||||||
local ext_dir=""
|
|
||||||
local name="web_proxy_ext"
|
|
||||||
local stable_dir="$HOME/.web_proxy/extension"
|
|
||||||
local chromiumd="/etc/chromium.d"
|
|
||||||
local uninstall="no"
|
|
||||||
|
|
||||||
# Permite sudo no interactivo via SUDO_ASKPASS (sudo -A) cuando se ejecuta
|
|
||||||
# sin terminal (agentes, CI). Con terminal interactivo usa sudo normal.
|
|
||||||
local SUDO="sudo"
|
|
||||||
[[ -n "${SUDO_ASKPASS:-}" ]] && SUDO="sudo -A"
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--ext-dir) ext_dir="$2"; shift 2 ;;
|
|
||||||
--name) name="$2"; shift 2 ;;
|
|
||||||
--stable-dir) stable_dir="$2"; shift 2 ;;
|
|
||||||
--uninstall) uninstall="yes"; shift ;;
|
|
||||||
*) echo "ERROR: argumento desconocido: $1" >&2; return 1 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ ! -d "$chromiumd" ]]; then
|
|
||||||
echo "ERROR: $chromiumd no existe. Este Chromium no usa el wrapper con /etc/chromium.d." >&2
|
|
||||||
echo " Comprueba 'head -1 \$(command -v chromium)'; si no es un wrapper shell, usa otra via." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Desinstalacion: quitar el fragmento global y la copia estable.
|
|
||||||
if [[ "$uninstall" == "yes" ]]; then
|
|
||||||
$SUDO rm -f "${chromiumd}/${name}" || {
|
|
||||||
echo "ERROR: no se pudo eliminar ${chromiumd}/${name} (requiere sudo)." >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
rm -rf "$stable_dir"
|
|
||||||
printf '{"uninstalled": true, "name": "%s"}\n' "$name"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Instalacion: validar la extension de origen.
|
|
||||||
if [[ -z "$ext_dir" || ! -f "${ext_dir}/manifest.json" ]]; then
|
|
||||||
echo "ERROR: --ext-dir debe apuntar a un directorio con manifest.json." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Copiar la extension a una ubicacion estable, independiente del repo, para
|
|
||||||
# que --load-extension no se rompa si el repo se mueve o se limpia.
|
|
||||||
mkdir -p "$stable_dir" || {
|
|
||||||
echo "ERROR: no se pudo crear $stable_dir." >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
# Vaciar destino y copiar el contenido del origen.
|
|
||||||
rm -rf "${stable_dir:?}/"* 2>/dev/null
|
|
||||||
cp -r "${ext_dir}/." "$stable_dir/" || {
|
|
||||||
echo "ERROR: no se pudo copiar la extension a $stable_dir." >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Escribir el fragmento que el wrapper carga en cada arranque. Se hace via
|
|
||||||
# archivo temporal + sudo cp para no exponer el contenido por una tuberia.
|
|
||||||
local tmp
|
|
||||||
tmp="$(mktemp)"
|
|
||||||
printf 'export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --enable-remote-extensions --load-extension=%s"\n' "$stable_dir" > "$tmp"
|
|
||||||
if ! $SUDO cp "$tmp" "${chromiumd}/${name}"; then
|
|
||||||
rm -f "$tmp"
|
|
||||||
echo "ERROR: no se pudo escribir ${chromiumd}/${name} (requiere sudo)." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
$SUDO chmod 0644 "${chromiumd}/${name}" 2>/dev/null
|
|
||||||
rm -f "$tmp"
|
|
||||||
|
|
||||||
# ID de extension desempaquetada (deterministico: sha256 del path estable).
|
|
||||||
local ext_id
|
|
||||||
ext_id="$(python3 - "$stable_dir" <<'PY' 2>/dev/null
|
|
||||||
import hashlib, sys
|
|
||||||
h = hashlib.sha256(sys.argv[1].encode()).hexdigest()[:32]
|
|
||||||
print(''.join(chr(ord('a') + int(c, 16)) for c in h))
|
|
||||||
PY
|
|
||||||
)"
|
|
||||||
|
|
||||||
printf '{"installed": true, "name": "%s", "stable_dir": "%s", "chromiumd": "%s/%s", "ext_id": "%s"}\n' \
|
|
||||||
"$name" "$stable_dir" "$chromiumd" "$name" "$ext_id"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Ejecutar si se llama directamente (fn run / bash <file>)
|
|
||||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
||||||
install_chromium_proxy_extension "$@"
|
|
||||||
fi
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
---
|
|
||||||
name: launch_chromium_proxy
|
|
||||||
kind: function
|
|
||||||
lang: bash
|
|
||||||
domain: browser
|
|
||||||
version: "1.0.0"
|
|
||||||
purity: impure
|
|
||||||
signature: "launch_chromium_proxy [--proxy URL] [--profile DIR] [--url URL] [--ca-cert PATH] [--extra \"ARGS\"]"
|
|
||||||
description: "Lanza Chromium (o Chrome) apuntando a un proxy HTTP/HTTPS local con un perfil completamente aislado del perfil real del usuario. Pensado para capturar trafico con un proxy de interceptacion (mitmproxy, Burp Suite) sin contaminar la sesion normal de navegacion. Emite un JSON con el PID del proceso lanzado."
|
|
||||||
tags: [chromium, chrome, proxy, mitmproxy, burp, browser, web-proxy, intercept, tls]
|
|
||||||
uses_functions: []
|
|
||||||
uses_types: []
|
|
||||||
returns: []
|
|
||||||
returns_optional: false
|
|
||||||
error_type: "error_go_core"
|
|
||||||
imports: []
|
|
||||||
params:
|
|
||||||
- name: "--proxy URL"
|
|
||||||
desc: "URL del proxy HTTP/HTTPS local, ej. http://127.0.0.1:8080. Se pasa a Chromium como --proxy-server=URL. Default: http://127.0.0.1:8080."
|
|
||||||
- name: "--profile DIR"
|
|
||||||
desc: "Directorio de perfil aislado para Chromium (--user-data-dir). Se crea automaticamente si no existe. Default: /tmp/chromium-proxy. Usar un path distinto por sesion si se quiere aislamiento total entre corridas."
|
|
||||||
- name: "--url URL"
|
|
||||||
desc: "URL inicial a abrir al arrancar el navegador. Opcional. Si se omite, Chromium abre su pagina de nueva pestana."
|
|
||||||
- name: "--ca-cert PATH"
|
|
||||||
desc: "Ruta a un CA cert PEM del proxy (ej. ~/.mitmproxy/mitmproxy-ca-cert.pem). Si se pasa, la funcion NO agrega --ignore-certificate-errors y asume que el usuario ya importo el CA en el perfil o en el sistema. Si se omite, se agrega --ignore-certificate-errors automaticamente para que el proxy MITM no rompa HTTPS (menos seguro)."
|
|
||||||
- name: "--extra \"ARGS\""
|
|
||||||
desc: "Flags extra que se pasan directamente a chromium, ej. --extra \"--disable-gpu --window-size=1280,800\". El valor completo debe ir entre comillas."
|
|
||||||
output: "JSON en stdout: {\"pid\": <pid>, \"browser\": \"<binario>\", \"proxy\": \"<url>\", \"profile\": \"<dir>\", \"log\": \"<ruta_log>\"}. Mensajes de estado e informacion de CA en stderr. El navegador corre en background desacoplado de la sesion. Exit codes: 0=lanzado correctamente, 1=binario no encontrado o argumento invalido o error al crear el directorio de perfil."
|
|
||||||
tested: false
|
|
||||||
tests: []
|
|
||||||
test_file_path: ""
|
|
||||||
file_path: "bash/functions/browser/launch_chromium_proxy.sh"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ejemplo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
source bash/functions/browser/launch_chromium_proxy.sh
|
|
||||||
|
|
||||||
# Caso mas comun: interceptar trafico con mitmproxy corriendo en 8080
|
|
||||||
# (sin CA instalado: --ignore-certificate-errors se aplica automaticamente)
|
|
||||||
result=$(launch_chromium_proxy --proxy http://127.0.0.1:8080 --url https://httpbin.org/get)
|
|
||||||
echo "$result"
|
|
||||||
# {"pid":12345,"browser":"chromium","proxy":"http://127.0.0.1:8080","profile":"/tmp/chromium-proxy","log":"/tmp/chromium-proxy-12345.log"}
|
|
||||||
|
|
||||||
# Con CA cert instalado (mitmproxy): sin --ignore-certificate-errors
|
|
||||||
launch_chromium_proxy \
|
|
||||||
--proxy http://127.0.0.1:8080 \
|
|
||||||
--ca-cert ~/.mitmproxy/mitmproxy-ca-cert.pem \
|
|
||||||
--profile /tmp/mitm-session \
|
|
||||||
--url https://api.ejemplo.com/v1/test
|
|
||||||
|
|
||||||
# Con Burp Suite en puerto 8081, perfil aislado y ventana de tamano fijo
|
|
||||||
launch_chromium_proxy \
|
|
||||||
--proxy http://127.0.0.1:8081 \
|
|
||||||
--profile /tmp/burp-session \
|
|
||||||
--extra "--window-size=1440,900" \
|
|
||||||
--url https://app.objetivo.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cuando usarla
|
|
||||||
|
|
||||||
Cuando necesitas capturar y analizar trafico HTTPS de un navegador con mitmproxy, Burp Suite u otro proxy de interceptacion, sin tocar el perfil real del usuario ni sus cookies/credenciales guardadas. Ideal antes de hacer analisis de trafico de una app web o API, o al reproducir un flujo autenticado desde una sesion limpia.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **Deteccion de binario en orden**: la funcion prueba `chromium`, `chromium-browser`, `google-chrome-stable`, `google-chrome`. En sistemas donde solo existe `google-chrome`, ese sera el binario usado. Si ninguno esta en el PATH, retorna exit 1. Instalar con `sudo apt install chromium` o `chromium-browser`.
|
|
||||||
- **`--ignore-certificate-errors` sin `--ca-cert`**: este flag desactiva toda la validacion TLS del navegador. Es conveniente para empezar rapido, pero reduce la seguridad de la sesion. Para produccion o analisis de seguridad serio, instalar el CA del proxy en el sistema (`sudo cp mitmproxy-ca-cert.pem /usr/local/share/ca-certificates/mitmproxy.crt && sudo update-ca-certificates`) o en el perfil de Chromium (chrome://settings/certificates), y pasar `--ca-cert` para que la funcion omita el flag inseguro.
|
|
||||||
- **`--proxy-bypass-list="<-loopback>"`**: fuerza que el trafico loopback (127.0.0.1, localhost) TAMBIEN pase por el proxy. Sin esto, Chromium excluye loopback del proxy por defecto y no veras esas peticiones en mitmproxy. Si quieres el comportamiento estandar (excluir loopback), elimina este flag via `--extra`.
|
|
||||||
- **Perfil persistente entre sesiones**: el perfil en `/tmp/chromium-proxy` (o el directorio que elijas) persiste entre ejecuciones. Si quieres una sesion 100% limpia cada vez, pasa `--profile /tmp/chromium-proxy-$$` (usa el PID del shell como sufijo) o borra el directorio antes de llamar a la funcion.
|
|
||||||
- **`setsid` + `disown`**: el navegador se lanza desacoplado de la sesion del agente. Si la shell/sesion que llamo a la funcion termina, el proceso Chromium sigue vivo. Para matarlo, usar `kill <pid>` con el PID del JSON de salida.
|
|
||||||
- **Log del navegador**: stdout y stderr de Chromium se redirigen a `/tmp/chromium-proxy-<pid>.log`. Si el navegador no arranca, revisar ese archivo para ver el error.
|
|
||||||
- **Chrome STABLE 138+**: al igual que `chrome_load_extensions`, algunos flags de automatizacion estan bloqueados en Chrome STABLE. Para interceptacion de trafico `--proxy-server` y `--user-data-dir` siguen funcionando en todas las versiones. Esta funcion es compatible con Chrome/Chromium en cualquier canal.
|
|
||||||
- **Multiples instancias**: si ya hay una instancia de Chromium corriendo con el mismo `--user-data-dir`, Chromium puede reusar esa instancia en lugar de abrir una nueva. Usar un directorio de perfil distinto por sesion concurrente.
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# launch_chromium_proxy — Lanza Chromium apuntando a un proxy HTTP/HTTPS local con perfil aislado.
|
|
||||||
|
|
||||||
launch_chromium_proxy() {
|
|
||||||
local proxy_url="http://127.0.0.1:8080"
|
|
||||||
local profile_dir="/tmp/chromium-proxy"
|
|
||||||
local start_url=""
|
|
||||||
local ca_cert=""
|
|
||||||
local extra_args=""
|
|
||||||
local ext_dir=""
|
|
||||||
|
|
||||||
# Parsear argumentos
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--proxy)
|
|
||||||
proxy_url="$2"; shift 2 ;;
|
|
||||||
--profile)
|
|
||||||
profile_dir="$2"; shift 2 ;;
|
|
||||||
--url)
|
|
||||||
start_url="$2"; shift 2 ;;
|
|
||||||
--ca-cert)
|
|
||||||
ca_cert="$2"; shift 2 ;;
|
|
||||||
--ext)
|
|
||||||
ext_dir="$2"; shift 2 ;;
|
|
||||||
--extra)
|
|
||||||
extra_args="$2"; shift 2 ;;
|
|
||||||
*)
|
|
||||||
echo "ERROR: argumento desconocido: $1" >&2
|
|
||||||
return 1 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# Detectar binario del navegador
|
|
||||||
local browser_bin=""
|
|
||||||
for candidate in chromium chromium-browser google-chrome-stable google-chrome; do
|
|
||||||
if command -v "$candidate" &>/dev/null; then
|
|
||||||
browser_bin="$candidate"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ -z "$browser_bin" ]]; then
|
|
||||||
echo "ERROR: no se encontro ningun binario Chromium/Chrome en el PATH." >&2
|
|
||||||
echo " Probados: chromium, chromium-browser, google-chrome-stable, google-chrome." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Crear directorio de perfil si no existe
|
|
||||||
if [[ ! -d "$profile_dir" ]]; then
|
|
||||||
mkdir -p "$profile_dir" || {
|
|
||||||
echo "ERROR: no se pudo crear el directorio de perfil: $profile_dir" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Construir argumentos del navegador
|
|
||||||
local args=(
|
|
||||||
"--user-data-dir=${profile_dir}"
|
|
||||||
"--no-first-run"
|
|
||||||
"--no-default-browser-check"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Proxy fijo opcional. Con "--proxy none" (o vacio) no se fija proxy en el
|
|
||||||
# cmdline: util cuando una extension de proxy gestiona la conexion (toggle).
|
|
||||||
if [[ -n "$proxy_url" && "$proxy_url" != "none" ]]; then
|
|
||||||
args+=("--proxy-server=${proxy_url}" "--proxy-bypass-list=<-loopback>")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Cargar una extension desempaquetada (--load-extension). Funciona en
|
|
||||||
# Chromium (no en Chrome stable 138+). Para persistencia en todos los
|
|
||||||
# perfiles se usa managed policy en su lugar.
|
|
||||||
if [[ -n "$ext_dir" ]]; then
|
|
||||||
args+=("--load-extension=${ext_dir}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Manejo de certificados TLS
|
|
||||||
if [[ -n "$ca_cert" ]]; then
|
|
||||||
# El usuario instalo el CA en el perfil; no ignorar errores de certificado.
|
|
||||||
# (El CA se instala en el sistema o en el perfil antes de lanzar.)
|
|
||||||
echo "INFO: CA cert declarado: $ca_cert" >&2
|
|
||||||
echo "INFO: Asegurate de haber importado el CA en el perfil o en el sistema antes de navegar HTTPS." >&2
|
|
||||||
else
|
|
||||||
# Sin CA cert: ignorar errores de certificado para que mitmproxy/Burp funcionen sin configuracion extra.
|
|
||||||
# ADVERTENCIA: esto desactiva la validacion TLS completa del navegador.
|
|
||||||
args+=("--ignore-certificate-errors")
|
|
||||||
echo "WARN: --ignore-certificate-errors activo. Usa --ca-cert si instalaste el CA del proxy." >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# URL inicial opcional
|
|
||||||
if [[ -n "$start_url" ]]; then
|
|
||||||
args+=("$start_url")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Argumentos extra pasados por el usuario
|
|
||||||
# shellcheck disable=SC2206
|
|
||||||
local extra_arr=()
|
|
||||||
if [[ -n "$extra_args" ]]; then
|
|
||||||
read -r -a extra_arr <<< "$extra_args"
|
|
||||||
args+=("${extra_arr[@]}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Log temporal para stderr/stdout del navegador
|
|
||||||
local log_file="/tmp/chromium-proxy-$$.log"
|
|
||||||
|
|
||||||
# Lanzar en background desacoplado de la sesion del agente
|
|
||||||
setsid "$browser_bin" "${args[@]}" </dev/null >"$log_file" 2>&1 &
|
|
||||||
local browser_pid=$!
|
|
||||||
disown "$browser_pid"
|
|
||||||
|
|
||||||
# Emitir JSON con informacion del proceso lanzado
|
|
||||||
printf '{"pid":%d,"browser":"%s","proxy":"%s","profile":"%s","log":"%s"}\n' \
|
|
||||||
"$browser_pid" "$browser_bin" "$proxy_url" "$profile_dir" "$log_file"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Ejecutar si se llama directamente (fn run / bash <file>)
|
|
||||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
||||||
launch_chromium_proxy "$@"
|
|
||||||
fi
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
---
|
|
||||||
name: query_mitm_flows
|
|
||||||
kind: function
|
|
||||||
lang: bash
|
|
||||||
domain: cybersecurity
|
|
||||||
version: "1.0.0"
|
|
||||||
purity: impure
|
|
||||||
signature: "query_mitm_flows(file_or_glob: string, filter?: string, har?: string, mitmdump?: string) -> void"
|
|
||||||
description: "Consulta capturas .mitm guardadas con mitmproxy: vuelca los flujos que coinciden con un filtro de mitmproxy a stdout, o exporta la captura a HAR. Acepta uno o varios archivos .mitm (incluyendo globs expandidos por el shell). Autodetecta mitmdump en PATH y $HOME/.local/bin."
|
|
||||||
tags: [bash, cybersecurity, mitmproxy, web-proxy, query, har, capture, traffic, proxy, network]
|
|
||||||
uses_functions: []
|
|
||||||
uses_types: []
|
|
||||||
returns: []
|
|
||||||
returns_optional: false
|
|
||||||
error_type: "error_go_core"
|
|
||||||
imports: []
|
|
||||||
params:
|
|
||||||
- name: file_or_glob
|
|
||||||
desc: "Ruta a un archivo .mitm o glob expandido por el shell (ej. ~/captures/traffic-*.mitm). Acepta múltiples archivos como argumentos posicionales."
|
|
||||||
- name: filter
|
|
||||||
desc: "Expresión de filtro mitmproxy (ej. '~m POST & ~u /api', '~c 500', '~d example.com'). Si se omite, vuelca todos los flujos."
|
|
||||||
- name: har
|
|
||||||
desc: "Ruta de salida para exportar en formato HAR (--set hardump=OUT). Si se omite, el volcado va a stdout."
|
|
||||||
- name: mitmdump
|
|
||||||
desc: "Ruta al binario mitmdump. Si se omite, autodetecta en PATH y luego en $HOME/.local/bin/mitmdump."
|
|
||||||
output: "Vuelca los flujos capturados a stdout (modo default) o exporta a un archivo HAR (con --har); informa la ruta de exportación en stderr al terminar. Exit code de mitmdump propagado."
|
|
||||||
tested: false
|
|
||||||
tests: []
|
|
||||||
test_file_path: ""
|
|
||||||
file_path: "bash/functions/cybersecurity/query_mitm_flows.sh"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ejemplo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
source bash/functions/cybersecurity/query_mitm_flows.sh
|
|
||||||
|
|
||||||
# Volcar todos los flujos de una captura
|
|
||||||
query_mitm_flows ~/captures/traffic-20260602.mitm
|
|
||||||
|
|
||||||
# Filtrar solo peticiones POST a /api (glob expandido por el shell)
|
|
||||||
query_mitm_flows ~/captures/traffic-20260602-*.mitm --filter "~m POST & ~u /api"
|
|
||||||
|
|
||||||
# Ver solo respuestas con código 500
|
|
||||||
query_mitm_flows session.mitm --filter "~c 500"
|
|
||||||
|
|
||||||
# Exportar a HAR para abrir en el Network tab del browser DevTools
|
|
||||||
query_mitm_flows ~/captures/traffic-20260602.mitm --har salida.har
|
|
||||||
|
|
||||||
# Exportar a HAR con filtro aplicado
|
|
||||||
query_mitm_flows session.mitm --filter "~d example.com" --har example_flows.har
|
|
||||||
|
|
||||||
# Especificar mitmdump manualmente (ej. en un venv de Python)
|
|
||||||
query_mitm_flows session.mitm --mitmdump ~/.venv/bin/mitmdump --filter "~m POST"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cuando usarla
|
|
||||||
|
|
||||||
Cuando hayas capturado tráfico HTTP/HTTPS con mitmproxy y necesites consultarlo después sin abrir una interfaz interactiva: filtrar flujos específicos por método, dominio, URL o código de respuesta, o exportar la captura a HAR para análisis posterior en herramientas externas (browser DevTools, Insomnia, Postman). Úsala desde scripts de análisis automatizados o pipelines de revisión de seguridad.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
**Sintaxis de filtros mitmproxy** — los filtros se pasan como expresión entre comillas:
|
|
||||||
|
|
||||||
| Operador | Significado | Ejemplo |
|
|
||||||
|---|---|---|
|
|
||||||
| `~u <regex>` | URL (path + query) | `~u /api/login` |
|
|
||||||
| `~d <regex>` | Dominio del host | `~d example.com` |
|
|
||||||
| `~m <método>` | Método HTTP | `~m POST` |
|
|
||||||
| `~c <código>` | Código de respuesta | `~c 500` |
|
|
||||||
| `~bq <regex>` | Body del request | `~bq password` |
|
|
||||||
| `~bs <regex>` | Body del response | `~bs token` |
|
|
||||||
| `~t <regex>` | Content-Type | `~t application/json` |
|
|
||||||
| `~s` | Solo respuestas | `~s` |
|
|
||||||
| `~q` | Solo requests | `~q` |
|
|
||||||
|
|
||||||
Combinar con `&` (AND), `|` (OR), `!` (NOT). Ejemplos:
|
|
||||||
- `"~m POST & ~u /api"` — POST a rutas /api
|
|
||||||
- `"~c 500 | ~c 503"` — errores de servidor
|
|
||||||
- `"~d example.com & !~u /static"` — todo de example.com excepto estáticos
|
|
||||||
|
|
||||||
**Globs:** el shell expande el glob *antes* de pasar los argumentos a la función. Pasar `~/captures/traffic-*.mitm` sin comillas para que el shell expanda; con comillas el glob llega literalmente y fallará si el archivo no existe.
|
|
||||||
|
|
||||||
**Inspección interactiva:** para navegar los flujos con UI, usar `mitmweb -r <file>` (web) o `mitmproxy -r <file>` (TUI), no esta función. Esta función es para consulta programática y scripting.
|
|
||||||
|
|
||||||
**Múltiples archivos con `-r`:** mitmdump acepta varios flags `-r` y concatena los flujos en orden. El filtro se aplica sobre el conjunto completo.
|
|
||||||
|
|
||||||
**HAR y filtros juntos:** al usar `--har` con `--filter`, solo los flujos que pasen el filtro se exportan al HAR.
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# query_mitm_flows — Consulta capturas .mitm con mitmdump: vuelca flujos a stdout
|
|
||||||
# o exporta a HAR. Acepta uno o varios archivos (también globs expandidos por el shell).
|
|
||||||
|
|
||||||
query_mitm_flows() {
|
|
||||||
local -a files=()
|
|
||||||
local filter=""
|
|
||||||
local har_out=""
|
|
||||||
local mitmdump_bin=""
|
|
||||||
|
|
||||||
# ── Parseo de argumentos ────────────────────────────────────────────────────
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--filter)
|
|
||||||
[[ -z "${2:-}" ]] && { echo "ERROR: --filter requiere un valor" >&2; return 1; }
|
|
||||||
filter="$2"; shift 2 ;;
|
|
||||||
--har)
|
|
||||||
[[ -z "${2:-}" ]] && { echo "ERROR: --har requiere una ruta de salida" >&2; return 1; }
|
|
||||||
har_out="$2"; shift 2 ;;
|
|
||||||
--mitmdump)
|
|
||||||
[[ -z "${2:-}" ]] && { echo "ERROR: --mitmdump requiere la ruta al binario" >&2; return 1; }
|
|
||||||
mitmdump_bin="$2"; shift 2 ;;
|
|
||||||
--*)
|
|
||||||
echo "ERROR: opcion desconocida: $1" >&2; return 1 ;;
|
|
||||||
*)
|
|
||||||
files+=("$1"); shift ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# ── Validar que se paso al menos un archivo ─────────────────────────────────
|
|
||||||
if [[ ${#files[@]} -eq 0 ]]; then
|
|
||||||
echo "ERROR: se requiere al menos un archivo .mitm como argumento" >&2
|
|
||||||
echo "Uso: query_mitm_flows <file_or_glob> [--filter EXPR] [--har OUT] [--mitmdump BIN]" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Verificar que cada archivo existe ──────────────────────────────────────
|
|
||||||
local -a valid_files=()
|
|
||||||
for f in "${files[@]}"; do
|
|
||||||
if [[ -f "$f" ]]; then
|
|
||||||
valid_files+=("$f")
|
|
||||||
else
|
|
||||||
echo "ERROR: archivo no encontrado: $f" >&2
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ ${#valid_files[@]} -eq 0 ]]; then
|
|
||||||
echo "ERROR: ningun archivo valido encontrado (el patron no matcheo nada)" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Autodetectar mitmdump ───────────────────────────────────────────────────
|
|
||||||
if [[ -z "$mitmdump_bin" ]]; then
|
|
||||||
if command -v mitmdump &>/dev/null; then
|
|
||||||
mitmdump_bin="mitmdump"
|
|
||||||
elif [[ -x "$HOME/.local/bin/mitmdump" ]]; then
|
|
||||||
mitmdump_bin="$HOME/.local/bin/mitmdump"
|
|
||||||
else
|
|
||||||
echo "ERROR: mitmdump no encontrado. Instala mitmproxy:" >&2
|
|
||||||
echo " pip install mitmproxy o pip install --user mitmproxy" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -x "$(command -v "$mitmdump_bin" 2>/dev/null || echo "$mitmdump_bin")" ]]; then
|
|
||||||
echo "ERROR: binario mitmdump no ejecutable: $mitmdump_bin" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Construir y ejecutar el comando ────────────────────────────────────────
|
|
||||||
local -a cmd=("$mitmdump_bin" -n)
|
|
||||||
|
|
||||||
# Añadir todos los archivos de entrada con -r
|
|
||||||
for f in "${valid_files[@]}"; do
|
|
||||||
cmd+=(-r "$f")
|
|
||||||
done
|
|
||||||
|
|
||||||
# Modo HAR
|
|
||||||
if [[ -n "$har_out" ]]; then
|
|
||||||
cmd+=(--set "hardump=${har_out}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Filtro de flujos (argumento posicional al final)
|
|
||||||
if [[ -n "$filter" ]]; then
|
|
||||||
cmd+=("$filter")
|
|
||||||
fi
|
|
||||||
|
|
||||||
"${cmd[@]}"
|
|
||||||
local exit_code=$?
|
|
||||||
|
|
||||||
if [[ -n "$har_out" && $exit_code -eq 0 ]]; then
|
|
||||||
echo "HAR exportado a ${har_out}" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
return $exit_code
|
|
||||||
}
|
|
||||||
|
|
||||||
# Ejecutar si se llama directamente
|
|
||||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
||||||
query_mitm_flows "$@"
|
|
||||||
fi
|
|
||||||
@@ -38,7 +38,7 @@ if [[ -n "$matches" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Escanear repo especifico
|
# Escanear repo especifico
|
||||||
scan_secrets_in_dirty $HOME/fn_registry
|
scan_secrets_in_dirty /home/lucas/fn_registry
|
||||||
```
|
```
|
||||||
|
|
||||||
## Patrones detectados
|
## Patrones detectados
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
---
|
|
||||||
name: start_mitm_capture
|
|
||||||
kind: function
|
|
||||||
lang: bash
|
|
||||||
domain: cybersecurity
|
|
||||||
version: "1.0.0"
|
|
||||||
purity: impure
|
|
||||||
signature: "start_mitm_capture([--port N] [--out DIR] [--rotate-min N] [--addon PATH] [--mitmdump BIN] [--log PATH]) -> string"
|
|
||||||
description: "Arranca mitmdump en modo headless en segundo plano como proxy de interceptación liviano, con rotación de capturas cada N minutos vía el addon rotate_capture_flows.py del registry. El proceso sobrevive al cierre de la shell (setsid). Emite JSON con PID, puerto, directorio de salida y ruta del log."
|
|
||||||
tags: [bash, cybersecurity, mitmproxy, proxy, capture, web-proxy, network, interception, mitmdump]
|
|
||||||
uses_functions: []
|
|
||||||
uses_types: []
|
|
||||||
returns: []
|
|
||||||
returns_optional: false
|
|
||||||
error_type: "error_go_core"
|
|
||||||
imports: []
|
|
||||||
params:
|
|
||||||
- name: "--port N"
|
|
||||||
desc: "Puerto TCP donde mitmdump escucha conexiones del cliente proxy. Default: 8080."
|
|
||||||
- name: "--out DIR"
|
|
||||||
desc: "Directorio donde se guardan los archivos .mitm rotados. Se crea automáticamente si no existe. Default: $HOME/captures."
|
|
||||||
- name: "--rotate-min N"
|
|
||||||
desc: "Minutos de duración de cada archivo de captura antes de rotar. Default: 20."
|
|
||||||
- name: "--addon PATH"
|
|
||||||
desc: "Ruta al addon Python de rotación (rotate_capture_flows.py). Default: derivado desde FN_REGISTRY_ROOT o desde la ubicación del propio script (3 niveles arriba)."
|
|
||||||
- name: "--mitmdump BIN"
|
|
||||||
desc: "Ruta al binario mitmdump. Default: autodetectado con command -v mitmdump, luego $HOME/.local/bin/mitmdump. Si no se encuentra, falla con instrucción de instalación."
|
|
||||||
- name: "--log PATH"
|
|
||||||
desc: "Archivo de log del proceso mitmdump. Default: <out>/mitmdump.log."
|
|
||||||
output: "JSON en stdout: {\"pid\": <pid>, \"port\": <port>, \"out_dir\": \"<dir>\", \"rotate_min\": <n>, \"log\": \"<log>\"}. Exit 1 en error (binario no encontrado, addon ausente, proceso muerto al arrancar)."
|
|
||||||
tested: false
|
|
||||||
tests: []
|
|
||||||
test_file_path: ""
|
|
||||||
file_path: "bash/functions/cybersecurity/start_mitm_capture.sh"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ejemplo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
source bash/functions/cybersecurity/start_mitm_capture.sh
|
|
||||||
|
|
||||||
# Arranque con defaults (puerto 8080, capturas en ~/captures, rotación cada 20 min)
|
|
||||||
start_mitm_capture --port 8080 --out /home/enmanuel/captures --rotate-min 20
|
|
||||||
|
|
||||||
# Salida esperada:
|
|
||||||
# {"pid": 123456, "port": 8080, "out_dir": "/home/enmanuel/captures", "rotate_min": 20, "log": "/home/enmanuel/captures/mitmdump.log"}
|
|
||||||
|
|
||||||
# Puerto alternativo y rotación más frecuente
|
|
||||||
start_mitm_capture --port 9090 --out /tmp/mitm_session --rotate-min 5
|
|
||||||
|
|
||||||
# Pasando addon y binario explícitos
|
|
||||||
start_mitm_capture \
|
|
||||||
--port 8080 \
|
|
||||||
--out /home/enmanuel/captures \
|
|
||||||
--addon /home/enmanuel/fn_registry/python/functions/cybersecurity/rotate_capture_flows.py \
|
|
||||||
--mitmdump /home/enmanuel/.local/bin/mitmdump
|
|
||||||
|
|
||||||
# Leer el PID para poder parar el proceso más adelante
|
|
||||||
info=$(start_mitm_capture --port 8080 --out /home/enmanuel/captures)
|
|
||||||
pid=$(echo "$info" | python3 -c "import sys,json; print(json.load(sys.stdin)['pid'])")
|
|
||||||
echo "Proxy corriendo con PID $pid"
|
|
||||||
# Para parar: kill "$pid"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cuando usarla
|
|
||||||
|
|
||||||
Cuando necesites un proxy de interceptación pasivo siempre activo en segundo plano que capture y rote el tráfico HTTP/HTTPS de forma continua sin supervisión manual. Úsala antes de lanzar pruebas de integración, sesiones de auditoría web o scraping supervisado donde quieras replay o análisis posterior de las capturas `.mitm`.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **HTTPS requiere instalar el CA de mitmproxy en el cliente.** El certificado raíz está en `~/.mitmproxy/mitmproxy-ca-cert.pem` tras el primer arranque. En navegadores: importar como CA de confianza. En curl/httpx: `--cacert ~/.mitmproxy/mitmproxy-ca-cert.pem`. En Chrome/Chromium headless: `--ignore-certificate-errors` (solo para pruebas).
|
|
||||||
- **El proceso queda en background.** Para pararlo: `kill <pid>` (el PID se devuelve en el JSON) o `port_kill 8080` si tienes la función del registry disponible.
|
|
||||||
- **El addon rotate_capture_flows.py debe existir.** La función lo busca en la raíz del registry derivada automáticamente; si el registry está en una ubicación no estándar, pasa `--addon` explícitamente o setea `FN_REGISTRY_ROOT`.
|
|
||||||
- **setsid desacopla el proceso de la shell.** Si cierras la terminal, mitmdump sigue corriendo. Necesitas matar el PID manualmente o via systemd/supervisor si quieres ciclo de vida gestionado.
|
|
||||||
- **El log crece sin límite.** El log de mitmdump no rota; solo rotan los archivos `.mitm` de capturas. Monitoriza el tamaño de `<out>/mitmdump.log` en sesiones largas.
|
|
||||||
- **Solo funciona con mitmdump >= 9.x** (API de addons `--set` con parámetros por nombre). Versiones anteriores usan sintaxis distinta.
|
|
||||||
|
|
||||||
## Notas
|
|
||||||
|
|
||||||
La derivación de la raíz del registry cuando `FN_REGISTRY_ROOT` no está seteado asciende 3 niveles desde `bash/functions/cybersecurity/` hasta la raíz usando `dirname "${BASH_SOURCE[0]}"`. Esto funciona siempre que el script se sourcee desde su ubicación original en el registry.
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# start_mitm_capture — Arranca mitmdump en segundo plano como proxy de interceptación
|
|
||||||
# con rotación de capturas cada N minutos vía addon Python del registry.
|
|
||||||
|
|
||||||
start_mitm_capture() {
|
|
||||||
local port=8080
|
|
||||||
local out_dir="$HOME/captures"
|
|
||||||
local rotate_min=20
|
|
||||||
local addon_path=""
|
|
||||||
local mitmdump_bin=""
|
|
||||||
local log_path=""
|
|
||||||
|
|
||||||
# Parseo de argumentos
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--port) port="$2"; shift 2 ;;
|
|
||||||
--out) out_dir="$2"; shift 2 ;;
|
|
||||||
--rotate-min) rotate_min="$2"; shift 2 ;;
|
|
||||||
--addon) addon_path="$2"; shift 2 ;;
|
|
||||||
--mitmdump) mitmdump_bin="$2"; shift 2 ;;
|
|
||||||
--log) log_path="$2"; shift 2 ;;
|
|
||||||
*)
|
|
||||||
echo "ERROR: argumento desconocido: $1" >&2
|
|
||||||
echo "Uso: start_mitm_capture [--port N] [--out DIR] [--rotate-min N] [--addon PATH] [--mitmdump BIN] [--log PATH]" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# Derivar raíz del registry cuando FN_REGISTRY_ROOT no está seteado
|
|
||||||
local registry_root
|
|
||||||
if [[ -n "${FN_REGISTRY_ROOT:-}" ]]; then
|
|
||||||
registry_root="$FN_REGISTRY_ROOT"
|
|
||||||
else
|
|
||||||
# bash/functions/cybersecurity/ -> 3 niveles arriba = raíz del registry
|
|
||||||
registry_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Default addon usando la raíz derivada
|
|
||||||
if [[ -z "$addon_path" ]]; then
|
|
||||||
addon_path="${registry_root}/python/functions/cybersecurity/rotate_capture_flows.py"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Default log path (depende de out_dir, se resuelve después de crear el dir)
|
|
||||||
# Se asigna más abajo si sigue vacío
|
|
||||||
|
|
||||||
# Crear directorio de capturas si no existe
|
|
||||||
if [[ ! -d "$out_dir" ]]; then
|
|
||||||
mkdir -p "$out_dir" || {
|
|
||||||
echo "ERROR: no se pudo crear el directorio de capturas: $out_dir" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Asignar log por defecto ahora que out_dir existe
|
|
||||||
if [[ -z "$log_path" ]]; then
|
|
||||||
log_path="${out_dir}/mitmdump.log"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Resolver binario mitmdump
|
|
||||||
if [[ -z "$mitmdump_bin" ]]; then
|
|
||||||
if command -v mitmdump &>/dev/null; then
|
|
||||||
mitmdump_bin="$(command -v mitmdump)"
|
|
||||||
elif [[ -x "$HOME/.local/bin/mitmdump" ]]; then
|
|
||||||
mitmdump_bin="$HOME/.local/bin/mitmdump"
|
|
||||||
else
|
|
||||||
echo "ERROR: mitmdump no encontrado; instala con: uv tool install mitmproxy" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if [[ ! -x "$mitmdump_bin" ]]; then
|
|
||||||
echo "ERROR: el binario indicado no existe o no es ejecutable: $mitmdump_bin" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verificar que el addon existe
|
|
||||||
if [[ ! -f "$addon_path" ]]; then
|
|
||||||
echo "ERROR: addon no encontrado: $addon_path" >&2
|
|
||||||
echo " Asegúrate de que FN_REGISTRY_ROOT apunta a la raíz del registry" >&2
|
|
||||||
echo " o pasa --addon con la ruta correcta a rotate_capture_flows.py" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Arrancar mitmdump en background con setsid (sobrevive al cierre de la shell).
|
|
||||||
# El redirect </dev/null desacopla stdin: sin él, el proceso retiene el pipe
|
|
||||||
# heredado y la shell sale con exit 144 (SIGTERM) al cerrarse. disown lo saca
|
|
||||||
# de la tabla de jobs para que la shell no le envíe señales al terminar.
|
|
||||||
setsid "$mitmdump_bin" \
|
|
||||||
-s "$addon_path" \
|
|
||||||
--set "rotate_min=${rotate_min}" \
|
|
||||||
--set "capture_dir=${out_dir}" \
|
|
||||||
--listen-port "$port" \
|
|
||||||
</dev/null >> "$log_path" 2>&1 &
|
|
||||||
local pid=$!
|
|
||||||
disown "$pid" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Esperar ~1s y verificar que el proceso sigue vivo
|
|
||||||
sleep 1
|
|
||||||
if ! kill -0 "$pid" 2>/dev/null; then
|
|
||||||
echo "ERROR: mitmdump murió al arrancar. Últimas líneas del log:" >&2
|
|
||||||
tail -20 "$log_path" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Emitir JSON con información del proceso arrancado
|
|
||||||
printf '{"pid": %d, "port": %d, "out_dir": "%s", "rotate_min": %d, "log": "%s"}\n' \
|
|
||||||
"$pid" "$port" "$out_dir" "$rotate_min" "$log_path"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Ejecutar si se llama directamente (fn run / bash <file>)
|
|
||||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
||||||
start_mitm_capture "$@"
|
|
||||||
fi
|
|
||||||
@@ -22,14 +22,14 @@ params:
|
|||||||
- name: app_name
|
- name: app_name
|
||||||
desc: "Nombre de la app (ej: chart_demo). Se usa para localizar cpp/build/windows/apps/<app>/<app>.exe y el directorio destino Desktop/apps/<app>/."
|
desc: "Nombre de la app (ej: chart_demo). Se usa para localizar cpp/build/windows/apps/<app>/<app>.exe y el directorio destino Desktop/apps/<app>/."
|
||||||
- name: app_dir
|
- name: app_dir
|
||||||
desc: "Ruta absoluta al directorio fuente de la app (ej: $HOME/fn_registry/cpp/apps/chart_demo). Se usa para localizar enrichers/, runtime/ y app.md."
|
desc: "Ruta absoluta al directorio fuente de la app (ej: /home/lucas/fn_registry/cpp/apps/chart_demo). Se usa para localizar enrichers/, runtime/ y app.md."
|
||||||
output: "Copia archivos al escritorio de Windows. Imprime 'OK: <app> -> <dest>' en stdout. Si local_files/ existe, imprime su tamanio. Errores fatales a stderr con exit 1."
|
output: "Copia archivos al escritorio de Windows. Imprime 'OK: <app> -> <dest>' en stdout. Si local_files/ existe, imprime su tamanio. Errores fatales a stderr con exit 1."
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ejemplo
|
## Ejemplo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
deploy_cpp_exe_to_windows "chart_demo" "$HOME/fn_registry/cpp/apps/chart_demo"
|
deploy_cpp_exe_to_windows "chart_demo" "/home/lucas/fn_registry/cpp/apps/chart_demo"
|
||||||
# OK: chart_demo -> /mnt/c/Users/lucas/Desktop/apps/chart_demo
|
# OK: chart_demo -> /mnt/c/Users/lucas/Desktop/apps/chart_demo
|
||||||
|
|
||||||
# Con rutas custom via env vars
|
# Con rutas custom via env vars
|
||||||
@@ -55,7 +55,7 @@ Desktop/apps/<APP>/
|
|||||||
|
|
||||||
- `BUILD_WIN` — directorio de build Windows; default `$FN_REGISTRY_ROOT/cpp/build/windows`
|
- `BUILD_WIN` — directorio de build Windows; default `$FN_REGISTRY_ROOT/cpp/build/windows`
|
||||||
- `WIN_DESKTOP_APPS` — directorio destino; default `/mnt/c/Users/lucas/Desktop/apps`
|
- `WIN_DESKTOP_APPS` — directorio destino; default `/mnt/c/Users/lucas/Desktop/apps`
|
||||||
- `FN_REGISTRY_ROOT` — raiz del registry; default `$HOME/fn_registry`
|
- `FN_REGISTRY_ROOT` — raiz del registry; default `/home/lucas/fn_registry`
|
||||||
|
|
||||||
## Notas
|
## Notas
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ deploy_cpp_exe_to_windows() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local root="${FN_REGISTRY_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}"
|
local root="${FN_REGISTRY_ROOT:-/home/lucas/fn_registry}"
|
||||||
local build_win="${BUILD_WIN:-$root/cpp/build/windows}"
|
local build_win="${BUILD_WIN:-$root/cpp/build/windows}"
|
||||||
local win_desktop_apps="${WIN_DESKTOP_APPS:-/mnt/c/Users/lucas/Desktop/apps}"
|
local win_desktop_apps="${WIN_DESKTOP_APPS:-/mnt/c/Users/lucas/Desktop/apps}"
|
||||||
|
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
---
|
|
||||||
name: deploy_wails_exe_to_windows
|
|
||||||
kind: function
|
|
||||||
lang: bash
|
|
||||||
domain: infra
|
|
||||||
version: "0.1.0"
|
|
||||||
purity: impure
|
|
||||||
signature: "deploy_wails_exe_to_windows <app_name> <app_dir>"
|
|
||||||
description: "Copia el .exe de una app Wails desde <app_dir>/build/bin/<app>.exe al escritorio de Windows, mata el proceso anterior (taskkill /F) y relanza la app via cmd.exe. Single-binary: no copia DLLs (Webview2 nativo en SO). Preserva local_files/ si existe."
|
|
||||||
tags: ["wails", "windows", "deploy", "cross-compile", "mingw", "infra", "launch", "matrix-mas"]
|
|
||||||
params:
|
|
||||||
- name: app_name
|
|
||||||
desc: "Nombre del binario sin extension (ej. matrix_client_pc). Debe coincidir con el nombre del .exe generado por wails build."
|
|
||||||
- name: app_dir
|
|
||||||
desc: "Ruta absoluta al directorio raiz de la app, donde vive build/bin/<app>.exe. Puede estar en projects/<project>/apps/<app>/ o apps/<app>/."
|
|
||||||
output: "Imprime pasos en stderr. En stdout: ls -lh del .exe desplegado. Exit 0 si ok, exit 1 si build/bin/<app>.exe no existe o los args estan vacios."
|
|
||||||
uses_functions: []
|
|
||||||
uses_types: []
|
|
||||||
returns: []
|
|
||||||
returns_optional: false
|
|
||||||
error_type: "error_go_core"
|
|
||||||
imports: []
|
|
||||||
tested: true
|
|
||||||
tests:
|
|
||||||
- "args vacios devuelven error con mensaje de uso"
|
|
||||||
- "app_dir inexistente devuelve exit 1"
|
|
||||||
- "build/bin exe inexistente devuelve exit 1"
|
|
||||||
test_file_path: "bash/functions/infra/deploy_wails_exe_to_windows_test.sh"
|
|
||||||
file_path: "bash/functions/infra/deploy_wails_exe_to_windows.sh"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ejemplo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
source bash/functions/infra/deploy_wails_exe_to_windows.sh
|
|
||||||
|
|
||||||
# Desplegar matrix_client_pc tras wails build -platform windows/amd64
|
|
||||||
deploy_wails_exe_to_windows matrix_client_pc \
|
|
||||||
$HOME/fn_registry/projects/element_agents/apps/matrix_client_pc
|
|
||||||
```
|
|
||||||
|
|
||||||
Con override de destino:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
WIN_DESKTOP_APPS=/mnt/c/Users/lucas/Desktop/apps \
|
|
||||||
deploy_wails_exe_to_windows matrix_admin_panel \
|
|
||||||
$HOME/fn_registry/projects/element_agents/apps/matrix_admin_panel
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cuando usarla
|
|
||||||
|
|
||||||
Tras un `wails build -platform windows/amd64` exitoso, para desplegar el binario compilado en Windows y relanzarlo en el mismo paso. Ideal en el ciclo de iteracion rapida: compilar → desplegar → ver cambios. Equivalente a `deploy_cpp_exe_to_windows_bash_infra` pero para apps Wails (single-binary sin DLLs extras).
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **taskkill /F fuerza muerte** sin permitir guardado en disco. Las apps Wails persisten estado en keyring de Windows y AppData — este kill es seguro para ellas. Si la app tuviera autosave en progreso, se perderia (aceptable en ciclos de dev).
|
|
||||||
- **UNC paths prohibidos en cmd.exe**: `cmd.exe /c start` debe ejecutarse con `cd` previo al directorio Windows (`/mnt/c/...`). Intentar lanzar con path `\\wsl.localhost\...` falla con "UNC paths are not supported as the current directory".
|
|
||||||
- **cmd.exe start no bloquea**: la funcion espera 3s y verifica via `tasklist.exe`. Si la app cierra sola tras el arranque (error de inicio), el warn final lo indica pero no causa exit 1. Revisar logs en `%APPDATA%\<app>\` o `%LOCALAPPDATA%\<app>\`.
|
|
||||||
- **Single-binary Wails**: no copiar DLLs. Webview2 es nativo del SO (Windows 10+ ya lo incluye). Si una version vieja de Windows no tuviera Webview2, la app falla al arrancar — solucion: instalar Webview2 Runtime en esa maquina.
|
|
||||||
- **Build previo es responsabilidad del caller**: esta funcion NO compila. Para matrix_client_pc usa `-tags goolm` por el crypto de Matrix: `wails build -platform windows/amd64 -tags goolm`.
|
|
||||||
- **WIN_DESKTOP_APPS override**: variable de entorno para cambiar el destino. Util en CI o maquinas con escritorio en otra ruta.
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# deploy_wails_exe_to_windows — Copia el .exe de una app Wails compilado en
|
|
||||||
# <app_dir>/build/bin/<app>.exe al escritorio de Windows, mata el proceso
|
|
||||||
# anterior y relanza la app. Single-binary: no copia DLLs (Webview2 nativo SO).
|
|
||||||
# Pre-authorized: taskkill.exe /F — idempotente, sin prompt.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
deploy_wails_exe_to_windows() {
|
|
||||||
local app="${1:-}"
|
|
||||||
local app_dir="${2:-}"
|
|
||||||
|
|
||||||
if [ -z "$app" ] || [ -z "$app_dir" ]; then
|
|
||||||
echo "ERROR: uso: deploy_wails_exe_to_windows <app_name> <app_dir>" >&2
|
|
||||||
echo " app_name: nombre del binario sin extension (ej. matrix_client_pc)" >&2
|
|
||||||
echo " app_dir: ruta absoluta al directorio de la app (donde vive build/bin/)" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
local win_desktop_apps="${WIN_DESKTOP_APPS:-/mnt/c/Users/lucas/Desktop/apps}"
|
|
||||||
|
|
||||||
# --- 1. Validar que el .exe existe ---
|
|
||||||
local exe_src="${app_dir}/build/bin/${app}.exe"
|
|
||||||
if [ ! -f "$exe_src" ]; then
|
|
||||||
echo "ERROR: no se encontro $exe_src" >&2
|
|
||||||
echo "Compila primero con: wails build -platform windows/amd64" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- 2. Crear directorio destino (preserva local_files/ si existe) ---
|
|
||||||
local dest="${win_desktop_apps}/${app}"
|
|
||||||
mkdir -p "$dest"
|
|
||||||
echo "[deploy_wails] dest: $dest" >&2
|
|
||||||
|
|
||||||
# --- 3. Matar proceso si esta corriendo en Windows ---
|
|
||||||
# Pre-authorized. Wails apps usan AppData+keyring para estado, kill /F es seguro.
|
|
||||||
if command -v taskkill.exe >/dev/null 2>&1; then
|
|
||||||
echo "[deploy_wails] matando ${app}.exe si corre..." >&2
|
|
||||||
taskkill.exe /IM "${app}.exe" /F 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- 4. Esperar a que Windows libere el file handle ---
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
# --- 5. Copiar .exe (cp -f: overwrite sin borrar el directorio) ---
|
|
||||||
echo "[deploy_wails] copiando ${app}.exe..." >&2
|
|
||||||
cp -f "$exe_src" "$dest/${app}.exe"
|
|
||||||
|
|
||||||
# --- 6. Copiar appicon.ico si existe (opcional, algunos hubs lo leen) ---
|
|
||||||
local icon_src="${app_dir}/appicon.ico"
|
|
||||||
if [ -f "$icon_src" ]; then
|
|
||||||
echo "[deploy_wails] copiando appicon.ico..." >&2
|
|
||||||
cp -f "$icon_src" "$dest/appicon.ico"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- 7. Relanzar la app desde su dir Windows ---
|
|
||||||
# Usar cmd.exe /c start desde el dir destino (no UNC paths — falla en cmd.exe).
|
|
||||||
echo "[deploy_wails] lanzando ${app}.exe..." >&2
|
|
||||||
(
|
|
||||||
cd "$dest"
|
|
||||||
cmd.exe /c start "" "${app}.exe"
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- 8. Dar tiempo a que el proceso arranque ---
|
|
||||||
sleep 3
|
|
||||||
|
|
||||||
# --- 9. Verificar que el proceso esta corriendo ---
|
|
||||||
if command -v tasklist.exe >/dev/null 2>&1; then
|
|
||||||
local tasklist_out
|
|
||||||
tasklist_out=$(tasklist.exe /FI "IMAGENAME eq ${app}.exe" /NH 2>/dev/null || true)
|
|
||||||
if echo "$tasklist_out" | grep -qi "^${app}.exe"; then
|
|
||||||
local pid
|
|
||||||
pid=$(echo "$tasklist_out" | grep -i "^${app}.exe" | awk '{print $2}' | head -n1)
|
|
||||||
echo "[deploy_wails] ${app}.exe corriendo con PID $pid" >&2
|
|
||||||
else
|
|
||||||
echo "WARN: ${app}.exe no aparece en tasklist tras el lanzamiento." >&2
|
|
||||||
echo " Puede que la app cerro con error. Revisar AppData para logs." >&2
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- 10. Resumen final en stdout ---
|
|
||||||
ls -lh "$dest/${app}.exe"
|
|
||||||
|
|
||||||
echo "[deploy_wails] OK: ${app} deployado en $dest" >&2
|
|
||||||
if [ -d "$dest/local_files" ]; then
|
|
||||||
echo "[deploy_wails] local_files/ preservado: $(du -sh "$dest/local_files" | cut -f1)" >&2
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
|
|
||||||
deploy_wails_exe_to_windows "$@"
|
|
||||||
fi
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Tests para deploy_wails_exe_to_windows
|
|
||||||
# Solo prueba validacion de argumentos y rutas — no ejecuta taskkill/cmd.exe reales.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
source "$SCRIPT_DIR/deploy_wails_exe_to_windows.sh"
|
|
||||||
|
|
||||||
PASS=0
|
|
||||||
FAIL=0
|
|
||||||
|
|
||||||
assert_eq() {
|
|
||||||
local test_name="$1" expected="$2" got="$3"
|
|
||||||
if [[ "$expected" == "$got" ]]; then
|
|
||||||
echo "PASS: $test_name"
|
|
||||||
PASS=$((PASS + 1))
|
|
||||||
else
|
|
||||||
echo "FAIL: $test_name — expected '$expected', got '$got'"
|
|
||||||
FAIL=$((FAIL + 1))
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Test 1: args vacios devuelven error con mensaje de uso ---
|
|
||||||
actual_exit=0
|
|
||||||
deploy_wails_exe_to_windows >/dev/null 2>&1 || actual_exit=$?
|
|
||||||
assert_eq "args vacios devuelven error con mensaje de uso" "1" "$actual_exit"
|
|
||||||
|
|
||||||
# --- Test 2: app_dir inexistente devuelve exit 1 ---
|
|
||||||
actual_exit=0
|
|
||||||
deploy_wails_exe_to_windows "myapp" "/tmp/nonexistent_dir_$(date +%s)" >/dev/null 2>&1 || actual_exit=$?
|
|
||||||
assert_eq "app_dir inexistente devuelve exit 1" "1" "$actual_exit"
|
|
||||||
|
|
||||||
# --- Test 3: build/bin exe inexistente devuelve exit 1 ---
|
|
||||||
TMPDIR_APP=$(mktemp -d)
|
|
||||||
# Crear estructura de dir de app pero SIN el exe
|
|
||||||
mkdir -p "$TMPDIR_APP/build/bin"
|
|
||||||
actual_exit=0
|
|
||||||
deploy_wails_exe_to_windows "myapp" "$TMPDIR_APP" >/dev/null 2>&1 || actual_exit=$?
|
|
||||||
rm -rf "$TMPDIR_APP"
|
|
||||||
assert_eq "build/bin exe inexistente devuelve exit 1" "1" "$actual_exit"
|
|
||||||
|
|
||||||
echo "---"
|
|
||||||
echo "Results: $PASS passed, $FAIL failed"
|
|
||||||
[[ $FAIL -eq 0 ]] || exit 1
|
|
||||||
@@ -30,15 +30,15 @@ file_path: "bash/functions/infra/discover_git_repos.sh"
|
|||||||
source bash/functions/infra/discover_git_repos.sh
|
source bash/functions/infra/discover_git_repos.sh
|
||||||
|
|
||||||
# Listar todos los repos bajo fn_registry
|
# Listar todos los repos bajo fn_registry
|
||||||
discover_git_repos $HOME/fn_registry
|
discover_git_repos /home/lucas/fn_registry
|
||||||
|
|
||||||
# Contar repos
|
# Contar repos
|
||||||
discover_git_repos $HOME/fn_registry | wc -l
|
discover_git_repos /home/lucas/fn_registry | wc -l
|
||||||
|
|
||||||
# Iterar
|
# Iterar
|
||||||
while IFS= read -r repo; do
|
while IFS= read -r repo; do
|
||||||
echo "Repo: $repo"
|
echo "Repo: $repo"
|
||||||
done < <(discover_git_repos $HOME/fn_registry)
|
done < <(discover_git_repos /home/lucas/fn_registry)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notas
|
## Notas
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ file_path: "bash/functions/infra/docker_cp_file.sh"
|
|||||||
```bash
|
```bash
|
||||||
source functions/infra/docker_cp_file.sh
|
source functions/infra/docker_cp_file.sh
|
||||||
|
|
||||||
result=$(docker_cp_file $HOME/fn_registry/registry.db metabase /registry.db)
|
result=$(docker_cp_file /home/lucas/fn_registry/registry.db metabase /registry.db)
|
||||||
echo "$result"
|
echo "$result"
|
||||||
# {"local_size":524288,"remote_size":524288}
|
# {"local_size":524288,"remote_size":524288}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ file_path: "bash/functions/infra/git_auto_commit_dirty.sh"
|
|||||||
source bash/functions/infra/git_auto_commit_dirty.sh
|
source bash/functions/infra/git_auto_commit_dirty.sh
|
||||||
|
|
||||||
# Commitear con mensaje automatico
|
# Commitear con mensaje automatico
|
||||||
subject=$(git_auto_commit_dirty $HOME/fn_registry)
|
subject=$(git_auto_commit_dirty /home/lucas/fn_registry)
|
||||||
echo "Commit: $subject"
|
echo "Commit: $subject"
|
||||||
|
|
||||||
# Commitear con mensaje fijo
|
# Commitear con mensaje fijo
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ error_type: "error_go_core"
|
|||||||
imports: []
|
imports: []
|
||||||
example: |
|
example: |
|
||||||
# Manual check
|
# Manual check
|
||||||
bash bash/functions/infra/git_hook_audit_app_drift.sh $HOME/fn_registry/apps/kanban
|
bash bash/functions/infra/git_hook_audit_app_drift.sh /home/lucas/fn_registry/apps/kanban
|
||||||
|
|
||||||
# Used by pre_commit_hook_install_bash_infra (v2 hook chain)
|
# Used by pre_commit_hook_install_bash_infra (v2 hook chain)
|
||||||
file_path: bash/functions/infra/git_hook_audit_app_drift.sh
|
file_path: bash/functions/infra/git_hook_audit_app_drift.sh
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ file_path: "bash/functions/infra/git_pull_with_stash.sh"
|
|||||||
source bash/functions/infra/git_pull_with_stash.sh
|
source bash/functions/infra/git_pull_with_stash.sh
|
||||||
|
|
||||||
# Pullear repo con auto-stash
|
# Pullear repo con auto-stash
|
||||||
status=$(git_pull_with_stash $HOME/fn_registry)
|
status=$(git_pull_with_stash /home/lucas/fn_registry)
|
||||||
echo "$status"
|
echo "$status"
|
||||||
# [pulled] fn_registry
|
# [pulled] fn_registry
|
||||||
# o:
|
# o:
|
||||||
@@ -46,7 +46,7 @@ while IFS= read -r repo; do
|
|||||||
if [[ "$result" == "[diverged]"* || "$result" == "[stash-conflict]"* ]]; then
|
if [[ "$result" == "[diverged]"* || "$result" == "[stash-conflict]"* ]]; then
|
||||||
diverged+=("$result")
|
diverged+=("$result")
|
||||||
fi
|
fi
|
||||||
done < <(discover_git_repos $HOME/fn_registry)
|
done < <(discover_git_repos /home/lucas/fn_registry)
|
||||||
|
|
||||||
if [[ ${#diverged[@]} -gt 0 ]]; then
|
if [[ ${#diverged[@]} -gt 0 ]]; then
|
||||||
echo "ATENCION: repos que requieren intervencion manual:"
|
echo "ATENCION: repos que requieren intervencion manual:"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ file_path: "bash/functions/infra/git_push_if_ahead.sh"
|
|||||||
source bash/functions/infra/git_push_if_ahead.sh
|
source bash/functions/infra/git_push_if_ahead.sh
|
||||||
|
|
||||||
# Pushear si hay commits locales
|
# Pushear si hay commits locales
|
||||||
status=$(git_push_if_ahead $HOME/fn_registry)
|
status=$(git_push_if_ahead /home/lucas/fn_registry)
|
||||||
echo "$status"
|
echo "$status"
|
||||||
# [push] fn_registry (master, 3 commits ahead)
|
# [push] fn_registry (master, 3 commits ahead)
|
||||||
# o:
|
# o:
|
||||||
@@ -39,7 +39,7 @@ echo "$status"
|
|||||||
# Iterar sobre multiples repos
|
# Iterar sobre multiples repos
|
||||||
while IFS= read -r repo; do
|
while IFS= read -r repo; do
|
||||||
git_push_if_ahead "$repo"
|
git_push_if_ahead "$repo"
|
||||||
done < <(discover_git_repos $HOME/fn_registry)
|
done < <(discover_git_repos /home/lucas/fn_registry)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Estados de salida
|
## Estados de salida
|
||||||
|
|||||||
@@ -51,14 +51,7 @@ git_push_if_ahead() {
|
|||||||
echo "[push] $repo_name ($branch, $ahead commits ahead)" >&2
|
echo "[push] $repo_name ($branch, $ahead commits ahead)" >&2
|
||||||
local push_out
|
local push_out
|
||||||
push_out=$(git -C "$abs_repo" push origin "$branch" 2>&1) || {
|
push_out=$(git -C "$abs_repo" push origin "$branch" 2>&1) || {
|
||||||
# Preservar las lineas con los keywords que el orquestador usa para
|
echo "[error] $repo_name: $(echo "$push_out" | tail -1)"
|
||||||
# decidir el auto-recover (rejected / fast-forward / non-fast-forward).
|
|
||||||
# Un `tail -1` plano se quedaba con la linea final de `hint:` y perdia
|
|
||||||
# "[rejected]" + "Updates were rejected", impidiendo el recover.
|
|
||||||
local reason
|
|
||||||
reason=$(echo "$push_out" | grep -iE 'rejected|fast-forward|denied|permission|error:' | head -3 | tr '\n' ' ')
|
|
||||||
[[ -z "$reason" ]] && reason=$(echo "$push_out" | tail -1)
|
|
||||||
echo "[error] $repo_name: $reason"
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
echo "$push_out" | tail -3 >&2
|
echo "$push_out" | tail -3 >&2
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
---
|
|
||||||
name: jupyter_mcp_serve
|
|
||||||
kind: function
|
|
||||||
lang: bash
|
|
||||||
domain: infra
|
|
||||||
version: 1.0.0
|
|
||||||
purity: impure
|
|
||||||
error_type: "error_go_core"
|
|
||||||
signature: "jupyter_mcp_serve.sh [--dry-run]"
|
|
||||||
description: "Arranca (o reusa) un Jupyter Lab colaborativo en un puerto propio y lanza el Jupyter MCP server enganchado por stdio. Entrypoint robusto para la entrada 'jupyter' de .mcp.json: garantiza que el MCP SIEMPRE tiene servidor al que conectarse, sin depender de que haya un jupyter en 8888."
|
|
||||||
tags: [notebook, jupyter, mcp, infra, launcher-glue]
|
|
||||||
uses_functions: []
|
|
||||||
uses_types: []
|
|
||||||
params:
|
|
||||||
- name: "--dry-run"
|
|
||||||
desc: "Opcional. Arranca/verifica jupyter pero NO hace exec del MCP; loguea el comando elegido. Para tests."
|
|
||||||
output: "Proceso jupyter-mcp-server enganchado por stdio a un Jupyter Lab colaborativo local (127.0.0.1, puerto JUPYTER_MCP_PORT, default 8899). Logs en ~/.fn_jupyter_mcp/. stdout reservado al protocolo MCP."
|
|
||||||
---
|
|
||||||
|
|
||||||
## Que hace
|
|
||||||
|
|
||||||
El MCP de Jupyter (datalayer `jupyter-mcp-server`) **no arranca jupyter**, solo se
|
|
||||||
conecta a uno existente. Si la URL configurada no tiene jupyter detras, el MCP
|
|
||||||
nunca conecta. En esta maquina `localhost:8888` es el **proxy HTTP del contenedor
|
|
||||||
VPN gluetun**, no un jupyter — por eso el MCP fallaba siempre.
|
|
||||||
|
|
||||||
Este wrapper resuelve la cadena entera:
|
|
||||||
|
|
||||||
1. Localiza el venv (`python/.venv`) y los binarios `jupyter` + `jupyter-mcp-server`.
|
|
||||||
2. Si ya hay un jupyter gestionado vivo en `127.0.0.1:$PORT` (`/api/status` = 200) lo reusa.
|
|
||||||
3. Si no, arranca `jupyter lab` colaborativo detached (RTC via `jupyter-collaboration`),
|
|
||||||
en `JUPYTER_MCP_ROOT` (default = raiz del repo, asi cualquier notebook del arbol es lanzable).
|
|
||||||
4. Detecta el dialecto de CLI del MCP (`--document-url` nuevo / `--jupyter-url` viejo / env vars)
|
|
||||||
y hace `exec` del MCP por `--transport stdio`.
|
|
||||||
|
|
||||||
Self-adapting: funciona aunque cambie la version de `jupyter-mcp-server`.
|
|
||||||
|
|
||||||
## Ejemplo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Como lo usa Claude Code (entrada en .mcp.json):
|
|
||||||
# "jupyter": { "command": "bash", "args": ["bash/functions/infra/jupyter_mcp_serve.sh"] }
|
|
||||||
|
|
||||||
# Test manual (arranca jupyter en 8899, no lanza el MCP):
|
|
||||||
bash bash/functions/infra/jupyter_mcp_serve.sh --dry-run
|
|
||||||
curl -s http://127.0.0.1:8899/api/status # {"started":..., "version":...}
|
|
||||||
|
|
||||||
# Cambiar puerto / raiz de notebooks:
|
|
||||||
JUPYTER_MCP_PORT=8900 JUPYTER_MCP_ROOT=/home/enmanuel/fn_registry/analysis \
|
|
||||||
bash bash/functions/infra/jupyter_mcp_serve.sh --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cuando usarla
|
|
||||||
|
|
||||||
Cuando quieras que el MCP de Jupyter de Claude Code **siempre** tenga servidor:
|
|
||||||
es el `command` de la entrada `jupyter` en `.mcp.json`. No la invoques a mano salvo
|
|
||||||
para depurar (`--dry-run`) o para levantar el jupyter colaborativo sin el MCP.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **stdout reservado**: el MCP habla por stdout (protocolo stdio). El wrapper jamas
|
|
||||||
escribe a stdout — todo log va a stderr y `~/.fn_jupyter_mcp/wrapper.log`. No metas
|
|
||||||
`echo` a stdout aqui o rompes el handshake del MCP.
|
|
||||||
- **Puerto 8888 ocupado por gluetun** en esta maquina. Por eso el default es **8899**.
|
|
||||||
Si 8899 tambien se ocupa, exporta `JUPYTER_MCP_PORT`.
|
|
||||||
- **Token vacio**: solo escucha en `127.0.0.1` con `disable_check_xsrf` + `allow_origin '*'`.
|
|
||||||
Aceptable en local; NO exponer el puerto a la red.
|
|
||||||
- **venv requerido**: necesita `python/.venv` con `jupyterlab`, `jupyter-collaboration`
|
|
||||||
y `jupyter-mcp-server`. Reconstruir: `cd python && uv sync --extra jupyter`.
|
|
||||||
- El jupyter arrancado queda **detached** (nohup): persiste entre invocaciones del MCP.
|
|
||||||
Para pararlo: `python/.venv/bin/jupyter server stop 8899` o `pkill -f 'jupyter-lab.*8899'`.
|
|
||||||
|
|
||||||
## Capability growth log
|
|
||||||
|
|
||||||
v1.0.0 (2026-06-01) — version inicial. Wrapper auto-start: reusa/levanta jupyter
|
|
||||||
colaborativo en puerto propio (8899) y autodetecta el dialecto de CLI del MCP.
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# jupyter_mcp_serve — arranca (o reusa) un Jupyter Lab colaborativo y lanza el
|
|
||||||
# Jupyter MCP server enganchado a el por stdio. Pensado para ser el `command` de
|
|
||||||
# la entrada "jupyter" en .mcp.json: garantiza que el MCP SIEMPRE tiene servidor.
|
|
||||||
#
|
|
||||||
# Por que existe: el MCP datalayer NO arranca jupyter, solo se conecta. Si la URL
|
|
||||||
# apunta a un puerto sin jupyter (en esta maquina 8888 = proxy VPN gluetun), el
|
|
||||||
# MCP nunca conecta. Este wrapper levanta su propio jupyter en un puerto propio.
|
|
||||||
#
|
|
||||||
# Env overrides:
|
|
||||||
# JUPYTER_MCP_ROOT raiz de notebooks (default: raiz del repo)
|
|
||||||
# JUPYTER_MCP_PORT puerto del jupyter gestionado (default: 8899)
|
|
||||||
# JUPYTER_MCP_VENV venv (default: <repo>/python/.venv)
|
|
||||||
# JUPYTER_MCP_TOKEN token (default: "" — solo escucha en 127.0.0.1)
|
|
||||||
#
|
|
||||||
# stdout esta RESERVADO al protocolo stdio del MCP. Todo log va a stderr + LOGFILE.
|
|
||||||
# Nunca hacer echo a stdout aqui.
|
|
||||||
#
|
|
||||||
# Uso directo / test:
|
|
||||||
# bash jupyter_mcp_serve.sh --dry-run # arranca jupyter, NO exec del MCP, loguea args
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
DRY=0
|
|
||||||
[ "${1:-}" = "--dry-run" ] && DRY=1
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
# raiz del repo = tres niveles arriba de bash/functions/infra/
|
|
||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
|
||||||
|
|
||||||
VENV="${JUPYTER_MCP_VENV:-$REPO_ROOT/python/.venv}"
|
|
||||||
ROOT_DIR="${JUPYTER_MCP_ROOT:-$REPO_ROOT}"
|
|
||||||
PORT="${JUPYTER_MCP_PORT:-8899}"
|
|
||||||
HOST=127.0.0.1
|
|
||||||
TOKEN="${JUPYTER_MCP_TOKEN:-}"
|
|
||||||
LOGDIR="${HOME}/.fn_jupyter_mcp"
|
|
||||||
mkdir -p "$LOGDIR"
|
|
||||||
LOGFILE="$LOGDIR/wrapper.log"
|
|
||||||
JLOG="$LOGDIR/jupyterlab.log"
|
|
||||||
|
|
||||||
log(){ printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*" >>"$LOGFILE"; printf '%s\n' "$*" >&2; }
|
|
||||||
|
|
||||||
JUPYTER="$VENV/bin/jupyter"
|
|
||||||
MCP="$VENV/bin/jupyter-mcp-server"
|
|
||||||
|
|
||||||
if [ ! -x "$JUPYTER" ]; then
|
|
||||||
log "FATAL: $JUPYTER no existe. Instala: cd $REPO_ROOT/python && uv pip install --python .venv/bin/python3 jupyterlab jupyter-collaboration jupyter-mcp-server"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ ! -x "$MCP" ]; then
|
|
||||||
log "FATAL: $MCP no existe. Instala jupyter-mcp-server en el venv."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
server_up(){
|
|
||||||
local code
|
|
||||||
code="$(curl -s -m 3 -o /dev/null -w '%{http_code}' "http://$HOST:$PORT/api/status?token=$TOKEN" 2>/dev/null || true)"
|
|
||||||
[ "$code" = "200" ]
|
|
||||||
}
|
|
||||||
|
|
||||||
if server_up; then
|
|
||||||
log "reuso jupyter existente en $HOST:$PORT"
|
|
||||||
else
|
|
||||||
log "arranco jupyter colaborativo en $HOST:$PORT (root=$ROOT_DIR)"
|
|
||||||
nohup "$JUPYTER" lab \
|
|
||||||
--no-browser \
|
|
||||||
--ServerApp.ip="$HOST" \
|
|
||||||
--ServerApp.port="$PORT" \
|
|
||||||
--ServerApp.root_dir="$ROOT_DIR" \
|
|
||||||
--IdentityProvider.token="$TOKEN" \
|
|
||||||
--ServerApp.disable_check_xsrf=True \
|
|
||||||
--ServerApp.allow_origin='*' \
|
|
||||||
>>"$JLOG" 2>&1 &
|
|
||||||
disown 2>/dev/null || true
|
|
||||||
# esperar hasta ~30s a que levante
|
|
||||||
for _ in $(seq 1 60); do
|
|
||||||
server_up && break
|
|
||||||
sleep 0.5
|
|
||||||
done
|
|
||||||
if ! server_up; then
|
|
||||||
log "FATAL: jupyter no levanto en 30s. Ver $JLOG"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
log "jupyter arriba"
|
|
||||||
fi
|
|
||||||
|
|
||||||
BASE="http://$HOST:$PORT"
|
|
||||||
|
|
||||||
# Detectar el dialecto de CLI del MCP (cambia entre versiones de jupyter-mcp-server)
|
|
||||||
HELP="$("$MCP" --help 2>&1 || true)"
|
|
||||||
ARGS=(--transport stdio)
|
|
||||||
if printf '%s' "$HELP" | grep -q -- '--document-url'; then
|
|
||||||
ARGS+=(--document-url "$BASE" --runtime-url "$BASE")
|
|
||||||
printf '%s' "$HELP" | grep -q -- '--document-token' && ARGS+=(--document-token "$TOKEN" --runtime-token "$TOKEN")
|
|
||||||
elif printf '%s' "$HELP" | grep -q -- '--jupyter-url'; then
|
|
||||||
ARGS+=(--jupyter-url "$BASE" --jupyter-token "$TOKEN")
|
|
||||||
else
|
|
||||||
# fallback: variables de entorno que las distintas versiones reconocen
|
|
||||||
export DOCUMENT_URL="$BASE" RUNTIME_URL="$BASE" DOCUMENT_TOKEN="$TOKEN" RUNTIME_TOKEN="$TOKEN"
|
|
||||||
export JUPYTER_URL="$BASE" JUPYTER_TOKEN="$TOKEN"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "MCP cmd: $MCP ${ARGS[*]}"
|
|
||||||
|
|
||||||
if [ "$DRY" = "1" ]; then
|
|
||||||
log "--dry-run: no ejecuto el MCP. Jupyter sigue corriendo en $BASE"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec "$MCP" "${ARGS[@]}"
|
|
||||||
@@ -61,7 +61,7 @@ Mitad complementaria de `deploy_cpp_exe_to_windows_bash_infra`. El flujo complet
|
|||||||
build_cpp_windows "registry_dashboard"
|
build_cpp_windows "registry_dashboard"
|
||||||
|
|
||||||
# 2. Copiar al escritorio (mata proceso si corre, copia DLLs+assets)
|
# 2. Copiar al escritorio (mata proceso si corre, copia DLLs+assets)
|
||||||
deploy_cpp_exe_to_windows "registry_dashboard" "$HOME/fn_registry/apps/registry_dashboard"
|
deploy_cpp_exe_to_windows "registry_dashboard" "/home/lucas/fn_registry/apps/registry_dashboard"
|
||||||
|
|
||||||
# 3. Lanzar
|
# 3. Lanzar
|
||||||
launch_cpp_app_windows "registry_dashboard"
|
launch_cpp_app_windows "registry_dashboard"
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
---
|
|
||||||
name: mas_client_register
|
|
||||||
kind: function
|
|
||||||
lang: bash
|
|
||||||
domain: infra
|
|
||||||
version: "0.1.0"
|
|
||||||
purity: impure
|
|
||||||
signature: "mas_client_register(ssh_host: string, container: string, config_file: string, dry_run: bool) -> json"
|
|
||||||
description: "Registra y sincroniza clientes OAuth en Matrix Authentication Service (MAS) ejecutando mas-cli config sync dentro del container Docker remoto via SSH. Verifica sintaxis YAML, soporte dry-run para ver diff antes de aplicar, y emite JSON estructurado con resultado. Idempotente: re-ejecucion con misma config no genera cambios."
|
|
||||||
tags: [matrix, mas, oauth, oidc, migration, mas-migration, infra, docker, ssh, matrix-mas]
|
|
||||||
uses_functions: []
|
|
||||||
uses_types: []
|
|
||||||
returns: []
|
|
||||||
returns_optional: false
|
|
||||||
error_type: "error_go_core"
|
|
||||||
imports: []
|
|
||||||
params:
|
|
||||||
- name: ssh_host
|
|
||||||
desc: "alias SSH del VPS donde corre MAS (ej. organic-machine.com). Debe estar en ~/.ssh/config con key auth."
|
|
||||||
- name: container
|
|
||||||
desc: "nombre del container Docker con MAS (ej. element_matrix_chat-mas-1). El config dentro del container se espera en /data/config.yaml."
|
|
||||||
- name: config_file
|
|
||||||
desc: "ruta absoluta en el VPS al archivo mas/config.yaml (ej. /home/ubuntu/CodeProyects/element_matrix_chat/mas/config.yaml). MAS lo monta como /data/config.yaml."
|
|
||||||
- name: dry_run
|
|
||||||
desc: "flag opcional --dry-run: ejecuta mas-cli config dump y devuelve el estado sin aplicar cambios. Util para verificar antes de activar MSC3861."
|
|
||||||
output: "JSON con: status ('ok'|'dry-run'|'error'), applied (bool), clients_total (int), clients_diff (array de lineas del output de mas-cli), stderr (string con logs de error si aplica)."
|
|
||||||
tested: true
|
|
||||||
tests:
|
|
||||||
- "help flag emite JSON parseable"
|
|
||||||
- "args faltantes retornan JSON de error sin ssh"
|
|
||||||
- "jq disponible en host local"
|
|
||||||
test_file_path: "bash/functions/infra/mas_client_register_test.sh"
|
|
||||||
file_path: "bash/functions/infra/mas_client_register.sh"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ejemplo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Dry-run: verificar que clients se aplicarian correctamente
|
|
||||||
source bash/functions/infra/mas_client_register.sh
|
|
||||||
|
|
||||||
mas_client_register \
|
|
||||||
--ssh-host organic-machine.com \
|
|
||||||
--container element_matrix_chat-mas-1 \
|
|
||||||
--config-file /home/ubuntu/CodeProyects/element_matrix_chat/mas/config.yaml \
|
|
||||||
--dry-run
|
|
||||||
|
|
||||||
# Aplicar sync real (con --prune para eliminar clients viejos)
|
|
||||||
mas_client_register \
|
|
||||||
--ssh-host organic-machine.com \
|
|
||||||
--container element_matrix_chat-mas-1 \
|
|
||||||
--config-file /home/ubuntu/CodeProyects/element_matrix_chat/mas/config.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
Salida esperada (sync OK):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"applied": true,
|
|
||||||
"clients_total": 6,
|
|
||||||
"clients_diff": ["synced client element-web", "synced client synapse-admin", "..."],
|
|
||||||
"stderr": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Salida dry-run:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "dry-run",
|
|
||||||
"applied": false,
|
|
||||||
"clients_total": 42,
|
|
||||||
"clients_diff": ["clients:", " - client_id: element-web", " ..."],
|
|
||||||
"stderr": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cuando usarla
|
|
||||||
|
|
||||||
Usar despues de editar `mas/config.yaml` localmente y antes de hacer restart a Synapse con `msc3861` habilitado en `homeserver.yaml`. Ejecutar primero con `--dry-run` para verificar que los 6 clients OAuth (Element Web, Synapse-Admin, matrix_client_pc, matrix_client_android, matrix_admin_panel, Synapse-internal) estan correctamente definidos, luego sin `--dry-run` para aplicar el sync.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **`--prune` elimina clients no declarados en config**: el sync real usa `--prune`, lo que borra cualquier client OAuth que exista en MAS pero no este en el `config.yaml`. Verificar con `--dry-run` antes de aplicar en produccion.
|
|
||||||
- **Requiere `jq` en el host local**: el JSON output se construye con `jq`. Si no esta instalado, la funcion falla con error claro antes de conectar al VPS.
|
|
||||||
- **`mas-cli` debe estar en el container**: la funcion asume que `mas-cli` esta en el PATH dentro del container MAS. Si el container usa una imagen diferente, verificar con `docker exec <container> mas-cli --version`.
|
|
||||||
- **Config dentro del container siempre en `/data/config.yaml`**: el `--config-file` apunta a la ruta en el VPS (para que el operador sepa que archivo editar), pero el comando dentro del container usa `/data/config.yaml` (el mount point estandar de MAS). Si el compose monta el archivo en otro path, ajustar la constante `container_config` en el script.
|
|
||||||
- **SSH key debe estar en agent o `~/.ssh/config`**: la funcion usa `ssh <alias>` directamente. Si la key requiere passphrase, ejecutar `ssh-add` antes.
|
|
||||||
- **Si `config.yaml` es invalido, sync aborta sin tocar estado**: el paso 1 (`mas-cli config check`) detecta errores de sintaxis YAML antes de intentar sync. El estado de MAS no se modifica si la config tiene errores.
|
|
||||||
- **Idempotente**: re-ejecutar con la misma config no genera cambios en MAS (mas-cli detecta que el estado ya coincide).
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# mas_client_register — Registra/sincroniza clientes OAuth en Matrix Authentication Service (MAS)
|
|
||||||
# via mas-cli config sync ejecutado en container Docker remoto a traves de SSH.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
mas_client_register() {
|
|
||||||
local ssh_host=""
|
|
||||||
local container=""
|
|
||||||
local config_file=""
|
|
||||||
local dry_run=false
|
|
||||||
|
|
||||||
# Parse args
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--ssh-host)
|
|
||||||
ssh_host="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--container)
|
|
||||||
container="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--config-file)
|
|
||||||
config_file="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--dry-run)
|
|
||||||
dry_run=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--help|-h)
|
|
||||||
cat >&2 <<'USAGE'
|
|
||||||
mas_client_register - Sincroniza clientes OAuth en MAS via mas-cli config sync
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
mas_client_register --ssh-host <host> --container <name> --config-file <path> [--dry-run]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--ssh-host Alias SSH del VPS (ej. organic-machine.com)
|
|
||||||
--container Nombre del container MAS (ej. element_matrix_chat-mas-1)
|
|
||||||
--config-file Ruta en el VPS al mas/config.yaml (ej. /home/ubuntu/project/mas/config.yaml)
|
|
||||||
--dry-run Solo valida config y muestra diff, sin aplicar cambios
|
|
||||||
|
|
||||||
Output: JSON en stdout con status, applied, clients_total, clients_diff, stderr
|
|
||||||
USAGE
|
|
||||||
# emit minimal valid JSON so callers that parse stdout don't break
|
|
||||||
echo '{"status":"help","applied":false,"clients_total":0,"clients_diff":[],"stderr":""}'
|
|
||||||
return 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "mas_client_register: argumento desconocido: $1" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# Validar argumentos obligatorios
|
|
||||||
local errors=()
|
|
||||||
[[ -z "$ssh_host" ]] && errors+=("--ssh-host es obligatorio")
|
|
||||||
[[ -z "$container" ]] && errors+=("--container es obligatorio")
|
|
||||||
[[ -z "$config_file" ]] && errors+=("--config-file es obligatorio")
|
|
||||||
|
|
||||||
if [[ ${#errors[@]} -gt 0 ]]; then
|
|
||||||
for err in "${errors[@]}"; do
|
|
||||||
echo "ERROR: $err" >&2
|
|
||||||
done
|
|
||||||
echo '{"status":"error","applied":false,"clients_total":0,"clients_diff":[],"stderr":"missing required arguments"}'
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verificar dependencias locales
|
|
||||||
if ! command -v jq &>/dev/null; then
|
|
||||||
echo "ERROR: jq no encontrado en el host local. Instalar: apt install jq / brew install jq" >&2
|
|
||||||
echo '{"status":"error","applied":false,"clients_total":0,"clients_diff":[],"stderr":"jq not found on local host"}'
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "mas_client_register: ssh-host=$ssh_host container=$container dry-run=$dry_run" >&2
|
|
||||||
|
|
||||||
# La ruta de config dentro del container siempre es /data/config.yaml (mount convention de MAS)
|
|
||||||
local container_config="/data/config.yaml"
|
|
||||||
|
|
||||||
# ---- PASO 1: Verificar sintaxis YAML con mas-cli config check ----
|
|
||||||
echo "mas_client_register: verificando sintaxis de config con mas-cli config check..." >&2
|
|
||||||
local check_stdout check_stderr check_exit
|
|
||||||
check_stdout=$(ssh "$ssh_host" \
|
|
||||||
"docker exec ${container} mas-cli config check --config ${container_config}" 2>/tmp/mas_check_stderr_$$ || true)
|
|
||||||
check_exit=$?
|
|
||||||
check_stderr=$(cat /tmp/mas_check_stderr_$$ 2>/dev/null || true)
|
|
||||||
rm -f /tmp/mas_check_stderr_$$
|
|
||||||
|
|
||||||
if [[ $check_exit -ne 0 ]]; then
|
|
||||||
echo "mas_client_register: config check falló (exit=$check_exit)" >&2
|
|
||||||
echo "$check_stderr" >&2
|
|
||||||
local escaped_stderr
|
|
||||||
escaped_stderr=$(printf '%s' "${check_stderr}" | jq -Rs '.')
|
|
||||||
echo "{\"status\":\"error\",\"applied\":false,\"clients_total\":0,\"clients_diff\":[],\"stderr\":${escaped_stderr}}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "mas_client_register: config check OK" >&2
|
|
||||||
|
|
||||||
# ---- PASO 2: dry-run o sync ----
|
|
||||||
if [[ "$dry_run" == "true" ]]; then
|
|
||||||
# Ejecutar mas-cli config dump para mostrar el estado actual y lo que se aplicaria
|
|
||||||
echo "mas_client_register: modo dry-run — ejecutando mas-cli config dump..." >&2
|
|
||||||
local dump_stdout dump_stderr dump_exit
|
|
||||||
dump_stdout=$(ssh "$ssh_host" \
|
|
||||||
"docker exec ${container} mas-cli config dump --config ${container_config}" 2>/tmp/mas_dump_stderr_$$ || true)
|
|
||||||
dump_exit=$?
|
|
||||||
dump_stderr=$(cat /tmp/mas_dump_stderr_$$ 2>/dev/null || true)
|
|
||||||
rm -f /tmp/mas_dump_stderr_$$
|
|
||||||
|
|
||||||
if [[ $dump_exit -ne 0 ]]; then
|
|
||||||
echo "mas_client_register: config dump falló (exit=$dump_exit)" >&2
|
|
||||||
echo "$dump_stderr" >&2
|
|
||||||
local escaped_stderr
|
|
||||||
escaped_stderr=$(printf '%s' "${dump_stderr}" | jq -Rs '.')
|
|
||||||
echo "{\"status\":\"error\",\"applied\":false,\"clients_total\":0,\"clients_diff\":[],\"stderr\":${escaped_stderr}}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Extraer listado de clients del dump (buscar lineas con client_id o type: client)
|
|
||||||
local clients_diff_raw
|
|
||||||
clients_diff_raw=$(printf '%s\n' "$dump_stdout" | grep -E "client_id:|client_name:" | \
|
|
||||||
sed 's/^[[:space:]]*//' | head -50 || true)
|
|
||||||
|
|
||||||
local diff_json
|
|
||||||
diff_json=$(printf '%s\n' "$dump_stdout" | jq -Rs 'split("\n") | map(select(length > 0)) | map(ltrimstr(" "))' 2>/dev/null \
|
|
||||||
|| echo '["(jq parse error — ver stderr)"]')
|
|
||||||
|
|
||||||
local escaped_dump_stderr
|
|
||||||
escaped_dump_stderr=$(printf '%s' "${dump_stderr}" | jq -Rs '.')
|
|
||||||
|
|
||||||
echo "mas_client_register: dry-run completado. dump lines=$(echo "$dump_stdout" | wc -l)" >&2
|
|
||||||
|
|
||||||
jq -n \
|
|
||||||
--argjson diff "$diff_json" \
|
|
||||||
--argjson stderr_str "$escaped_dump_stderr" \
|
|
||||||
'{
|
|
||||||
status: "dry-run",
|
|
||||||
applied: false,
|
|
||||||
clients_total: ($diff | length),
|
|
||||||
clients_diff: $diff,
|
|
||||||
stderr: $stderr_str
|
|
||||||
}'
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---- PASO 3: sync real ----
|
|
||||||
echo "mas_client_register: ejecutando mas-cli config sync --prune..." >&2
|
|
||||||
local sync_stdout sync_stderr sync_exit
|
|
||||||
sync_stdout=$(ssh "$ssh_host" \
|
|
||||||
"docker exec ${container} mas-cli config sync --config ${container_config} --prune" \
|
|
||||||
2>/tmp/mas_sync_stderr_$$ || true)
|
|
||||||
sync_exit=$?
|
|
||||||
sync_stderr=$(cat /tmp/mas_sync_stderr_$$ 2>/dev/null || true)
|
|
||||||
rm -f /tmp/mas_sync_stderr_$$
|
|
||||||
|
|
||||||
echo "mas_client_register: sync exit=$sync_exit" >&2
|
|
||||||
if [[ -n "$sync_stderr" ]]; then
|
|
||||||
echo "mas_client_register stderr: $sync_stderr" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ $sync_exit -ne 0 ]]; then
|
|
||||||
local escaped_stderr
|
|
||||||
escaped_stderr=$(printf '%s' "${sync_stderr}" | jq -Rs '.')
|
|
||||||
echo "{\"status\":\"error\",\"applied\":false,\"clients_total\":0,\"clients_diff\":[],\"stderr\":${escaped_stderr}}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Parsear output del sync para extraer lineas con cambios aplicados
|
|
||||||
local diff_lines
|
|
||||||
diff_lines=$(printf '%s\n' "$sync_stdout" | grep -E "^\s*(created|updated|deleted|unchanged|synced)" || true)
|
|
||||||
|
|
||||||
local diff_json
|
|
||||||
diff_json=$(printf '%s\n' "$sync_stdout" | jq -Rs 'split("\n") | map(select(length > 0))' 2>/dev/null \
|
|
||||||
|| echo '[]')
|
|
||||||
|
|
||||||
local clients_count
|
|
||||||
clients_count=$(printf '%s\n' "$sync_stdout" | grep -cE "client" 2>/dev/null || echo 0)
|
|
||||||
|
|
||||||
local escaped_sync_stderr
|
|
||||||
escaped_sync_stderr=$(printf '%s' "${sync_stderr}" | jq -Rs '.')
|
|
||||||
|
|
||||||
echo "mas_client_register: sync completado con exito" >&2
|
|
||||||
|
|
||||||
jq -n \
|
|
||||||
--argjson diff "$diff_json" \
|
|
||||||
--argjson total "$clients_count" \
|
|
||||||
--argjson stderr_str "$escaped_sync_stderr" \
|
|
||||||
'{
|
|
||||||
status: "ok",
|
|
||||||
applied: true,
|
|
||||||
clients_total: $total,
|
|
||||||
clients_diff: $diff,
|
|
||||||
stderr: $stderr_str
|
|
||||||
}'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Ejecutar si se llama directamente (no sourced)
|
|
||||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
||||||
mas_client_register "$@"
|
|
||||||
fi
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Tests para mas_client_register
|
|
||||||
# No requiere SSH real — prueba paths locales (arg validation, --help, JSON output)
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
PASS=0
|
|
||||||
FAIL=0
|
|
||||||
|
|
||||||
assert_contains() {
|
|
||||||
local test_name="$1" needle="$2" haystack="$3"
|
|
||||||
if echo "$haystack" | grep -qF "$needle"; then
|
|
||||||
echo "PASS: $test_name"
|
|
||||||
((PASS++))
|
|
||||||
else
|
|
||||||
echo "FAIL: $test_name — expected to contain '$needle', got: $haystack"
|
|
||||||
((FAIL++))
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_json_parseable() {
|
|
||||||
local test_name="$1" json="$2"
|
|
||||||
if command -v jq &>/dev/null; then
|
|
||||||
if echo "$json" | jq . >/dev/null 2>&1; then
|
|
||||||
echo "PASS: $test_name"
|
|
||||||
((PASS++))
|
|
||||||
else
|
|
||||||
echo "FAIL: $test_name — output no es JSON valido: $json"
|
|
||||||
((FAIL++))
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if [[ "$json" == \{* ]]; then
|
|
||||||
echo "PASS: $test_name (jq no disponible, verificacion basica OK)"
|
|
||||||
((PASS++))
|
|
||||||
else
|
|
||||||
echo "FAIL: $test_name — output no parece JSON: $json"
|
|
||||||
((FAIL++))
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Test: help flag emite JSON parseable
|
|
||||||
# Cada invocacion en subshell aislada para no contaminar el runner con set -e del script fuente
|
|
||||||
bash "$SCRIPT_DIR/mas_client_register.sh" --help >/tmp/mas_test_help_$$ 2>/dev/null || true
|
|
||||||
output_help=$(cat /tmp/mas_test_help_$$ 2>/dev/null || true)
|
|
||||||
rm -f /tmp/mas_test_help_$$
|
|
||||||
assert_json_parseable "help flag emite JSON parseable" "$output_help"
|
|
||||||
|
|
||||||
# Test: args faltantes retornan JSON de error sin ssh
|
|
||||||
bash "$SCRIPT_DIR/mas_client_register.sh" >/tmp/mas_test_noargs_$$ 2>/dev/null || true
|
|
||||||
output_noargs=$(cat /tmp/mas_test_noargs_$$ 2>/dev/null || true)
|
|
||||||
rm -f /tmp/mas_test_noargs_$$
|
|
||||||
assert_json_parseable "args faltantes retornan JSON de error sin ssh" "$output_noargs"
|
|
||||||
assert_contains "args faltantes contienen status error" '"status":"error"' "$output_noargs"
|
|
||||||
|
|
||||||
# Test: jq disponible en host local
|
|
||||||
if command -v jq &>/dev/null; then
|
|
||||||
echo "PASS: jq disponible en host local"
|
|
||||||
((PASS++))
|
|
||||||
else
|
|
||||||
echo "FAIL: jq disponible en host local — instalar: apt install jq"
|
|
||||||
((FAIL++))
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "---"
|
|
||||||
echo "Results: $PASS passed, $FAIL failed"
|
|
||||||
[[ $FAIL -eq 0 ]] || exit 1
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
---
|
|
||||||
name: mas_syn2mas_migration
|
|
||||||
kind: function
|
|
||||||
lang: bash
|
|
||||||
domain: infra
|
|
||||||
version: "0.1.0"
|
|
||||||
purity: impure
|
|
||||||
signature: "mas_syn2mas_migration --ssh-host <host> --mas-container <name> --synapse-config-path <path-on-host> --log-dir <local-path> [--max-conflicts N] [--apply]"
|
|
||||||
description: "Migra usuarios Synapse a Matrix Authentication Service (MAS) via mas-cli syn2mas. Fuerza dry-run primero, archiva el log, aborta si los conflicts superan el threshold, y solo ejecuta la migracion real con --apply."
|
|
||||||
tags: [matrix, mas, syn2mas, migration, mas-migration, infra, users, docker, ssh, matrix-mas]
|
|
||||||
params:
|
|
||||||
- name: ssh-host
|
|
||||||
desc: "Alias SSH del VPS donde corren los containers (ej. organic-machine.com)"
|
|
||||||
- name: mas-container
|
|
||||||
desc: "Nombre del container Docker de MAS (ej. element_matrix_chat-mas-1)"
|
|
||||||
- name: synapse-config-path
|
|
||||||
desc: "Ruta en el VPS al homeserver.yaml de Synapse (ej. /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml). El container debe tener el archivo accesible en /data/homeserver.yaml via volume mount."
|
|
||||||
- name: log-dir
|
|
||||||
desc: "Directorio local donde archivar logs dry-run y apply. Se crea con chmod 0700 y los logs con 0600 (contienen userIDs)."
|
|
||||||
- name: max-conflicts
|
|
||||||
desc: "Tope de conflictos detectados en dry-run. Si conflicts > max-conflicts, status=aborted exit 2. Default 0 (abortar ante cualquier conflict)."
|
|
||||||
- name: apply
|
|
||||||
desc: "Flag booleano. Sin --apply: solo dry-run (status=ok, sin cambios). Con --apply: ejecuta la migracion real tras pasar el threshold."
|
|
||||||
output: "JSON en stdout: {\"status\":\"ok|aborted|error\",\"dry_run_log\":\"path\",\"apply_log\":\"path|null\",\"conflicts\":N,\"users_migrated\":N,\"duration_s\":N}. Exit 0=ok, 1=error, 2=aborted por conflicts."
|
|
||||||
uses_functions: []
|
|
||||||
uses_types: []
|
|
||||||
returns: []
|
|
||||||
returns_optional: false
|
|
||||||
error_type: "error_go_core"
|
|
||||||
imports: []
|
|
||||||
tested: true
|
|
||||||
tests:
|
|
||||||
- "aborta con error cuando faltan args obligatorios"
|
|
||||||
- "help no devuelve error"
|
|
||||||
- "argumento desconocido retorna exit 1"
|
|
||||||
- "max-conflicts invalido retorna exit 1"
|
|
||||||
test_file_path: "bash/functions/infra/mas_syn2mas_migration_test.sh"
|
|
||||||
file_path: "bash/functions/infra/mas_syn2mas_migration.sh"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ejemplo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Paso 1: dry-run OBLIGATORIO (sin --apply — no modifica nada)
|
|
||||||
mas_syn2mas_migration \
|
|
||||||
--ssh-host organic-machine.com \
|
|
||||||
--mas-container element_matrix_chat-mas-1 \
|
|
||||||
--synapse-config-path /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml \
|
|
||||||
--log-dir ~/matrix_migration_logs \
|
|
||||||
--max-conflicts 0
|
|
||||||
|
|
||||||
# Salida esperada (si hay 0 conflicts):
|
|
||||||
# {"status":"ok","dry_run_log":"/home/lucas/matrix_migration_logs/syn2mas_dryrun_1234567890.log","apply_log":null,"conflicts":0,"users_migrated":0,"duration_s":0}
|
|
||||||
|
|
||||||
# Revisar el log antes de continuar:
|
|
||||||
# cat ~/matrix_migration_logs/syn2mas_dryrun_*.log
|
|
||||||
|
|
||||||
# Paso 2: tras revisar el log dry-run, aplicar la migracion real
|
|
||||||
mas_syn2mas_migration \
|
|
||||||
--ssh-host organic-machine.com \
|
|
||||||
--mas-container element_matrix_chat-mas-1 \
|
|
||||||
--synapse-config-path /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml \
|
|
||||||
--log-dir ~/matrix_migration_logs \
|
|
||||||
--max-conflicts 0 \
|
|
||||||
--apply
|
|
||||||
|
|
||||||
# Salida esperada tras migracion exitosa:
|
|
||||||
# {"status":"ok","dry_run_log":"/home/lucas/matrix_migration_logs/syn2mas_dryrun_1234567890.log","apply_log":"/home/lucas/matrix_migration_logs/syn2mas_apply_1234567890.log","conflicts":0,"users_migrated":42,"duration_s":15}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cuando usarla
|
|
||||||
|
|
||||||
Usar en el paso 4 de la migracion del issue 0162 (Synapse a MAS auth), tras activar MSC3861 en `homeserver.yaml` y verificar que MAS esta corriendo con `syn2mas: true` en su config. NUNCA ejecutar antes de activar MSC3861 — sin ese flag activo, `syn2mas` no puede mapear usuarios a las tablas MAS y la migracion resultara en estado inconsistente.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **Dry-run NO modifica nada** — siempre ejecutar primero sin `--apply` y revisar el log manualmente antes de aplicar.
|
|
||||||
- Si el dry-run detecta usuarios con **guest accounts**, **application services** (bots), o **passwords externos** (LDAP/OIDC), revisar manualmente el log antes de aplicar — estos casos pueden requerir steps adicionales documentados en el issue 0162.
|
|
||||||
- **Backup postgres pre-migracion NO esta cubierto** por esta funcion. El operador es responsable de hacer `pg_dump` de la DB de Synapse antes de ejecutar con `--apply`. Ver issue 0162 paso 1.
|
|
||||||
- Si la migracion real falla **a mitad**, MAS puede quedar en estado inconsistente con usuarios parcialmente migrados. El rollback consiste en restaurar el backup postgres de Synapse + revertir `homeserver.yaml` a la configuracion pre-MSC3861.
|
|
||||||
- Los logs archivados en `--log-dir` **incluyen userIDs** (datos personales). Se crean con permisos `0600` (solo propietario puede leer). Mantener el directorio con `chmod 0700`. No subir los logs a repos publicos.
|
|
||||||
- El comando `mas-cli syn2mas` en el container asume que `homeserver.yaml` esta montado en `/data/homeserver.yaml`. Si el volume mount del container usa otra ruta, el comando fallara con "file not found". Verificar con `docker inspect <container> | jq '.[].Mounts'`.
|
|
||||||
- La postcondicion compara el count de usuarios MAS con una segunda ejecucion de dry-run para obtener el count esperado. Si el conteo no esta disponible (salida inesperada de mas-cli), la funcion emite `status=ok` con `users_migrated` del count real de MAS — no aborta por este motivo para evitar falsos negativos.
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# mas_syn2mas_migration — Migra usuarios Synapse a MAS via mas-cli syn2mas.
|
|
||||||
# Fuerza dry-run primero, archiva el log, aborta si conflicts > threshold,
|
|
||||||
# y solo ejecuta la migracion real cuando se pasa --apply.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# mas_syn2mas_migration --ssh-host <host> --mas-container <name> \
|
|
||||||
# --synapse-config-path <path-on-host> --log-dir <local-path> \
|
|
||||||
# [--max-conflicts N] [--apply]
|
|
||||||
#
|
|
||||||
# Output: JSON en stdout con status, dry_run_log, apply_log, conflicts, users_migrated, duration_s
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
mas_syn2mas_migration() {
|
|
||||||
local ssh_host=""
|
|
||||||
local mas_container=""
|
|
||||||
local synapse_config_path=""
|
|
||||||
local log_dir=""
|
|
||||||
local max_conflicts=0
|
|
||||||
local do_apply=false
|
|
||||||
|
|
||||||
# ---- Parse args ----
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--ssh-host)
|
|
||||||
ssh_host="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--mas-container)
|
|
||||||
mas_container="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--synapse-config-path)
|
|
||||||
synapse_config_path="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--log-dir)
|
|
||||||
log_dir="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--max-conflicts)
|
|
||||||
max_conflicts="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--apply)
|
|
||||||
do_apply=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--help|-h)
|
|
||||||
cat >&2 <<'USAGE'
|
|
||||||
mas_syn2mas_migration - Migra usuarios Synapse a Matrix Authentication Service (MAS)
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
mas_syn2mas_migration \
|
|
||||||
--ssh-host <host> \
|
|
||||||
--mas-container <name> \
|
|
||||||
--synapse-config-path <path-on-host> \
|
|
||||||
--log-dir <local-path> \
|
|
||||||
[--max-conflicts N] \
|
|
||||||
[--apply]
|
|
||||||
|
|
||||||
Opciones:
|
|
||||||
--ssh-host Alias SSH del VPS (ej. organic-machine.com)
|
|
||||||
--mas-container Nombre del container MAS (ej. element_matrix_chat-mas-1)
|
|
||||||
--synapse-config-path Ruta en el VPS al homeserver.yaml
|
|
||||||
(ej. /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml)
|
|
||||||
--log-dir Directorio local donde archivar logs dry-run y apply
|
|
||||||
--max-conflicts N Tope de conflictos en dry-run antes de abortar (default 0)
|
|
||||||
--apply Ejecutar migracion real. Sin esta flag: solo dry-run.
|
|
||||||
|
|
||||||
Comportamiento:
|
|
||||||
1. Siempre ejecuta dry-run primero y archiva el log.
|
|
||||||
2. Si conflicts > max-conflicts -> status=aborted, exit 2.
|
|
||||||
3. Sin --apply -> status=ok (dry-run completado), exit 0.
|
|
||||||
4. Con --apply -> ejecuta migracion real, archiva log, verifica postcondicion.
|
|
||||||
|
|
||||||
Output JSON: {"status":"ok|aborted|error","dry_run_log":"path","apply_log":"path|null","conflicts":N,"users_migrated":N,"duration_s":N}
|
|
||||||
USAGE
|
|
||||||
echo '{"status":"help","dry_run_log":"","apply_log":null,"conflicts":0,"users_migrated":0,"duration_s":0}'
|
|
||||||
return 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "mas_syn2mas_migration: argumento desconocido: $1" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# ---- Validar argumentos obligatorios ----
|
|
||||||
local errors=()
|
|
||||||
[[ -z "$ssh_host" ]] && errors+=("--ssh-host es obligatorio")
|
|
||||||
[[ -z "$mas_container" ]] && errors+=("--mas-container es obligatorio")
|
|
||||||
[[ -z "$synapse_config_path" ]] && errors+=("--synapse-config-path es obligatorio")
|
|
||||||
[[ -z "$log_dir" ]] && errors+=("--log-dir es obligatorio")
|
|
||||||
|
|
||||||
if [[ ${#errors[@]} -gt 0 ]]; then
|
|
||||||
for err in "${errors[@]}"; do
|
|
||||||
echo "ERROR: $err" >&2
|
|
||||||
done
|
|
||||||
echo '{"status":"error","dry_run_log":"","apply_log":null,"conflicts":-1,"users_migrated":0,"duration_s":0}'
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Validar que max_conflicts es un entero no negativo
|
|
||||||
if ! [[ "$max_conflicts" =~ ^[0-9]+$ ]]; then
|
|
||||||
echo "ERROR: --max-conflicts debe ser un entero >= 0, recibido: $max_conflicts" >&2
|
|
||||||
echo '{"status":"error","dry_run_log":"","apply_log":null,"conflicts":-1,"users_migrated":0,"duration_s":0}'
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---- Dependencias locales ----
|
|
||||||
if ! command -v jq &>/dev/null; then
|
|
||||||
echo "ERROR: jq no encontrado. Instalar: apt install jq / brew install jq" >&2
|
|
||||||
echo '{"status":"error","dry_run_log":"","apply_log":null,"conflicts":-1,"users_migrated":0,"duration_s":0}'
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---- Crear log-dir con permisos restringidos ----
|
|
||||||
mkdir -p "$log_dir"
|
|
||||||
chmod 0700 "$log_dir"
|
|
||||||
|
|
||||||
local ts
|
|
||||||
ts=$(date +%s)
|
|
||||||
|
|
||||||
local dry_run_log="${log_dir}/syn2mas_dryrun_${ts}.log"
|
|
||||||
local apply_log_path="null"
|
|
||||||
local apply_log_file="${log_dir}/syn2mas_apply_${ts}.log"
|
|
||||||
|
|
||||||
# La ruta del homeserver.yaml dentro del container MAS se pasa como --synapse-config
|
|
||||||
# MAS monta el directorio del synapse bajo /data/ por convencion, pero la ruta real
|
|
||||||
# puede variar — usamos la ruta tal como existe en el host (montada via volume).
|
|
||||||
# El comando real esperado: docker exec <container> mas-cli syn2mas --synapse-config <path>
|
|
||||||
# donde <path> es la ruta tal como el container la ve (via volume mount).
|
|
||||||
# Asumimos que el VPS tiene el config accesible en la misma ruta dentro del container.
|
|
||||||
local container_config="/data/homeserver.yaml"
|
|
||||||
|
|
||||||
echo "mas_syn2mas_migration: ssh-host=${ssh_host} container=${mas_container} max-conflicts=${max_conflicts} apply=${do_apply}" >&2
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# PASO 1: DRY-RUN obligatorio
|
|
||||||
# =========================================================================
|
|
||||||
echo "mas_syn2mas_migration: ejecutando dry-run..." >&2
|
|
||||||
|
|
||||||
local dry_exit=0
|
|
||||||
# Capturar stdout+stderr del dry-run en el log y tambien en variable para parsing
|
|
||||||
local dry_output
|
|
||||||
dry_output=$(ssh "$ssh_host" \
|
|
||||||
"docker exec '${mas_container}' mas-cli syn2mas \
|
|
||||||
--synapse-config '${container_config}' \
|
|
||||||
--dry-run" \
|
|
||||||
2>&1) || dry_exit=$?
|
|
||||||
|
|
||||||
# Archivar log con timestamp + header informativo
|
|
||||||
{
|
|
||||||
echo "# mas_syn2mas_migration dry-run"
|
|
||||||
echo "# ts=${ts} ssh-host=${ssh_host} container=${mas_container}"
|
|
||||||
echo "# synapse-config-path=${synapse_config_path}"
|
|
||||||
echo "# exit=${dry_exit}"
|
|
||||||
echo "# ---"
|
|
||||||
printf '%s\n' "$dry_output"
|
|
||||||
} > "$dry_run_log"
|
|
||||||
chmod 0600 "$dry_run_log"
|
|
||||||
|
|
||||||
echo "mas_syn2mas_migration: dry-run exit=${dry_exit}, log=${dry_run_log}" >&2
|
|
||||||
|
|
||||||
if [[ $dry_exit -ne 0 ]]; then
|
|
||||||
# Si el comando SSH falla completamente (no es fallo de syn2mas sino de conectividad)
|
|
||||||
echo "mas_syn2mas_migration: ERROR — dry-run falló con exit ${dry_exit}" >&2
|
|
||||||
local escaped_out
|
|
||||||
escaped_out=$(printf '%s' "${dry_output}" | jq -Rs '.')
|
|
||||||
local dry_run_log_json
|
|
||||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
|
||||||
echo "{\"status\":\"error\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":-1,\"users_migrated\":0,\"duration_s\":0}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# PASO 2: Parsear conflicts del dry-run
|
|
||||||
# =========================================================================
|
|
||||||
# Regex sobre lineas tipo:
|
|
||||||
# "Conflict:", "Skipping:", "Error processing user", "conflict"
|
|
||||||
# También contamos líneas que indiquen usuarios problemáticos.
|
|
||||||
local conflicts=0
|
|
||||||
local conflict_lines
|
|
||||||
conflict_lines=$(printf '%s\n' "$dry_output" | \
|
|
||||||
grep -ciE '(conflict|skipping|error processing user|cannot migrate|already exists)' 2>/dev/null || true)
|
|
||||||
|
|
||||||
# grep -c devuelve string; convertir a int defensivamente
|
|
||||||
if [[ "$conflict_lines" =~ ^[0-9]+$ ]]; then
|
|
||||||
conflicts=$conflict_lines
|
|
||||||
else
|
|
||||||
# Parser falló de forma inesperada — abortar defensivamente
|
|
||||||
echo "mas_syn2mas_migration: ERROR — no se pudo parsear el conteo de conflicts del dry-run (parser defensivo)" >&2
|
|
||||||
local dry_run_log_json
|
|
||||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
|
||||||
echo "{\"status\":\"error\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":-1,\"users_migrated\":0,\"duration_s\":0}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "mas_syn2mas_migration: conflicts detectados en dry-run: ${conflicts} (max permitido: ${max_conflicts})" >&2
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# PASO 3: Verificar threshold de conflicts
|
|
||||||
# =========================================================================
|
|
||||||
if [[ $conflicts -gt $max_conflicts ]]; then
|
|
||||||
echo "mas_syn2mas_migration: ABORTADO — conflicts (${conflicts}) > max-conflicts (${max_conflicts})" >&2
|
|
||||||
echo "Revisar: ${dry_run_log}" >&2
|
|
||||||
local dry_run_log_json
|
|
||||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
|
||||||
echo "{\"status\":\"aborted\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":${conflicts},\"users_migrated\":0,\"duration_s\":0}"
|
|
||||||
return 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# PASO 4: Si no --apply, terminar aqui con status=ok (dry-run completado)
|
|
||||||
# =========================================================================
|
|
||||||
if [[ "$do_apply" == "false" ]]; then
|
|
||||||
echo "mas_syn2mas_migration: dry-run completado (${conflicts} conflicts). Revisar log y re-ejecutar con --apply." >&2
|
|
||||||
local dry_run_log_json
|
|
||||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
|
||||||
echo "{\"status\":\"ok\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":${conflicts},\"users_migrated\":0,\"duration_s\":0}"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# PASO 5: Migracion REAL (--apply)
|
|
||||||
# =========================================================================
|
|
||||||
echo "mas_syn2mas_migration: ejecutando migracion REAL..." >&2
|
|
||||||
local apply_start
|
|
||||||
apply_start=$(date +%s)
|
|
||||||
|
|
||||||
local apply_exit=0
|
|
||||||
local apply_output
|
|
||||||
apply_output=$(ssh "$ssh_host" \
|
|
||||||
"docker exec '${mas_container}' mas-cli syn2mas \
|
|
||||||
--synapse-config '${container_config}'" \
|
|
||||||
2>&1) || apply_exit=$?
|
|
||||||
|
|
||||||
local apply_end
|
|
||||||
apply_end=$(date +%s)
|
|
||||||
local duration_s=$(( apply_end - apply_start ))
|
|
||||||
|
|
||||||
# Archivar log de apply
|
|
||||||
{
|
|
||||||
echo "# mas_syn2mas_migration apply"
|
|
||||||
echo "# ts=${ts} ssh-host=${ssh_host} container=${mas_container}"
|
|
||||||
echo "# synapse-config-path=${synapse_config_path}"
|
|
||||||
echo "# exit=${apply_exit} duration_s=${duration_s}"
|
|
||||||
echo "# ---"
|
|
||||||
printf '%s\n' "$apply_output"
|
|
||||||
} > "$apply_log_file"
|
|
||||||
chmod 0600 "$apply_log_file"
|
|
||||||
|
|
||||||
apply_log_path="$apply_log_file"
|
|
||||||
echo "mas_syn2mas_migration: apply exit=${apply_exit}, duration=${duration_s}s, log=${apply_log_file}" >&2
|
|
||||||
|
|
||||||
if [[ $apply_exit -ne 0 ]]; then
|
|
||||||
echo "mas_syn2mas_migration: ERROR — migracion real falló con exit ${apply_exit}" >&2
|
|
||||||
local dry_run_log_json apply_log_json
|
|
||||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
|
||||||
apply_log_json=$(printf '%s' "$apply_log_file" | jq -Rs '.')
|
|
||||||
echo "{\"status\":\"error\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":${apply_log_json},\"conflicts\":${conflicts},\"users_migrated\":0,\"duration_s\":${duration_s}}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# PASO 6: Postcondicion — comparar usuarios en MAS vs Synapse
|
|
||||||
# =========================================================================
|
|
||||||
echo "mas_syn2mas_migration: verificando postcondicion (usuarios MAS vs Synapse)..." >&2
|
|
||||||
|
|
||||||
local mas_user_count=0
|
|
||||||
local synapse_user_count=0
|
|
||||||
local users_migrated=0
|
|
||||||
local post_status="ok"
|
|
||||||
|
|
||||||
# Contar usuarios en MAS via mas-cli admin user list
|
|
||||||
local mas_count_raw
|
|
||||||
mas_count_raw=$(ssh "$ssh_host" \
|
|
||||||
"docker exec '${mas_container}' mas-cli manage list-users --json 2>/dev/null | jq length" \
|
|
||||||
2>/dev/null || echo "0")
|
|
||||||
|
|
||||||
if [[ "$mas_count_raw" =~ ^[0-9]+$ ]]; then
|
|
||||||
mas_user_count=$mas_count_raw
|
|
||||||
else
|
|
||||||
echo "mas_syn2mas_migration: ADVERTENCIA — no se pudo obtener conteo de usuarios MAS (output: ${mas_count_raw})" >&2
|
|
||||||
post_status="ok" # No abortar, solo advertir
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Contar usuarios locales en Synapse via psql (excluyendo bots/AS)
|
|
||||||
# Intentamos obtener el count; si falla, continuamos sin abortar
|
|
||||||
local synapse_count_raw
|
|
||||||
synapse_count_raw=$(ssh "$ssh_host" \
|
|
||||||
"docker exec '${mas_container}' mas-cli syn2mas --synapse-config '${container_config}' --dry-run 2>&1 | grep -oE 'Found [0-9]+ users' | grep -oE '[0-9]+' | head -1" \
|
|
||||||
2>/dev/null || echo "0")
|
|
||||||
|
|
||||||
if [[ "$synapse_count_raw" =~ ^[0-9]+$ ]]; then
|
|
||||||
synapse_user_count=$synapse_count_raw
|
|
||||||
fi
|
|
||||||
|
|
||||||
users_migrated=$mas_user_count
|
|
||||||
|
|
||||||
# Si tenemos ambos counts y difieren significativamente, marcar como warning en log
|
|
||||||
if [[ $synapse_user_count -gt 0 && $mas_user_count -eq 0 ]]; then
|
|
||||||
echo "mas_syn2mas_migration: ADVERTENCIA — MAS reporta 0 usuarios pero Synapse tenia ${synapse_user_count}" >&2
|
|
||||||
post_status="error"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "mas_syn2mas_migration: postcondicion: mas_users=${mas_user_count} synapse_users=${synapse_user_count} status=${post_status}" >&2
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# PASO 7: Emitir JSON final
|
|
||||||
# =========================================================================
|
|
||||||
local dry_run_log_json apply_log_json
|
|
||||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
|
||||||
apply_log_json=$(printf '%s' "$apply_log_file" | jq -Rs '.')
|
|
||||||
|
|
||||||
echo "{\"status\":\"${post_status}\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":${apply_log_json},\"conflicts\":${conflicts},\"users_migrated\":${users_migrated},\"duration_s\":${duration_s}}"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Ejecutar si se llama directamente (no sourced)
|
|
||||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
||||||
mas_syn2mas_migration "$@"
|
|
||||||
fi
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Tests para mas_syn2mas_migration
|
|
||||||
# Verifica arg parsing sin conectar al VPS real.
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
source "$SCRIPT_DIR/mas_syn2mas_migration.sh"
|
|
||||||
|
|
||||||
PASS=0
|
|
||||||
FAIL=0
|
|
||||||
|
|
||||||
assert_exit() {
|
|
||||||
local test_name="$1" expected_exit="$2"
|
|
||||||
shift 2
|
|
||||||
local actual_exit=0
|
|
||||||
set +e
|
|
||||||
"$@" >/dev/null 2>&1
|
|
||||||
actual_exit=$?
|
|
||||||
set -e
|
|
||||||
if [[ "$actual_exit" == "$expected_exit" ]]; then
|
|
||||||
echo "PASS: $test_name"
|
|
||||||
((PASS++)) || true
|
|
||||||
else
|
|
||||||
echo "FAIL: $test_name — expected exit $expected_exit, got $actual_exit"
|
|
||||||
((FAIL++)) || true
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_stdout_contains() {
|
|
||||||
local test_name="$1" needle="$2"
|
|
||||||
shift 2
|
|
||||||
local output actual_exit=0
|
|
||||||
set +e
|
|
||||||
output=$("$@" 2>/dev/null)
|
|
||||||
actual_exit=$?
|
|
||||||
set -e
|
|
||||||
if echo "$output" | grep -q "$needle"; then
|
|
||||||
echo "PASS: $test_name"
|
|
||||||
((PASS++)) || true
|
|
||||||
else
|
|
||||||
echo "FAIL: $test_name — expected stdout to contain '$needle', got: $output"
|
|
||||||
((FAIL++)) || true
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Test: aborta con error cuando faltan args obligatorios
|
|
||||||
assert_exit "aborta con error cuando faltan args obligatorios" 1 \
|
|
||||||
mas_syn2mas_migration
|
|
||||||
|
|
||||||
# Test: help no devuelve error
|
|
||||||
assert_exit "help no devuelve error" 0 \
|
|
||||||
mas_syn2mas_migration --help
|
|
||||||
|
|
||||||
# Test: argumento desconocido retorna exit 1
|
|
||||||
assert_exit "argumento desconocido retorna exit 1" 1 \
|
|
||||||
mas_syn2mas_migration --unknown-flag
|
|
||||||
|
|
||||||
# Test: max-conflicts invalido retorna exit 1
|
|
||||||
assert_exit "max-conflicts invalido retorna exit 1" 1 \
|
|
||||||
mas_syn2mas_migration \
|
|
||||||
--ssh-host fake-host \
|
|
||||||
--mas-container fake-container \
|
|
||||||
--synapse-config-path /fake/homeserver.yaml \
|
|
||||||
--log-dir "/tmp/test_mas_migration_$$" \
|
|
||||||
--max-conflicts "not-a-number"
|
|
||||||
|
|
||||||
# Test: help emite JSON valido con status=help
|
|
||||||
assert_stdout_contains "help emite JSON con status help" '"status":"help"' \
|
|
||||||
mas_syn2mas_migration --help
|
|
||||||
|
|
||||||
# Test: falta --ssh-host emite JSON con status=error
|
|
||||||
assert_stdout_contains "falta ssh-host emite JSON error" '"status":"error"' \
|
|
||||||
mas_syn2mas_migration \
|
|
||||||
--mas-container fake-container \
|
|
||||||
--synapse-config-path /fake/homeserver.yaml \
|
|
||||||
--log-dir "/tmp/test_mas_migration_$$"
|
|
||||||
|
|
||||||
# Test: falta --log-dir emite JSON con status=error
|
|
||||||
assert_stdout_contains "falta log-dir emite JSON error" '"status":"error"' \
|
|
||||||
mas_syn2mas_migration \
|
|
||||||
--ssh-host fake-host \
|
|
||||||
--mas-container fake-container \
|
|
||||||
--synapse-config-path /fake/homeserver.yaml
|
|
||||||
|
|
||||||
# Limpieza
|
|
||||||
rm -rf "/tmp/test_mas_migration_$$" 2>/dev/null || true
|
|
||||||
|
|
||||||
echo "---"
|
|
||||||
echo "Results: $PASS passed, $FAIL failed"
|
|
||||||
[[ $FAIL -eq 0 ]] || exit 1
|
|
||||||
@@ -32,16 +32,16 @@ file_path: "bash/functions/infra/pre_commit_hook_install.sh"
|
|||||||
source bash/functions/infra/pre_commit_hook_install.sh
|
source bash/functions/infra/pre_commit_hook_install.sh
|
||||||
|
|
||||||
# Instalar en el repo actual
|
# Instalar en el repo actual
|
||||||
pre_commit_hook_install $HOME/fn_registry
|
pre_commit_hook_install /home/lucas/fn_registry
|
||||||
# INSTALLED $HOME/fn_registry/.git/hooks/pre-commit
|
# INSTALLED /home/lucas/fn_registry/.git/hooks/pre-commit
|
||||||
|
|
||||||
# Idempotente: segunda llamada no sobreescribe
|
# Idempotente: segunda llamada no sobreescribe
|
||||||
pre_commit_hook_install $HOME/fn_registry
|
pre_commit_hook_install /home/lucas/fn_registry
|
||||||
# SKIP $HOME/fn_registry/.git/hooks/pre-commit (already installed)
|
# SKIP /home/lucas/fn_registry/.git/hooks/pre-commit (already installed)
|
||||||
|
|
||||||
# Forzar reinstalacion (hace backup del hook anterior)
|
# Forzar reinstalacion (hace backup del hook anterior)
|
||||||
pre_commit_hook_install $HOME/fn_registry --force
|
pre_commit_hook_install /home/lucas/fn_registry --force
|
||||||
# INSTALLED $HOME/fn_registry/.git/hooks/pre-commit
|
# INSTALLED /home/lucas/fn_registry/.git/hooks/pre-commit
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notas
|
## Notas
|
||||||
@@ -58,5 +58,5 @@ Si no puede localizar `fn_registry`, el hook imprime un aviso y sale con exit 0
|
|||||||
|
|
||||||
Configurar `FN_REGISTRY_ROOT` en el perfil del shell para garantizar que el hook siempre encuentre el registry:
|
Configurar `FN_REGISTRY_ROOT` en el perfil del shell para garantizar que el hook siempre encuentre el registry:
|
||||||
```bash
|
```bash
|
||||||
export FN_REGISTRY_ROOT=$HOME/fn_registry
|
export FN_REGISTRY_ROOT=/home/lucas/fn_registry
|
||||||
```
|
```
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user