Compare commits
662 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8dd398f28 | |||
| 61d95cc47f | |||
| 577fb27470 | |||
| 5475dd3f5c | |||
| 09b7ddf6d0 | |||
| c4e94bbe56 | |||
| dbefe793f2 | |||
| 6483bc4861 | |||
| fecb02b115 | |||
| 87938c1886 | |||
| 95a130136b | |||
| aba5642908 | |||
| 168e604602 | |||
| d50159c5a7 | |||
| 9a7ad34e55 | |||
| 4193157124 | |||
| 6ff1414cf0 | |||
| 37f4203b40 | |||
| c4ec22bb4f | |||
| 427f47d72c | |||
| 9f25745e1e | |||
| 82247c86ce | |||
| 0ef2d8fec2 | |||
| aa14fd08e0 | |||
| e43d4f3bbf | |||
| b0c73b5439 | |||
| bbbe4651c2 | |||
| 7dfe0eb6a9 | |||
| 47a4268b9b | |||
| 0c90b88e08 | |||
| 5583a9da00 | |||
| 4a0cb7c615 | |||
| fa97a7c92a | |||
| 0097633a3b | |||
| 04591296b2 | |||
| 96039dbbbf | |||
| 5230462b8d | |||
| 4a39d3fdef | |||
| 4de13cea44 | |||
| d9d542c570 | |||
| cf6115517c | |||
| 108f134020 | |||
| 8123399189 | |||
| 6324122168 | |||
| 25b411f22e | |||
| 54dc8fcb39 | |||
| a40e1bb847 | |||
| 2c8bcf7996 | |||
| af2a25bf87 | |||
| 0abc8bcb3e | |||
| 524e00a0a6 | |||
| eba3c70e76 | |||
| e2d60fa72e | |||
| dfae96ad45 | |||
| 2f4473f22c | |||
| ca223a19c6 | |||
| 0779ab85ca | |||
| 4d85a2eebb | |||
| cef4617956 | |||
| 813cbfd5c2 | |||
| a11d76264a | |||
| 6e8dedeb2f | |||
| 762c457978 | |||
| 9c26285424 | |||
| 45f479e60f | |||
| 3fca67e443 | |||
| 9b2460633c | |||
| dfb6fda06d | |||
| 827ff13c4a | |||
| 7a19ceca89 | |||
| 298b944169 | |||
| e40e41555b | |||
| 5a7f417899 | |||
| d00f9df2dd | |||
| ff959be3ef | |||
| f45563ee31 | |||
| 2b8cd99100 | |||
| 1ca13e02b2 | |||
| 3146832d1b | |||
| 64f929c10d | |||
| 1ec32ae0fd | |||
| f851a087f2 | |||
| 984e6ae56d | |||
| 92f3b36b10 | |||
| a4677f8adb | |||
| 9ab6291a41 | |||
| 50a4db72b1 | |||
| dfdf0ac073 | |||
| c2c849df7e | |||
| 39f6ae692b | |||
| eb027e70f4 | |||
| 8fbbbce07e | |||
| f115718564 | |||
| 5c0eabda8e | |||
| 669af841e1 | |||
| 650313b1c2 | |||
| c40007eeaf | |||
| 1f13b1e79c | |||
| 031c3aa632 | |||
| 7b50969307 | |||
| fc6072c28c | |||
| b0e3f4a363 | |||
| 38112053a0 | |||
| 1d174ee5c9 | |||
| 6e3b32ca34 | |||
| 932bbe5c41 | |||
| 9440b9af82 | |||
| f96d6eb7c4 | |||
| 95102941f1 | |||
| 521d908719 | |||
| 02a2c00175 | |||
| 8ca7708a12 | |||
| 0fdc0fdec3 | |||
| d6eaf3fdc7 | |||
| 5dc2352ac5 | |||
| 39e2ab7f0d | |||
| 221919448e | |||
| 68b82c9574 | |||
| 335b8a4ae6 | |||
| bf31fa0d03 | |||
| c39f2f3e63 | |||
| 6481fc0cc5 | |||
| 3190e06ebe | |||
| f8ae2fe25b | |||
| ffc31a8c19 | |||
| 48a274685b | |||
| 5b68359dfc | |||
| c2ddfb8558 | |||
| 603018caf2 | |||
| aff0ee6fea | |||
| d37fa1393a | |||
| 759c784267 | |||
| 52ffc7dcc1 | |||
| ac55a3e3d9 | |||
| edb979baa9 | |||
| 3d1850ddbe | |||
| 3c1f4a6dfa | |||
| f07a6daa30 | |||
| 0eeb4a8ed8 | |||
| a3fac708b6 | |||
| 52879c0028 | |||
| 8720eb19af | |||
| 9e4527bb89 | |||
| 5209cfa7ac | |||
| 155b879d29 | |||
| c1f04929f4 | |||
| 50cd93aa05 | |||
| 1abc3b4cf4 | |||
| 330db43b5f | |||
| 211f38bc8d | |||
| 613793e128 | |||
| 080792a6c0 | |||
| 43cbf4127f | |||
| 9f95956e19 | |||
| 65e2305761 | |||
| 44147da419 | |||
| bc64f2613b | |||
| 0e5b494e12 | |||
| 747b318cc5 | |||
| d16ce44bc3 | |||
| 45122bc458 | |||
| 3183b4c879 | |||
| 03eacbabb2 | |||
| cef4db9a87 | |||
| 1afaeacd57 | |||
| deac2336b6 | |||
| aad2792754 | |||
| 10642fdc45 | |||
| 92020d2d78 | |||
| cd0a79d72b | |||
| 3b6aa6525f | |||
| 54631c43c7 | |||
| 655b57c4b5 | |||
| 7faa8184ae | |||
| 816137315e | |||
| 9a111d16c7 | |||
| ea6ceafe51 | |||
| d0dc7b5a19 | |||
| 3b81521e9d | |||
| aa0a23293f | |||
| 187dd036e5 | |||
| 4b45f4e6d1 | |||
| 2a6bce4918 | |||
| 3d6399c0e3 | |||
| b9258f84a5 | |||
| 638329a562 | |||
| 1a06e014f5 | |||
| d5d05334f5 | |||
| 844f5438fe | |||
| e329d74ec0 | |||
| f90614a622 | |||
| 68af555313 | |||
| 025695dbe9 | |||
| 96584e481f | |||
| 766ec7ddc2 | |||
| aeb7180e9d | |||
| 9b56f7b75e | |||
| c9507b8dc1 | |||
| baa2edfa38 | |||
| 4dff320d5c | |||
| d8863ceda2 | |||
| c9fcb15384 | |||
| 5e868a2977 | |||
| 76fab22827 | |||
| 176844d7f5 | |||
| 31695e89a8 | |||
| 669875fb46 | |||
| 6d94be34a9 | |||
| d95fe45a3d | |||
| b9252b5ce2 | |||
| 4d47effd39 | |||
| 7b93ce8f3e | |||
| 67870392ab | |||
| 9316b843f6 | |||
| 6b9ff9781d | |||
| 487c48effa | |||
| 4eca2d533c | |||
| f0f840e6e0 | |||
| 7ff90cef4f | |||
| e62d51d845 | |||
| a803fcb4fc | |||
| d00c93fc19 | |||
| 99a2890be2 | |||
| 3b7d0564bb | |||
| 45dacf540b | |||
| 2eb5ee0666 | |||
| e67932c83c | |||
| 04a12231c3 | |||
| e5ae9a134c | |||
| bdafaf3aa1 | |||
| 02f7f0b708 | |||
| 8ea554bb5e | |||
| 8a425521da | |||
| f174f2fd0a | |||
| 355594d074 | |||
| fd7609fe90 | |||
| dacecfbbb7 | |||
| 3238eaeddf | |||
| 995f273fa5 | |||
| 866154ad73 | |||
| 8efc5050cb | |||
| 3fb60ee649 | |||
| e181f67a2d | |||
| a3114bf840 | |||
| e59ae9aca1 | |||
| c050a38f38 | |||
| c60c310074 | |||
| fe035c065d | |||
| 192ad656a4 | |||
| c0c8d6499e | |||
| 505f85e3c4 | |||
| fc7d482b4f | |||
| f9979c3c89 | |||
| 46def2945a | |||
| 4e89508246 | |||
| 77d799d458 | |||
| 6c14259184 | |||
| 7b9cb6a9c8 | |||
| 68246e20b1 | |||
| d63d58ccb5 | |||
| 2a3a40b9d9 | |||
| 762ecec872 | |||
| c0ac4ade09 | |||
| a991393053 | |||
| 892ccab8d5 | |||
| 70c83c65b0 | |||
| 8a7e756fe3 | |||
| 4a4ddba9f6 | |||
| d5f47d1a18 | |||
| 37c35560e9 | |||
| f174b96028 | |||
| 43978360ff | |||
| 432400ee20 | |||
| dacebe1841 | |||
| c42125946d | |||
| 4c9ce72395 | |||
| 8b43f7993b | |||
| cf9e2cd3d6 | |||
| 7a99c1a9d6 | |||
| 546237db85 | |||
| 1ed7e32067 | |||
| bd5e5a435b | |||
| 67e689ff1a | |||
| 06eb2dff3b | |||
| a2bd3126c8 | |||
| 710d42d6b9 | |||
| 072d900fcb | |||
| cfebd07124 | |||
| f2be6fc057 | |||
| d392563549 | |||
| 2af8196451 | |||
| ad74768630 | |||
| a38ca7e8db | |||
| 3d97968887 | |||
| ade6227ffe | |||
| 13789e2c66 | |||
| 6758adc1d5 | |||
| 7c12015855 | |||
| 0f6859678c | |||
| 84798510b0 | |||
| 6eace09826 | |||
| cb39a6693a | |||
| 36d120b745 | |||
| 86dd37b0c8 | |||
| bb0f2ca678 | |||
| ec0bc672f6 | |||
| f08ba6914c | |||
| 7dd6ab5328 | |||
| f6dd4d59a1 | |||
| d8cdc8ab88 | |||
| 20ddc5d7aa | |||
| 2846311f8d | |||
| ed0043b8ac | |||
| bee3911f3f | |||
| 4285b8b180 | |||
| 7c955fa749 | |||
| f0b7be79fb | |||
| d4f05adeba | |||
| c4c9f96117 | |||
| 633d5aeafe | |||
| 17103c1ee1 | |||
| c8a39be9bd | |||
| b304f65876 | |||
| c9010a024f | |||
| 3434e2b2be | |||
| 52282e1960 | |||
| 7a579ee67a | |||
| 7c230383ad | |||
| e604fa5f47 | |||
| 5580791686 | |||
| 3afd7a5319 | |||
| 3d1b2268b4 | |||
| 8bebe9da0f | |||
| 53a7355f0b | |||
| d99c285725 | |||
| 2505dd8d6a | |||
| 1121dfa48b | |||
| d90e29ad05 | |||
| 0727e0e8d5 | |||
| ba012d98bc | |||
| b165049a13 | |||
| 4d477c67c0 | |||
| 0d5678c961 | |||
| a8e2aa85f8 | |||
| 2a4d35399b | |||
| 391915411e | |||
| 4aaf49bfc6 | |||
| 08de1ae5eb | |||
| c91a3d1ec6 | |||
| 13d932f69c | |||
| f705d9b8ea | |||
| f84d327410 | |||
| 9c2f93629b | |||
| bcefcb9af7 | |||
| b14dce2bc6 | |||
| 85c572e770 | |||
| 9a89885ce6 | |||
| d095ceaafa | |||
| 2a0346f48b | |||
| b4e25ea002 | |||
| 77f2060e6b | |||
| 8f9a925179 | |||
| 2f3fff3536 | |||
| a7eb9814ae | |||
| a8024e6d42 | |||
| 6d94d9c85a | |||
| 49a4d08c04 | |||
| d5773369af | |||
| f49e014f41 | |||
| ab48a8169d | |||
| 2b707fb44e | |||
| 6306b23a9f | |||
| 6bb8cf8315 | |||
| 93b7947d7c | |||
| 9584a86fb7 | |||
| defc0afc27 | |||
| 9f6633d5bf | |||
| 7dadf874e1 | |||
| ba90214af6 | |||
| 5bbac3e4f7 | |||
| 131021b1a7 | |||
| e42555fd9c | |||
| 3a26eb28e5 | |||
| c1a17b707c | |||
| 4ea1e0d8a4 | |||
| b1a9d2ec3f | |||
| 2b8707a706 | |||
| 241bfbf6d9 | |||
| 40530d757e | |||
| 0f3661b566 | |||
| 9c44c709e9 | |||
| 8892ab9978 | |||
| 7ec86d82a3 | |||
| f728b36e8d | |||
| 3431d3070b | |||
| 576df067d4 | |||
| a46a225d04 | |||
| f74b482cc0 | |||
| 89abfdc257 | |||
| 77e395b913 | |||
| b65a006d45 | |||
| 5994548f0b | |||
| 0871ae54ea | |||
| 9576d69032 | |||
| 64dadf114d | |||
| baaa1d1dc8 | |||
| 3074cd5f0c | |||
| 15edc99c72 | |||
| 153178c545 | |||
| fac215bd21 | |||
| 9822e68c55 | |||
| 764b73209e | |||
| e7987c4cd7 | |||
| 942ff4b1a4 | |||
| f5977c46c6 | |||
| 71ccf8d03c | |||
| 510b49287a | |||
| bb6b309efa | |||
| 56db22a908 | |||
| 2a1b7b0aeb | |||
| d037eca4ae | |||
| b9d543a465 | |||
| 07f52594d1 | |||
| c7b426ff5a | |||
| 4582274ba4 | |||
| d140801337 | |||
| 4563f1bb5e | |||
| 59b7e89028 | |||
| 019d8c1ee9 | |||
| 5e783e7a24 | |||
| a450a88b16 | |||
| 5b47317cef | |||
| 3384c38d02 | |||
| b370d96504 | |||
| 8cae114c7e | |||
| c2c6fc5304 | |||
| 94f725c50b | |||
| 9b23e6f85a | |||
| 238a94f7a6 | |||
| 236c771e4e | |||
| 0ffd84cf7d | |||
| e14643a50b | |||
| 76830c5e22 | |||
| 90a388f5ab | |||
| 82e40f0c21 | |||
| 8d650326db | |||
| dd3eec7d35 | |||
| 9276689cb6 | |||
| b4584cbaa6 | |||
| 1cf963a1fb | |||
| ce5bc50210 | |||
| 439e203113 | |||
| 522afdd4bd | |||
| ca644dabaa | |||
| 9d9c00df98 | |||
| a1c5dc3e92 | |||
| c85980f604 | |||
| eff5fb332a | |||
| 658a4a24ef | |||
| aabfd90a87 | |||
| 0207f93303 | |||
| e1423f196b | |||
| ae6954a02f | |||
| aede8f7613 | |||
| 6d1e0498ba | |||
| 6cce5e2593 | |||
| d3a7ed9062 | |||
| e7f733c393 | |||
| d4bb4a9324 | |||
| e4900b6fd6 | |||
| 39540d9ae8 | |||
| 10899364d4 | |||
| dc5fdd7358 | |||
| bb1293f6b9 | |||
| 55b3b61498 | |||
| 484ed520cd | |||
| 497f03c747 | |||
| cfe4543d39 | |||
| 399b967c59 | |||
| 061926b86a | |||
| acb0ebed97 | |||
| d5d7be6100 | |||
| 1566a43395 | |||
| afbde4573a | |||
| 63e447fc3d | |||
| 34fcbf29cb | |||
| 256799fcfd | |||
| 21cf3db111 | |||
| ed38543db4 | |||
| 78771fbebc | |||
| c15f58bdeb | |||
| 6d4bf108bb | |||
| 5b7c9b844b | |||
| f0d1bb9ae8 | |||
| 04cfd33f99 | |||
| a8c00f169b | |||
| c4d34530e8 | |||
| 90a410c00a | |||
| 6276ca5a4a | |||
| 8e63f99eb6 | |||
| 9ca70bbb69 | |||
| ed1f38c7da | |||
| 1664d68b50 | |||
| 1871ef31b4 | |||
| ec3c97b200 | |||
| 18e3dc7603 | |||
| fc229cac79 | |||
| ec555b43d1 | |||
| c8de86d7c9 | |||
| bd110b76e1 | |||
| dc10ccceaa | |||
| c040a90a8f | |||
| ec4599a230 | |||
| 1f4bd3f431 | |||
| bebf4aad45 | |||
| 11ba185fef | |||
| 730340d123 | |||
| c848216396 | |||
| 2698e0a6cb | |||
| 47f2b1a128 | |||
| 0c02cb7574 | |||
| 320810ec25 | |||
| 91f585c534 | |||
| 299ff126d9 | |||
| 931eb81458 | |||
| c604d1f600 | |||
| 20bcc65f61 | |||
| f5612ef1b5 | |||
| a92deeaf3f | |||
| 1e936cf04a | |||
| ea16057803 | |||
| 4493fd8979 | |||
| cc1ee8d5e3 | |||
| 0ad5c85f5a | |||
| d02d410dcd | |||
| cdf3c95622 | |||
| a7fea10383 | |||
| 3846dc12a9 | |||
| c5fd84432f | |||
| 4c4dabb7a3 | |||
| 1b62cec0a2 | |||
| ecc348f182 | |||
| 41209f1ef8 | |||
| 58a4aefcc4 | |||
| bbb79f821e | |||
| 05fbd4f5b5 | |||
| 7e7331eb2d | |||
| 0fbbf37548 | |||
| 2af39463de | |||
| 5f2458238f | |||
| aadec0b96c | |||
| 1c68ce6217 | |||
| 7265603b55 | |||
| 74cea09ac0 | |||
| b1e66fa7a4 | |||
| 81a7a8c7c1 | |||
| 9cb7d68abe | |||
| 98122b446d | |||
| 4a31cf9d63 | |||
| 2c26be6ec6 | |||
| f723daa014 | |||
| 1e9900bed3 | |||
| aebff8b906 | |||
| db45089922 | |||
| 9c1b018ffa | |||
| a98431a12a | |||
| 0fe17b0fb2 | |||
| e37dbc3f35 | |||
| 82d9abf260 | |||
| 50aec2d0cf | |||
| e979a55f8a | |||
| 30f1582046 | |||
| cf0540d7fa | |||
| c05fac746c | |||
| 34efd25e91 | |||
| cc0bc6c8aa | |||
| 626cb5d98e | |||
| f87cb38a67 | |||
| 0970139418 | |||
| 376dd87b6b | |||
| 4d8469a649 | |||
| a929fa4ccb | |||
| ff3e19fd22 | |||
| b509d1715e | |||
| b93f6e736f | |||
| ec13c19505 | |||
| 203b86e827 | |||
| 90de1c7025 | |||
| 2b587612d5 | |||
| 2342a6e3bd | |||
| 0021596ff0 | |||
| 56ec8cd401 | |||
| fe87efd79d | |||
| b783027406 | |||
| 904ee1eb83 | |||
| 1742ced6df | |||
| 93145cf08c | |||
| da6bcb10d6 | |||
| 6fc97fc8c8 | |||
| 93d9821f64 | |||
| 29367cbe31 | |||
| ec397aecd3 | |||
| 2e9d939f8e | |||
| 064a24f093 | |||
| fede623a82 | |||
| 2a52b930b9 | |||
| bf2f790e6e | |||
| 08a79b77db | |||
| 22a6200b69 | |||
| 7e7f6aa6d6 | |||
| d6fe3f844c | |||
| d0803019b5 | |||
| f16e7641fd | |||
| 3b41625001 | |||
| c602d2284b | |||
| d96e10b0fc | |||
| 8e36d3e1f5 | |||
| bbe4fe0ed1 | |||
| e105c5cac1 | |||
| 578776fccf | |||
| cb756a999a | |||
| e0577ceefe | |||
| 024dd8c1e8 | |||
| 9e98119145 | |||
| fd8943f131 | |||
| f7253d5948 | |||
| 1c5636c270 | |||
| ca0403c8ab | |||
| aa25f55f63 | |||
| e29d1bf345 | |||
| f3aedf8d6c | |||
| 26cf51366f | |||
| 81c582ae0e | |||
| 6f000512d2 | |||
| 8f78a00063 | |||
| 6c2a137aef | |||
| 6cd856ca99 | |||
| 064696c558 | |||
| 0f28e9b61a | |||
| 1ea56009a2 | |||
| 6cc2481a16 | |||
| 44018bd17d | |||
| 28c35bb3e0 | |||
| b8b557baf6 | |||
| 727b4bb3ed | |||
| 9bbdfc41bd | |||
| b07f8cf166 | |||
| 1a1e8b3398 | |||
| 39d2a61686 | |||
| bf0bc47a3f | |||
| 2cffaad127 | |||
| 9a3daac657 | |||
| b8f9ffcb59 | |||
| a7171f05f6 | |||
| b53667a2aa | |||
| 5b60fa6454 | |||
| 2c0e744ebf | |||
| ae16f09688 | |||
| 73a3638ad9 | |||
| 7b0260cedd | |||
| 61fc1cfe1b | |||
| 8ff6f7e778 |
@@ -0,0 +1,8 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets).
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md).
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [["@uncaged/*"]],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": ["@uncaged/workflow-dashboard"]
|
||||
}
|
||||
@@ -1,27 +1,3 @@
|
||||
---
|
||||
description: Ban dynamic import() in production code — use static imports instead
|
||||
globs: packages/*/src/**/*.ts
|
||||
alwaysApply: true
|
||||
---
|
||||
# No Dynamic Import
|
||||
|
||||
# No Dynamic Import in Production Code
|
||||
|
||||
## Rule
|
||||
|
||||
Do NOT use `await import()` or dynamic `import()` expressions in production source code.
|
||||
Always use static top-level `import` statements.
|
||||
|
||||
## Exception (must include a comment explaining why)
|
||||
|
||||
1. **Bundle loader** — loads user-authored workflow bundles whose paths are only known at runtime
|
||||
|
||||
When suppressing, add a comment directly above:
|
||||
|
||||
```ts
|
||||
// Dynamic import required: user bundle path resolved at runtime
|
||||
const mod = await import(bundlePath);
|
||||
```
|
||||
|
||||
## Test Files
|
||||
|
||||
Test files (`__tests__/**`) are exempt.
|
||||
See [docs/no-dynamic-import.md](../../docs/no-dynamic-import.md) for full rules.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Sync Readme
|
||||
|
||||
See [docs/sync-readme.md](../../docs/sync-readme.md) for full rules.
|
||||
@@ -0,0 +1,25 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Check
|
||||
run: bun run check
|
||||
|
||||
- name: Test
|
||||
run: bun run test:ci
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "🔍 Running check (tsc + biome + lint-log-tags)..."
|
||||
bun run check
|
||||
|
||||
echo "🧪 Running tests..."
|
||||
bun run test
|
||||
|
||||
echo "✅ All checks passed!"
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug or unexpected behavior
|
||||
labels: bug
|
||||
---
|
||||
|
||||
## Describe the bug
|
||||
|
||||
A clear description of what the bug is.
|
||||
|
||||
## To reproduce
|
||||
|
||||
Steps or commands to reproduce:
|
||||
|
||||
```bash
|
||||
uwf ...
|
||||
```
|
||||
|
||||
## Expected behavior
|
||||
|
||||
What you expected to happen.
|
||||
|
||||
## Actual behavior
|
||||
|
||||
What actually happened. Include error messages or logs.
|
||||
|
||||
## Environment
|
||||
|
||||
- OS:
|
||||
- Bun version:
|
||||
- uwf version (`uwf --version`):
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature or improvement
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
## What
|
||||
|
||||
Describe the feature or improvement.
|
||||
|
||||
## Why
|
||||
|
||||
Why is this needed? What problem does it solve?
|
||||
|
||||
## Proposed solution
|
||||
|
||||
How should it work? Include API sketches, CLI examples, or workflow YAML snippets if applicable.
|
||||
@@ -0,0 +1,15 @@
|
||||
## What
|
||||
|
||||
What this PR does.
|
||||
|
||||
## Why
|
||||
|
||||
Why the change is needed.
|
||||
|
||||
## Changes
|
||||
|
||||
- `path/to/file` — what changed and why
|
||||
|
||||
## Ref
|
||||
|
||||
Fixes #
|
||||
@@ -0,0 +1,28 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
- name: Lint
|
||||
run: bunx biome check .
|
||||
|
||||
- name: Test
|
||||
run: bun run test:ci
|
||||
+10
@@ -4,3 +4,13 @@ bun.lock
|
||||
*.tgz
|
||||
tsconfig.tsbuildinfo
|
||||
.npmrc
|
||||
|
||||
bunfig.toml
|
||||
xiaoju/
|
||||
solve-issue-entry.ts
|
||||
packages/workflow-template-develop/develop.esm.js
|
||||
.DS_Store
|
||||
*.py
|
||||
.claude
|
||||
tmp.worktrees/
|
||||
.worktrees/
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
name: "e2e-walkthrough"
|
||||
description: "End-to-end walkthrough of uwf CLI. Dogfooding: uwf tests uwf. Each role validates a phase of the CLI surface inside an isolated Docker container."
|
||||
roles:
|
||||
bootstrap:
|
||||
description: "Start Docker container with isolated storage, verify uwf is runnable"
|
||||
goal: "You are an E2E test runner. Set up an isolated Docker environment and verify basic uwf functionality."
|
||||
capabilities:
|
||||
- docker
|
||||
- shell
|
||||
procedure: |
|
||||
1. Start a Docker container with isolated storage:
|
||||
```
|
||||
docker run -d --name uwf-e2e-$$ \
|
||||
-v $HOME:$HOME \
|
||||
-e HOME=$HOME \
|
||||
-e UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage \
|
||||
-w ~/repos/workflow \
|
||||
node:22-bookworm \
|
||||
sleep infinity
|
||||
```
|
||||
2. Inside the container, install bun, install deps, then `bun link` all packages
|
||||
so that `uwf`, `uwf-hermes`, `uwf-builtin` are on PATH (from source):
|
||||
```
|
||||
docker exec uwf-e2e-$$ bash -c '
|
||||
# Install bun
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
|
||||
# Isolated storage
|
||||
mkdir -p $UNCAGED_WORKFLOW_STORAGE_ROOT
|
||||
|
||||
# Install workspace deps
|
||||
cd ~/repos/workflow && bun install --frozen-lockfile
|
||||
|
||||
# bun link each package that has a bin entry
|
||||
cd packages/cli-workflow && bun link && cd ../..
|
||||
cd packages/workflow-agent-hermes && bun link && cd ../..
|
||||
cd packages/workflow-agent-builtin && bun link && cd ../..
|
||||
'
|
||||
```
|
||||
3. Verify all three commands are available inside the container:
|
||||
```
|
||||
docker exec uwf-e2e-$$ bash -c 'export PATH="$HOME/.bun/bin:$PATH" && uwf --version'
|
||||
docker exec uwf-e2e-$$ bash -c 'export PATH="$HOME/.bun/bin:$PATH" && uwf-hermes --help'
|
||||
docker exec uwf-e2e-$$ bash -c 'export PATH="$HOME/.bun/bin:$PATH" && uwf-builtin --help'
|
||||
```
|
||||
4. Copy host config if it exists:
|
||||
```
|
||||
docker exec uwf-e2e-$$ bash -c '
|
||||
if [ -f $HOME/.uncaged/workflow/config.yaml ]; then
|
||||
cp $HOME/.uncaged/workflow/config.yaml $UNCAGED_WORKFLOW_STORAGE_ROOT/config.yaml
|
||||
fi
|
||||
'
|
||||
```
|
||||
|
||||
Report the container name and confirm uwf + agents are working.
|
||||
Set containerName to the Docker container name for subsequent roles.
|
||||
output: "Report uwf version and container readiness. Set $status to pass with containerName, or fail with error."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "pass" }
|
||||
containerName: { type: string }
|
||||
required: [$status, containerName]
|
||||
- properties:
|
||||
$status: { const: "fail" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
|
||||
config-and-registry:
|
||||
description: "Validate uwf config commands and workflow registration"
|
||||
goal: "You are an E2E test runner. Validate uwf config operations and workflow registration inside the Docker container."
|
||||
capabilities:
|
||||
- docker
|
||||
- shell
|
||||
procedure: |
|
||||
Use the container from the previous step (containerName is in your prompt).
|
||||
All commands run via: `docker exec <containerName> bash -c '...'`
|
||||
All commands use `uwf` (installed via `bun link` inside the container).
|
||||
Remember to set env vars in each exec:
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
export UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
|
||||
|
||||
Config tests:
|
||||
1. `uwf config list` — verify it returns valid JSON
|
||||
2. `uwf config set models.test.name test-model` — set a test key
|
||||
3. `uwf config get models.test.name` — verify it returns "test-model"
|
||||
|
||||
Workflow registration tests:
|
||||
4. `uwf workflow add ~/repos/workflow/examples/solve-issue.yaml` — register workflow
|
||||
5. Verify the output contains a hash
|
||||
6. `uwf workflow list` — verify non-empty array
|
||||
7. Capture the workflow name from the list
|
||||
8. `uwf workflow show <name>` — verify it returns roles
|
||||
|
||||
Report all test results with pass/fail counts.
|
||||
output: "Report test results. Set $status to pass (with workflowName and containerName) or fail."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "pass" }
|
||||
workflowName: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, workflowName, containerName]
|
||||
- properties:
|
||||
$status: { const: "fail" }
|
||||
error: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, error, containerName]
|
||||
|
||||
thread-ops:
|
||||
description: "Test thread start, list, show, and exec"
|
||||
goal: "You are an E2E test runner. Validate thread creation and execution inside the Docker container."
|
||||
capabilities:
|
||||
- docker
|
||||
- shell
|
||||
procedure: |
|
||||
Use the container (containerName) and workflow (workflowName) from your prompt.
|
||||
All commands via: `docker exec <containerName> bash -c '...'`
|
||||
Set env: PATH="$HOME/.bun/bin:$PATH" UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
|
||||
|
||||
1. `uwf thread start <workflowName> -p 'E2E test: what is 2+2?'` — capture thread ID from JSON output
|
||||
2. `uwf thread list` — verify the thread appears in the list
|
||||
3. `uwf thread show <threadId>` — verify head pointer exists
|
||||
4. `uwf thread exec <threadId> --agent uwf-builtin` — execute one step
|
||||
5. Verify exec returns JSON with a head field
|
||||
|
||||
Report results. Pass threadId and containerName forward.
|
||||
output: "Report test results. Set $status to pass (with threadId, workflowName, containerName) or fail."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "pass" }
|
||||
threadId: { type: string }
|
||||
workflowName: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, threadId, workflowName, containerName]
|
||||
- properties:
|
||||
$status: { const: "fail" }
|
||||
error: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, error, containerName]
|
||||
|
||||
inspect:
|
||||
description: "Test step list/show, thread read, and CAS operations"
|
||||
goal: "You are an E2E test runner. Validate read and inspect operations inside the Docker container."
|
||||
capabilities:
|
||||
- docker
|
||||
- shell
|
||||
procedure: |
|
||||
Use the container (containerName) and threadId from your prompt.
|
||||
All commands via: `docker exec <containerName> bash -c '...'`
|
||||
Set env: PATH="$HOME/.bun/bin:$PATH" UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
|
||||
|
||||
Step inspection:
|
||||
1. `uwf step list <threadId>` — verify steps array has length > 1
|
||||
2. Capture the last step hash from the output
|
||||
3. `uwf step show <lastStepHash>` — verify it returns a role field
|
||||
|
||||
Thread read:
|
||||
4. `uwf thread read <threadId>` — verify non-empty output
|
||||
|
||||
CAS operations:
|
||||
5. `uwf cas get <lastStepHash>` — verify returns a type field
|
||||
6. `uwf cas has <lastStepHash>` — verify exits 0
|
||||
7. `uwf cas refs <lastStepHash>` — list refs (may be empty)
|
||||
8. `uwf cas walk <lastStepHash>` — verify returns non-empty array
|
||||
|
||||
Report results. Pass threadId, lastStepHash, workflowName, containerName forward.
|
||||
output: "Report test results. Set $status to pass (with threadId, lastStepHash, workflowName, containerName) or fail."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "pass" }
|
||||
threadId: { type: string }
|
||||
lastStepHash: { type: string }
|
||||
workflowName: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, threadId, lastStepHash, workflowName, containerName]
|
||||
- properties:
|
||||
$status: { const: "fail" }
|
||||
error: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, error, containerName]
|
||||
|
||||
cancel-and-fork:
|
||||
description: "Test thread cancel, step fork, and log inspection"
|
||||
goal: "You are an E2E test runner. Validate cancel, fork, and log operations inside the Docker container."
|
||||
capabilities:
|
||||
- docker
|
||||
- shell
|
||||
procedure: |
|
||||
Use containerName, threadId, lastStepHash, and workflowName from your prompt.
|
||||
All commands via: `docker exec <containerName> bash -c '...'`
|
||||
Set env: PATH="$HOME/.bun/bin:$PATH" UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
|
||||
|
||||
Cancel:
|
||||
1. Start a second thread: `uwf thread start <workflowName> -p 'E2E cancel test'`
|
||||
2. Cancel it: `uwf thread cancel <secondThreadId>`
|
||||
3. Verify it appears in completed list: `uwf thread list --status completed`
|
||||
|
||||
Fork:
|
||||
4. Fork from the first thread's last step: `uwf step fork <lastStepHash>`
|
||||
5. Verify fork creates a new thread with a different ID
|
||||
|
||||
Logs:
|
||||
6. `uwf log list` — verify output (may be empty)
|
||||
7. `uwf log show --thread <threadId>` — verify runs without error
|
||||
|
||||
Report results with summary.
|
||||
output: "Report test results with summary. Set $status to pass or fail."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "pass" }
|
||||
containerName: { type: string }
|
||||
summary: { type: string }
|
||||
required: [$status, containerName, summary]
|
||||
- properties:
|
||||
$status: { const: "fail" }
|
||||
error: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, error, containerName]
|
||||
|
||||
cleanup:
|
||||
description: "Remove Docker container"
|
||||
goal: "You are an E2E test runner. Clean up the Docker container used for testing."
|
||||
capabilities:
|
||||
- docker
|
||||
- shell
|
||||
procedure: |
|
||||
Remove the Docker container (containerName is in your prompt):
|
||||
1. `docker rm -f <containerName>`
|
||||
2. Verify the container is gone: `docker ps -a --filter name=<containerName> --format '{{.Names}}'` should return empty
|
||||
|
||||
Report cleanup result.
|
||||
output: "Report cleanup result. Set $status to pass or fail."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "pass" }
|
||||
summary: { type: string }
|
||||
required: [$status, summary]
|
||||
- properties:
|
||||
$status: { const: "fail" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "bootstrap", prompt: "Set up the Docker container and verify uwf is runnable." }
|
||||
bootstrap:
|
||||
pass: { role: "config-and-registry", prompt: "Container {{{containerName}}} is ready. Validate config and workflow registration." }
|
||||
fail: { role: "$END", prompt: "Bootstrap failed: {{{error}}}. No container was created." }
|
||||
config-and-registry:
|
||||
pass: { role: "thread-ops", prompt: "Config and registry OK. Workflow '{{{workflowName}}}' registered. Container: {{{containerName}}}. Now test thread operations." }
|
||||
fail: { role: "cleanup", prompt: "Config/registry failed: {{{error}}}. Clean up container {{{containerName}}}." }
|
||||
thread-ops:
|
||||
pass: { role: "inspect", prompt: "Thread ops OK. threadId={{{threadId}}}, workflowName={{{workflowName}}}, containerName={{{containerName}}}. Now test inspect operations." }
|
||||
fail: { role: "cleanup", prompt: "Thread ops failed: {{{error}}}. Clean up container {{{containerName}}}." }
|
||||
inspect:
|
||||
pass: { role: "cancel-and-fork", prompt: "Inspect OK. threadId={{{threadId}}}, lastStepHash={{{lastStepHash}}}, workflowName={{{workflowName}}}, containerName={{{containerName}}}. Now test cancel, fork, and logs." }
|
||||
fail: { role: "cleanup", prompt: "Inspect failed: {{{error}}}. Clean up container {{{containerName}}}." }
|
||||
cancel-and-fork:
|
||||
pass: { role: "cleanup", prompt: "All tests passed! {{{summary}}}. Clean up container {{{containerName}}}." }
|
||||
fail: { role: "cleanup", prompt: "Cancel/fork failed: {{{error}}}. Clean up container {{{containerName}}}." }
|
||||
cleanup:
|
||||
pass: { role: "$END", prompt: "E2E walkthrough complete. {{{summary}}}" }
|
||||
fail: { role: "$END", prompt: "Cleanup failed: {{{error}}}. Manual cleanup may be needed." }
|
||||
@@ -0,0 +1,198 @@
|
||||
name: "solve-issue"
|
||||
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds."
|
||||
roles:
|
||||
planner:
|
||||
description: "Analyzes issue and outputs a TDD test spec"
|
||||
goal: "You are a planning agent. You analyze Gitea issues and produce a TDD test specification that downstream roles will implement and verify."
|
||||
capabilities:
|
||||
- issue-analysis
|
||||
- planning
|
||||
procedure: |
|
||||
On first run (no previous steps):
|
||||
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
|
||||
2. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
|
||||
3. Assess whether the issue has enough information to produce a test spec
|
||||
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output $status=insufficient_info
|
||||
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
|
||||
|
||||
On subsequent runs (bounced back by tester with fix_spec):
|
||||
1. Read the tester's output from the previous step to understand what's wrong with the spec
|
||||
2. Revise the test spec accordingly
|
||||
|
||||
After producing the test spec:
|
||||
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
|
||||
2. Put the hash in frontmatter.plan (required when $status=ready)
|
||||
3. Set repoPath to the absolute path of the repository root
|
||||
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "ready" }
|
||||
plan: { type: string }
|
||||
repoPath: { type: string }
|
||||
required: [$status, plan, repoPath]
|
||||
- properties:
|
||||
$status: { const: "insufficient_info" }
|
||||
required: [$status]
|
||||
developer:
|
||||
description: "TDD implementation per test spec"
|
||||
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
|
||||
capabilities:
|
||||
- coding
|
||||
procedure: |
|
||||
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
|
||||
The repo path and other details are provided in your task prompt.
|
||||
|
||||
Before starting any work, set up an isolated worktree:
|
||||
1. cd into the repo path provided in your task prompt
|
||||
2. `git fetch origin` to get latest refs
|
||||
3. First time (no existing branch):
|
||||
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
|
||||
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
|
||||
4. If bounced back from reviewer or tester (branch already exists):
|
||||
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
|
||||
- `git fetch origin && git rebase origin/main`
|
||||
5. ALL subsequent work must happen inside the worktree directory.
|
||||
|
||||
Then implement TDD:
|
||||
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
|
||||
7. If bounced back from reviewer or tester: read the previous role's feedback in your task prompt
|
||||
8. Write tests first based on the spec
|
||||
9. Implement the code to make tests pass
|
||||
10. Ensure `bun run build` passes with no errors
|
||||
11. Run `bun test` to verify all tests pass
|
||||
|
||||
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
|
||||
or repeated attempts fail), set $status=failed with a reason.
|
||||
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "done" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "failed" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
reviewer:
|
||||
description: "Code standards compliance check"
|
||||
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
|
||||
capabilities:
|
||||
- code-review
|
||||
- static-analysis
|
||||
procedure: |
|
||||
The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
Before reviewing, verify the git branch:
|
||||
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
|
||||
2. If the branch doesn't correspond to the issue, flag it in your output and reject
|
||||
|
||||
Then perform code review:
|
||||
Hard checks (must all pass):
|
||||
3. `bun run build` — no build errors
|
||||
4. `bunx biome check` — no lint violations
|
||||
5. TypeScript strict mode — no type errors
|
||||
|
||||
Soft checks (review against project conventions if CLAUDE.md / .cursor/rules exist):
|
||||
- Naming conventions, module boundaries, code style
|
||||
- No `console.log` in production code
|
||||
- No dynamic imports in production code
|
||||
|
||||
Only review standards compliance. Do NOT test functionality.
|
||||
If rejecting, you MUST explain the specific reason in your output.
|
||||
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "approved" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "rejected" }
|
||||
comments: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, comments, worktree]
|
||||
tester:
|
||||
description: "Functional correctness verification"
|
||||
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
|
||||
capabilities:
|
||||
- testing
|
||||
procedure: |
|
||||
The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
1. Run `bun test` for automated test verification
|
||||
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner step in the thread history)
|
||||
3. Verify each scenario in the spec is covered and passing
|
||||
4. Determine outcome:
|
||||
- passed: all scenarios verified, tests pass
|
||||
- fix_code: tests fail or implementation doesn't match spec → send back to developer
|
||||
- fix_spec: the spec itself is wrong or incomplete → send back to planner
|
||||
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "passed" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "fix_code" }
|
||||
report: { type: string }
|
||||
required: [$status, report]
|
||||
- properties:
|
||||
$status: { const: "fix_spec" }
|
||||
report: { type: string }
|
||||
required: [$status, report]
|
||||
committer:
|
||||
description: "Commits and creates PR"
|
||||
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
|
||||
capabilities: []
|
||||
procedure: |
|
||||
The worktree path, branch name, and repo info are provided in your task prompt.
|
||||
cd into the worktree first.
|
||||
|
||||
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
|
||||
1. Stage all changes: `git add -A`
|
||||
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
|
||||
3. Push the branch: `git push -u origin <branch-name>`
|
||||
- If push hook fails: capture the error log in your output, mark hook_failed
|
||||
4. On push success: create a PR via `tea pr create --repo <owner/repo> --title "..." --description "..."`
|
||||
- Extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
|
||||
- PR description must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
||||
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
|
||||
5. After PR creation, clean up the worktree:
|
||||
- cd to the repo root (parent of .worktrees)
|
||||
- `git worktree remove <worktree-path>`
|
||||
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "committed" }
|
||||
prUrl: { type: string }
|
||||
required: [$status, prUrl]
|
||||
- properties:
|
||||
$status: { const: "hook_failed" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
|
||||
planner:
|
||||
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
|
||||
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
|
||||
developer:
|
||||
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
|
||||
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
|
||||
reviewer:
|
||||
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}." }
|
||||
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}." }
|
||||
tester:
|
||||
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit." }
|
||||
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec." }
|
||||
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}." }
|
||||
committer:
|
||||
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
|
||||
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
|
||||
@@ -2,32 +2,40 @@
|
||||
|
||||
## Project Overview
|
||||
|
||||
**@uncaged/workflow** is a workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file with an XXH64 hash as its version identifier.
|
||||
This monorepo implements a stateless workflow engine driven by a single-step CLI (`uwf`). Workflows are **YAML definitions** stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each `uwf thread step` invocation runs one moderator→agent→extract cycle and exits.
|
||||
|
||||
### Key Terms
|
||||
|
||||
| Concept | What it is |
|
||||
|---------|-----------|
|
||||
| **Workflow** | A single-file ESM module that exports `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash (Crockford Base32). |
|
||||
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
|
||||
| **Thread** | A single execution of a workflow, identified by a ULID. Persisted as `.data.jsonl` + `.info.jsonl`. |
|
||||
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. |
|
||||
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
|
||||
| **Workflow** | A YAML definition (`WorkflowPayload`) with roles, status-based routing, and a directed graph. Stored as a CAS node, identified by its XXH64 hash. |
|
||||
| **Thread** | A single execution of a workflow, identified by a ULID. State is an immutable CAS chain; active threads indexed in `threads.yaml`; completed threads in `history.jsonl`. |
|
||||
| **Role** | A named actor within a workflow. Each role has a system prompt and a JSON Schema `outputSchema`. |
|
||||
| **Moderator** | Status-based graph evaluator — determines the next role (or `$END`) with zero LLM cost. |
|
||||
| **Agent** | An external CLI command (`uwf-hermes`, etc.) spawned by `uwf thread step`. Produces frontmatter markdown output. |
|
||||
| **CAS** | Content-Addressed Storage via `@uncaged/json-cas` — all workflow definitions, thread nodes, and outputs are immutable CAS nodes. |
|
||||
| **Registry** | `~/.uncaged/workflow/registry.yaml` — maps workflow names to current CAS hashes. |
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
```
|
||||
workflow/
|
||||
packages/
|
||||
workflow/ # @uncaged/workflow — core lib (types, hash, ULID, JSONL, registry)
|
||||
cli-workflow/ # @uncaged/cli-workflow — CLI (uncaged-workflow command)
|
||||
docs/ # RFCs, conventions
|
||||
biome.json # root Biome config
|
||||
tsconfig.json # root TypeScript config
|
||||
workflow-protocol/ # @uncaged/workflow-protocol — shared types (WorkflowPayload, StepNodePayload, WorkflowConfig, etc.)
|
||||
workflow-util/ # @uncaged/workflow-util — Crockford Base32, ULID, logger, frontmatter parsing/validation
|
||||
workflow-util-agent/ # @uncaged/workflow-util-agent — createAgent factory, context builder, extract pipeline
|
||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI binary (spawns hermes chat)
|
||||
cli-workflow/ # @uncaged/cli-workflow — uwf CLI binary (includes status-based moderator in src/moderator/)
|
||||
legacy-packages/ # Archived packages (preserved for reference, not active)
|
||||
examples/ # Workflow YAML examples (solve-issue.yaml)
|
||||
docs/ # Architecture docs
|
||||
biome.json # root Biome config
|
||||
tsconfig.json # root TypeScript config
|
||||
```
|
||||
|
||||
- `workflow` is the core; `cli-workflow` depends on it
|
||||
- Packages use `workspace:*` protocol
|
||||
- Dependency layers: `workflow-protocol` → `workflow-util` → `workflow-util-agent` → `workflow-agent-hermes` / `cli-workflow`
|
||||
- Packages use `workspace:^` protocol (resolves to `^x.y.z` on publish)
|
||||
- External CAS: `@uncaged/json-cas` (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend)
|
||||
|
||||
## Language & Paradigm
|
||||
|
||||
@@ -95,8 +103,6 @@ type WorkflowEntry = {
|
||||
- Always named exports, never default exports
|
||||
- One module = one responsibility, filename = purpose
|
||||
|
||||
Workflow bundles (`.esm.js`) follow the same rule: export `const run` and `const descriptor`, not `export default`.
|
||||
|
||||
### Folder Module Discipline
|
||||
|
||||
Every folder under `src/` is a **module boundary**. Four rules:
|
||||
@@ -122,10 +128,10 @@ export { createCasStore } from "../cas/cas.js";
|
||||
|
||||
// ❌ Bad — types defined in index.ts
|
||||
// in cas/index.ts:
|
||||
export type CasStore = { ... }; // should be in cas/types.ts
|
||||
export type CasStore = { ... }; // should be in cas/types.ts
|
||||
```
|
||||
|
||||
**Exception**: The package-level `src/index.ts` is the public API surface and re-exports from folder `index.ts` files. Files that remain at `src/` root (e.g. `types.ts`, `workflow-as-agent.ts`) are not inside a folder module and follow normal rules.
|
||||
**Exception**: The package-level `src/index.ts` is the public API surface and re-exports from folder `index.ts` files. Files that remain at `src/` root (e.g. `types.ts`) are not inside a folder module and follow normal rules.
|
||||
|
||||
## Naming
|
||||
|
||||
@@ -146,7 +152,7 @@ Workflow names use **verb-first** kebab-case:
|
||||
### ID Encoding
|
||||
|
||||
All IDs use **Crockford Base32**:
|
||||
- Bundle hash: XXH64 → 13-char Crockford Base32
|
||||
- CAS hash: XXH64 → 13-char Crockford Base32
|
||||
- Thread ID: ULID → 26-char Crockford Base32 (10 timestamp + 16 random)
|
||||
|
||||
## Error Handling
|
||||
@@ -167,15 +173,15 @@ type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
|
||||
|
||||
Never use `console.log/warn/error` directly — Biome's `noConsole` rule enforces this.
|
||||
|
||||
All logging goes through the structured logger from `@uncaged/workflow`:
|
||||
All logging goes through the structured logger from `@uncaged/workflow-util`:
|
||||
|
||||
```typescript
|
||||
import { createLogger } from "@uncaged/workflow";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
const log = createLogger();
|
||||
|
||||
// Each call site has a fixed 8-char Crockford Base32 tag
|
||||
log("4KNMR2PX", "Loading workflow bundle...");
|
||||
log("4KNMR2PX", "Loading workflow...");
|
||||
log("7BQST3VW", `Role ${role} started`);
|
||||
```
|
||||
|
||||
@@ -190,7 +196,7 @@ log("7BQST3VW", `Role ${role} started`);
|
||||
|
||||
### Why fixed tags?
|
||||
|
||||
- `grep "4KNMR2PX"` in `.info.jsonl` → instant code location
|
||||
- `grep "4KNMR2PX"` in logs → instant code location
|
||||
- No need for file/line info in the log — tag is the locator
|
||||
- Survives refactoring (tag stays the same when code moves)
|
||||
|
||||
@@ -207,36 +213,87 @@ console.log(result);
|
||||
|
||||
Do NOT use `await import()` in production code. Always use static top-level `import`.
|
||||
|
||||
**Exception**: The bundle loader and `extractBundleExports` dynamically import user workflow files at runtime.
|
||||
|
||||
```ts
|
||||
// Dynamic import required: user bundle path resolved at runtime
|
||||
const mod = await import(bundlePath);
|
||||
```
|
||||
|
||||
Test files (`__tests__/**`) are exempt.
|
||||
|
||||
## Toolchain
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| **bun** | Package manager + runtime + test runner |
|
||||
| **bun** | Package manager + runtime |
|
||||
| **TypeScript** | Type checking (strict mode) |
|
||||
| **Biome** | Lint + format (replaces ESLint + Prettier) |
|
||||
| **vitest** | Test runner (`cli-workflow` uses vitest; other packages use `bun test`) |
|
||||
|
||||
### Commands
|
||||
### Development Workflow
|
||||
|
||||
```bash
|
||||
bun run check # tsc --build + biome check
|
||||
bun run format # biome format --write
|
||||
bun test # run tests
|
||||
# ── Setup ──
|
||||
bun install # install all workspace dependencies
|
||||
|
||||
# ── Daily development ──
|
||||
bun run build # tsc --build (all packages, dependency order)
|
||||
bun run check # tsc --build + biome check + lint-log-tags
|
||||
bun run format # biome format --write
|
||||
bun test # run tests across all packages
|
||||
|
||||
# ── Before committing ──
|
||||
bun run check # must pass — typecheck + lint + log tag validation
|
||||
bun test # must pass — all package tests
|
||||
```
|
||||
|
||||
### Publishing
|
||||
|
||||
All public `@uncaged/*` packages are published to **npmjs.org** with **fixed mode** (all packages share the same version number).
|
||||
|
||||
```bash
|
||||
# 1. Add a changeset describing the change
|
||||
bun changeset
|
||||
|
||||
# 2. Bump all package versions + generate CHANGELOGs
|
||||
bun version
|
||||
|
||||
# 3. Build, test, and publish (runs scripts/publish-all.mjs)
|
||||
bun release
|
||||
|
||||
# Or publish manually with a tag:
|
||||
node scripts/publish-all.mjs --tag alpha
|
||||
node scripts/publish-all.mjs --dry-run # preview without publishing
|
||||
```
|
||||
|
||||
- `workspace:^` dependencies resolve to `^x.y.z` on publish
|
||||
- Publish order defined in `scripts/publish-all.mjs` (dependency order)
|
||||
- Changesets config: `.changeset/config.json` (fixed mode, public access)
|
||||
|
||||
### End-to-end: Author → Register → Run
|
||||
|
||||
```
|
||||
examples/solve-issue.yaml — write a workflow YAML definition
|
||||
│ uwf workflow put
|
||||
▼
|
||||
~/.uncaged/workflow/cas/ — Workflow stored as CAS node
|
||||
~/.uncaged/workflow/registry.yaml — name → hash mapping updated
|
||||
│ uwf thread start <name> -p "..."
|
||||
▼
|
||||
~/.uncaged/workflow/threads.yaml — new thread head pointer
|
||||
│ uwf thread step <thread-id>
|
||||
▼
|
||||
moderator → agent → extract — one step per invocation, repeat until $END
|
||||
```
|
||||
|
||||
1. **Author** — write a workflow YAML file with roles, conditions, and graph
|
||||
2. **Register** — `uwf workflow put <file.yaml>` parses YAML, registers output schemas, stores `WorkflowPayload` in CAS
|
||||
3. **Run** — `uwf thread start` creates a thread, `uwf thread step` executes one cycle per invocation
|
||||
|
||||
## Project Rules
|
||||
|
||||
- [docs/sync-readme.md](docs/sync-readme.md) — README sync conventions
|
||||
- [docs/no-dynamic-import.md](docs/no-dynamic-import.md) — no dynamic import in production code
|
||||
|
||||
## Commit Convention
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
type: feat | fix | refactor | docs | chore | test
|
||||
scope: workflow | cli | rfc-001 | ...
|
||||
scope: workflow | cli | moderator | agent-kit | hermes | util | protocol | ...
|
||||
```
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
# Contributing to @uncaged/workflow
|
||||
|
||||
Thank you for your interest in contributing! This guide covers setup, conventions, and the PR workflow.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/) (latest)
|
||||
- [Node.js](https://nodejs.org/) 20+
|
||||
- Git
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/shazhou-ww/uncaged-workflow.git
|
||||
cd uncaged-workflow
|
||||
bun install
|
||||
bun run build
|
||||
bun test
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
```bash
|
||||
bun run build # TypeScript compilation (all packages)
|
||||
bun run check # tsc + biome lint + log tag validation
|
||||
bun run format # Auto-format with Biome
|
||||
bun test # Run all tests
|
||||
```
|
||||
|
||||
All three (`build`, `check`, `test`) must pass before submitting a PR. A pre-push hook runs `check` + `test` automatically.
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
See [CLAUDE.md](CLAUDE.md) for the full coding standard. Key points:
|
||||
|
||||
- **Functional-first** — `function` + `type`, not `class` + `interface`
|
||||
- **No optional properties** — use `T | null` instead of `?:`
|
||||
- **Named exports only** — no default exports
|
||||
- **No `console.log`** — use the structured logger from `@uncaged/workflow-util`
|
||||
- **Static imports only** — no `await import()` in production code
|
||||
- **Biome** for lint + format — run `bun run check` before committing
|
||||
|
||||
## Commit Messages
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
type: feat | fix | refactor | docs | chore | test
|
||||
scope: cli | moderator | agent-kit | hermes | builtin | claude-code | util | protocol | dashboard
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `feat(moderator): add cycle detection to graph evaluator`
|
||||
- `fix(cli): handle missing config file gracefully`
|
||||
- `docs(protocol): update StepNode field descriptions`
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. **Branch** from `main`: `git checkout -b feat/123-short-description`
|
||||
2. **Implement** your change with tests
|
||||
3. **Run checks**: `bun run check && bun test`
|
||||
4. **Commit** with a descriptive message referencing the issue: `Fixes #123`
|
||||
5. **Push** and open a PR
|
||||
|
||||
### PR Description Template
|
||||
|
||||
```
|
||||
## What
|
||||
What this PR does.
|
||||
|
||||
## Why
|
||||
Why the change is needed.
|
||||
|
||||
## Changes
|
||||
- `path/to/file.ts` — what changed and why
|
||||
|
||||
## Ref
|
||||
Fixes #N
|
||||
```
|
||||
|
||||
## Adding a Changeset
|
||||
|
||||
For any user-facing change (feat, fix, breaking change), add a changeset:
|
||||
|
||||
```bash
|
||||
bun changeset
|
||||
```
|
||||
|
||||
This creates a markdown file in `.changeset/` describing the change. It will be consumed on the next release to bump versions and generate CHANGELOG entries.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
packages/
|
||||
workflow-protocol/ # Shared types and JSON Schema
|
||||
workflow-util/ # Encoding, IDs, logging, frontmatter
|
||||
workflow-util-agent/ # createAgent factory, extract pipeline
|
||||
workflow-agent-hermes/ # Hermes ACP agent
|
||||
workflow-agent-builtin/ # Built-in LLM agent
|
||||
workflow-agent-claude-code/ # Claude Code agent
|
||||
cli-workflow/ # uwf CLI binary
|
||||
workflow-dashboard/ # Web UI (private, alpha)
|
||||
```
|
||||
|
||||
Dependency flows downward — lower layers have no dependency on higher layers. See [CLAUDE.md](CLAUDE.md) for the full architecture.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Uncaged
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,71 +1,115 @@
|
||||
# @uncaged/workflow
|
||||
|
||||
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32).
|
||||
[](https://github.com/shazhou-ww/uncaged-workflow/actions/workflows/ci.yml)
|
||||
[](https://www.npmjs.com/package/@uncaged/cli-workflow)
|
||||
[](https://www.npmjs.com/package/@uncaged/workflow-protocol)
|
||||
[](https://www.npmjs.com/package/@uncaged/workflow-util-agent)
|
||||
|
||||
## Core Concepts
|
||||
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions with roles, status-based routing, and a directed graph. Threads are immutable CAS-linked chains — each `uwf thread step` runs one moderator→agent→extract cycle and exits.
|
||||
|
||||
| Concept | Description |
|
||||
|---------|-------------|
|
||||
| **Workflow** | A single-file ESM module exporting `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash. |
|
||||
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
|
||||
| **Thread** | A single execution of a workflow, identified by a ULID. Persisted as `.data.jsonl` + `.info.jsonl`. |
|
||||
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. Roles live inside template packages (`src/roles/`). |
|
||||
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
|
||||
| **CAS** | Content-Addressed Storage — bundles are immutable and addressed by hash. |
|
||||
## Overview
|
||||
|
||||
## Monorepo Packages
|
||||
This monorepo implements **uwf**, a workflow engine with no long-running daemon. You register YAML workflow definitions in a content-addressed store (CAS), start a thread with an initial prompt, then invoke `uwf thread step` repeatedly until the moderator routes to `$END`. Each step is a complete process: the moderator evaluates status-based routing to pick the next role, an external agent CLI produces frontmatter markdown output, and an extract pipeline validates or structures that output against the role's JSON Schema.
|
||||
|
||||
```
|
||||
packages/
|
||||
workflow/ # @uncaged/workflow — core lib (types, engine, hash, ULID, registry)
|
||||
cli-workflow/ # @uncaged/cli-workflow — CLI (`uncaged-workflow` command)
|
||||
workflow-template-develop/ # @uncaged/workflow-template-develop — develop workflow template (includes roles)
|
||||
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue — solve-issue workflow template (includes roles)
|
||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — Hermes agent adapter
|
||||
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — Cursor agent adapter
|
||||
workflow-agent-llm/ # @uncaged/workflow-agent-llm — LLM agent adapter
|
||||
workflow-util-agent/ # @uncaged/workflow-util-agent — agent utilities (buildAgentPrompt, spawnCli)
|
||||
Workflow state lives entirely on disk under `~/.uncaged/workflow/`: CAS nodes for definitions and step payloads, `registry.yaml` for workflow name→hash mappings, and `threads.yaml` for active thread head pointers. Completed threads are archived to `history.jsonl`. Because there is no server process, workflows are easy to debug, fork, and inspect with ordinary CLI tools.
|
||||
|
||||
Agents are pluggable CLI binaries (`uwf-hermes`, `uwf-builtin`, `uwf-claude-code`, or custom commands). The engine spawns the configured agent with `<thread-id>` and `<role>`, sets `UWF_EDGE_PROMPT` from the graph transition, and captures both the agent's markdown output and a detail CAS node for session replay.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install -g @uncaged/cli-workflow
|
||||
```
|
||||
|
||||
Managed with **bun workspace** using the `workspace:*` protocol.
|
||||
Requires [Bun](https://bun.sh/) runtime (used internally for TypeScript execution).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
# 1. Configure provider, model, and default agent
|
||||
uwf setup
|
||||
|
||||
# Build all packages
|
||||
bun run build
|
||||
# 2. Register a workflow from YAML
|
||||
uwf workflow add examples/solve-issue.yaml
|
||||
|
||||
# Register a workflow bundle
|
||||
uncaged-workflow workflow add solve-issue dist/packages/workflow-template-solve-issue/solve-issue.esm.js
|
||||
# 3. Start a thread (creates head pointer; does not execute)
|
||||
uwf thread start solve-issue -p "Fix the login redirect bug"
|
||||
|
||||
# Run a workflow
|
||||
uncaged-workflow run solve-issue --prompt "Fix bug #42"
|
||||
# 4. Execute steps (one at a time, until done)
|
||||
uwf thread exec <thread-id>
|
||||
```
|
||||
|
||||
## CLI Usage
|
||||
Use `-c, --count <number>` on `thread exec` to run multiple steps in one invocation. Override the agent with `--agent <cmd>`.
|
||||
|
||||
```bash
|
||||
uncaged-workflow # Print full command usage (exits with status 1)
|
||||
uncaged-workflow workflow list # List registered workflows
|
||||
uncaged-workflow run <name> # Start a workflow thread
|
||||
uncaged-workflow thread list # List all threads
|
||||
uncaged-workflow thread show <id> # Inspect a thread
|
||||
uncaged-workflow skill # Agent-consumable reference docs
|
||||
## Architecture
|
||||
|
||||
Dependency layers (lower layers have no dependency on higher layers):
|
||||
|
||||
```
|
||||
Layer 0 — Contract
|
||||
workflow-protocol Shared types and JSON Schema definitions
|
||||
|
||||
Layer 1 — Shared infra
|
||||
workflow-util Encoding, IDs, logging, frontmatter, paths
|
||||
|
||||
Layer 2 — Agent framework
|
||||
workflow-util-agent createAgent factory, context builder, extract pipeline
|
||||
|
||||
Layer 3 — Agent implementations
|
||||
workflow-agent-hermes Hermes ACP agent (uwf-hermes)
|
||||
workflow-agent-builtin Built-in LLM + tools agent (uwf-builtin)
|
||||
workflow-agent-claude-code Claude Code agent (uwf-claude-code)
|
||||
|
||||
Layer 4 — CLI
|
||||
cli-workflow uwf binary — thread lifecycle, registry, CAS, setup (includes status-based moderator)
|
||||
|
||||
App (uses protocol; not in the runtime engine stack)
|
||||
workflow-dashboard Web UI for visual workflow editing
|
||||
```
|
||||
|
||||
Run `uncaged-workflow` with no arguments to print usage, or `uncaged-workflow skill cli` for the full CLI skill reference.
|
||||
External CAS: [`@uncaged/json-cas`](https://www.npmjs.com/package/@uncaged/json-cas) (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend).
|
||||
|
||||
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, CAS node types, storage layout, agent CLI protocol, and design decisions.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | npm | Description | Type | README |
|
||||
|---------|-----|-------------|------|--------|
|
||||
| `cli-workflow` | `@uncaged/cli-workflow` | `uwf` CLI — thread lifecycle, workflow registry, CAS inspection, setup | cli | [README](packages/cli-workflow/README.md) |
|
||||
| `workflow-protocol` | `@uncaged/workflow-protocol` | Shared TypeScript types and JSON Schema constants | lib | [README](packages/workflow-protocol/README.md) |
|
||||
| `workflow-util-agent` | `@uncaged/workflow-util-agent` | `createAgent` factory, context builder, extract pipeline | lib | [README](packages/workflow-util-agent/README.md) |
|
||||
| `workflow-util` | `@uncaged/workflow-util` | Crockford Base32, ULID, logger, frontmatter parsing, storage paths | lib | [README](packages/workflow-util/README.md) |
|
||||
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | `uwf-hermes` — spawns Hermes chat via ACP | agent | [README](packages/workflow-agent-hermes/README.md) |
|
||||
| `workflow-agent-builtin` | `@uncaged/workflow-agent-builtin` | `uwf-builtin` — built-in LLM agent with file/shell tools | agent | [README](packages/workflow-agent-builtin/README.md) |
|
||||
| `workflow-agent-claude-code` | `@uncaged/workflow-agent-claude-code` | `uwf-claude-code` — spawns Claude Code CLI | agent | [README](packages/workflow-agent-claude-code/README.md) |
|
||||
| `workflow-dashboard` | `@uncaged/workflow-dashboard` | Web graph editor for workflow YAML (private, alpha) | app | [README](packages/workflow-dashboard/README.md) |
|
||||
|
||||
## CLI Reference
|
||||
|
||||
Global options: `-V, --version`, `--format <json|yaml>`, `-h, --help`.
|
||||
|
||||
| Group | Commands |
|
||||
|-------|----------|
|
||||
| **thread** | `start`, `exec`, `show`, `list`, `stop`, `cancel`, `read` |
|
||||
| **step** | `list`, `show`, `read`, `fork` |
|
||||
| **workflow** | `add`, `show`, `list` |
|
||||
| **cas** | `get`, `put`, `put-text`, `has`, `refs`, `walk`, `reindex`, `schema list`, `schema get` |
|
||||
| **setup** | Interactive or `--provider`, `--base-url`, `--api-key`, `--model`, `--agent` |
|
||||
| **skill** | `cli` — print markdown reference of all uwf commands |
|
||||
| **log** | `list`, `show`, `clean` — process-level debug logs |
|
||||
|
||||
Config is stored in `~/.uncaged/workflow/config.yaml`. API keys go in `~/.uncaged/workflow/.env`.
|
||||
|
||||
Detailed command usage, options, and examples: [packages/cli-workflow/README.md](packages/cli-workflow/README.md).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun run check # Biome lint + format check
|
||||
bun run format # Auto-format with Biome
|
||||
bun test # Run tests
|
||||
bun install --no-cache # Install dependencies
|
||||
bun run build # tsc --build (all packages)
|
||||
bun run check # tsc + biome + lint-log-tags
|
||||
bun run format # Auto-format with Biome
|
||||
bun test # Run all tests
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, bundle contract, storage layout, and design decisions.
|
||||
Managed with **bun workspace**. See [CLAUDE.md](CLAUDE.md) for coding conventions.
|
||||
|
||||
+31
-3
@@ -1,7 +1,16 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
||||
"files": {
|
||||
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/dist",
|
||||
"!.worktrees",
|
||||
"!**/node_modules",
|
||||
"!**/legacy-packages",
|
||||
"!scripts",
|
||||
"!packages/workflow/workflow",
|
||||
"!xiaoju/scripts/bundle.ts"
|
||||
]
|
||||
},
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"formatter": {
|
||||
@@ -9,6 +18,15 @@
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"cssModules": true,
|
||||
"tailwindDirectives": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
@@ -30,7 +48,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": ["**/*.d.ts"],
|
||||
"includes": ["**/*.d.ts", "**/vitest.config.*"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
@@ -38,6 +56,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": ["**/cli.ts", "**/setup.ts"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noConsole": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"linter": {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[test]
|
||||
pathIgnorePatterns = ["dist/**"]
|
||||
+419
-185
@@ -1,256 +1,490 @@
|
||||
# @uncaged/workflow — Architecture
|
||||
# Workflow Engine — Architecture
|
||||
|
||||
**Last updated:** 2026-05-06 by 小橘 🍊(NEKO Team)
|
||||
**Last updated:** 2026-05-19
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32). No daemon — processes start on demand and exit when done.
|
||||
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each `uwf thread step` invocation runs one moderator→agent→extract cycle and exits.
|
||||
|
||||
## Package Structure
|
||||
The implementation lives in **5** active packages under `packages/`, plus two external CAS packages (`@uncaged/json-cas`, `@uncaged/json-cas-fs`). Legacy packages reside in `legacy-packages/` and are not part of the active stack.
|
||||
|
||||
| Package | npm Name | Purpose |
|
||||
|---------|----------|---------|
|
||||
| `workflow` | `@uncaged/workflow` | Core: types, engine, ExtractFn, hash/ULID/registry |
|
||||
| `cli-workflow` | `@uncaged/cli-workflow` | CLI: `uncaged-workflow` command |
|
||||
| `workflow-agent-cursor` | `@uncaged/workflow-agent-cursor` | Cursor CLI agent (extracts workspace from ctx) |
|
||||
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | Hermes CLI agent |
|
||||
| `workflow-agent-llm` | `@uncaged/workflow-agent-llm` | OpenAI-compatible LLM agent |
|
||||
| `workflow-template-develop` | `@uncaged/workflow-template-develop` | Develop workflow template (roles in `src/roles/`) |
|
||||
| `workflow-template-solve-issue` | `@uncaged/workflow-template-solve-issue` | Solve-issue workflow template (roles in `src/roles/`) |
|
||||
| `workflow-util-agent` | `@uncaged/workflow-util-agent` | `buildAgentPrompt` + `spawnCli` utilities |
|
||||
## Package map
|
||||
|
||||
Monorepo with **bun workspace**, `workspace:*` protocol.
|
||||
| Layer | Package | One-line role |
|
||||
|-------|---------|---------------|
|
||||
| Contract | `@uncaged/workflow-protocol` → `workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `ModeratorContext`, `WorkflowConfig`, etc.). No runtime deps beyond `@uncaged/json-cas-fs`. |
|
||||
| Shared infra | `@uncaged/workflow-util` → `workflow-util` | Crockford Base32, ULID generation, `createLogger`, frontmatter parsing/validation. |
|
||||
| Agent framework | `@uncaged/workflow-util-agent` → `workflow-util-agent` | `createAgent` entrypoint factory, context builder, frontmatter fast-path extractor, LLM extract fallback, output format instruction builder. |
|
||||
| Agent: Hermes | `@uncaged/workflow-agent-hermes` → `workflow-agent-hermes` | `uwf-hermes` CLI binary — spawns `hermes chat`, pipes prompt, captures session detail. |
|
||||
| CLI | `@uncaged/cli-workflow` → `cli-workflow` | `uwf` binary — thread lifecycle, workflow registry, CAS inspection, setup. Includes status-based graph evaluator in `src/moderator/` (next role or `$END`). |
|
||||
|
||||
## Core Types
|
||||
### External dependencies
|
||||
|
||||
```typescript
|
||||
// --- Sentinel values ---
|
||||
const START = "__start__";
|
||||
const END = "__end__";
|
||||
| Package | Role |
|
||||
|---------|------|
|
||||
| `@uncaged/json-cas` | Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
|
||||
| `@uncaged/json-cas-fs` | Filesystem backend for `json-cas`. |
|
||||
| `mustache` | Template renderer for edge prompts (used by `cli-workflow` moderator). |
|
||||
| `commander` | CLI argument parsing (used by `cli-workflow`). |
|
||||
| `dotenv` | Loads `.env` files for API keys. |
|
||||
| `yaml` | YAML parse/stringify. |
|
||||
|
||||
// --- RoleMeta: maps role names → their meta types ---
|
||||
type RoleMeta = Record<string, Record<string, unknown>>;
|
||||
## Dependency graph
|
||||
|
||||
// --- Role Definition: pure data, no execution logic ---
|
||||
type RoleDefinition<Meta> = {
|
||||
description: string; // human-readable
|
||||
systemPrompt: string; // given to agent
|
||||
extractPrompt: string; // given to extractor
|
||||
schema: z.ZodType<Meta>; // meta shape (Zod v4)
|
||||
};
|
||||
|
||||
// --- Workflow Definition: pure data, no agent binding ---
|
||||
type WorkflowDefinition<M extends RoleMeta> = {
|
||||
description: string;
|
||||
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
|
||||
moderator: Moderator<M>;
|
||||
};
|
||||
|
||||
// --- Agent: raw string output, reads role info from context ---
|
||||
type AgentFn = (ctx: AgentContext) => Promise<string>;
|
||||
|
||||
// --- Agent Binding: runtime assignment ---
|
||||
type AgentBinding = {
|
||||
agent: AgentFn;
|
||||
overrides?: Partial<Record<string, AgentFn>>;
|
||||
};
|
||||
|
||||
// --- Extract: structured data from context ---
|
||||
type ExtractFn = <T>(schema: z.ZodType<T>, prompt: string, ctx: ExtractContext) => Promise<T>;
|
||||
|
||||
// --- Moderator: pure routing function ---
|
||||
type Moderator<M extends RoleMeta> = (ctx: ModeratorContext<M>) => (keyof M & string) | typeof END;
|
||||
|
||||
// --- Composition ---
|
||||
// createWorkflow(def, binding, extract) => WorkflowFn
|
||||
```mermaid
|
||||
flowchart BT
|
||||
subgraph External
|
||||
jcas["@uncaged/json-cas"]
|
||||
jcasfs["@uncaged/json-cas-fs"]
|
||||
end
|
||||
subgraph L0["Layer 0 — contract"]
|
||||
protocol["@uncaged/workflow-protocol"]
|
||||
end
|
||||
subgraph L1["Layer 1 — shared"]
|
||||
util["@uncaged/workflow-util"]
|
||||
end
|
||||
subgraph L2["Layer 2 — agent framework"]
|
||||
kit["@uncaged/workflow-util-agent"]
|
||||
end
|
||||
subgraph L3["Layer 3 — agent implementations"]
|
||||
hermes["@uncaged/workflow-agent-hermes"]
|
||||
end
|
||||
subgraph L4["Layer 4 — CLI"]
|
||||
cli["@uncaged/cli-workflow"]
|
||||
end
|
||||
protocol --> jcasfs
|
||||
util --> protocol
|
||||
kit --> protocol
|
||||
kit --> util
|
||||
kit --> jcas
|
||||
kit --> jcasfs
|
||||
hermes --> kit
|
||||
hermes --> jcas
|
||||
cli --> protocol
|
||||
cli --> util
|
||||
cli --> kit
|
||||
cli --> jcas
|
||||
cli --> jcasfs
|
||||
```
|
||||
|
||||
## Three-Phase Engine Loop
|
||||
## Workflow definition
|
||||
|
||||
Each role execution has three distinct phases with progressive context:
|
||||
Workflows are **YAML files** (not ESM bundles). `uwf workflow put <file.yaml>` parses the YAML, registers output schemas as JSON Schema CAS nodes, and stores the `WorkflowPayload` as a CAS node.
|
||||
|
||||
Example (`examples/solve-issue.yaml`):
|
||||
|
||||
```yaml
|
||||
name: "solve-issue"
|
||||
description: "End-to-end issue resolution"
|
||||
roles:
|
||||
planner:
|
||||
description: "Creates implementation plan"
|
||||
goal: "You are a planning agent. Analyze the issue and create a step-by-step plan."
|
||||
capabilities:
|
||||
- issue-analysis
|
||||
- planning
|
||||
procedure: "Analyze the issue and create a detailed, actionable implementation plan."
|
||||
output: "Output the plan summary and list of concrete steps."
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
plan: { type: string }
|
||||
steps: { type: array, items: { type: string } }
|
||||
required: [plan, steps]
|
||||
developer:
|
||||
description: "Implements code changes"
|
||||
goal: "You are a developer agent. Implement the plan."
|
||||
capabilities:
|
||||
- file-edit
|
||||
- shell
|
||||
procedure: "Implement the plan. Write code, tests, and ensure existing tests pass."
|
||||
output: "List all files changed and provide a summary of the implementation."
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
filesChanged: { type: array, items: { type: string } }
|
||||
summary: { type: string }
|
||||
required: [filesChanged, summary]
|
||||
reviewer:
|
||||
description: "Reviews code changes"
|
||||
goal: "You are a code reviewer. Review the implementation."
|
||||
capabilities:
|
||||
- code-review
|
||||
procedure: "Review the implementation against the plan."
|
||||
output: "Approve or reject with detailed comments."
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
approved: { type: boolean }
|
||||
comments: { type: string }
|
||||
required: [approved, comments]
|
||||
conditions:
|
||||
notApproved:
|
||||
description: "Reviewer rejected the implementation"
|
||||
expression: "steps[-1].output.approved = false"
|
||||
graph:
|
||||
$START:
|
||||
- role: "planner"
|
||||
condition: null
|
||||
planner:
|
||||
- role: "developer"
|
||||
condition: null
|
||||
developer:
|
||||
- role: "reviewer"
|
||||
condition: null
|
||||
reviewer:
|
||||
- role: "developer"
|
||||
condition: "notApproved"
|
||||
- role: "$END"
|
||||
condition: null
|
||||
```
|
||||
|
||||
Key properties:
|
||||
|
||||
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
|
||||
- **`graph`** — `Record<Role | "$START", Record<Status, Target>>` — status-based routing; each role maps statuses to targets
|
||||
- **No agent binding** — agent selection is a deployment concern, configured in `config.yaml`
|
||||
- **No Zod** — all schemas are JSON Schema, validated through `@uncaged/json-cas`
|
||||
|
||||
## Three-phase engine loop
|
||||
|
||||
Each `uwf thread step` runs exactly one cycle: moderator → agent → extract. The CLI orchestrates this in `packages/cli-workflow/src/commands/thread.ts` (`cmdThreadStep`).
|
||||
|
||||
```
|
||||
┌─→ Phase 1: MODERATOR
|
||||
│ Context: ModeratorContext { threadId, start, steps }
|
||||
│ Action: moderator(ctx) → role name | END
|
||||
│ Input: graph + lastRole + lastOutput
|
||||
│ Engine: Status-based map lookup against lastOutput.status
|
||||
│ Output: next role name | $END
|
||||
│
|
||||
│ Phase 2: AGENT
|
||||
│ Context: AgentContext = ModeratorCtx + { currentRole: { name, systemPrompt } }
|
||||
│ Action: agent(ctx) → raw string
|
||||
│ Input: thread-id + role (via argv)
|
||||
│ Engine: agent-kit builds context from CAS chain, prepends
|
||||
│ output format instruction to system prompt, spawns agent
|
||||
│ Output: raw string (frontmatter markdown)
|
||||
│
|
||||
│ Phase 3: EXTRACTOR
|
||||
│ Context: ExtractContext = AgentCtx + { agentContent }
|
||||
│ Action: extract(schema, extractPrompt, ctx) → typed meta
|
||||
│ Phase 3: EXTRACT
|
||||
│ Input: raw agent output + role's meta schema
|
||||
│ Engine: two-layer extract (frontmatter fast path → LLM fallback)
|
||||
│ Output: CasRef to structured output node
|
||||
│
|
||||
│ Merge: RoleStep { role, content, meta, timestamp }
|
||||
│ Append to steps
|
||||
└─────────────────────────────────────────────────────┘
|
||||
│ Persist: StepNode { start, prev, role, output, detail, agent }
|
||||
│ Update: threads.yaml head pointer
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Context Types (progressive)
|
||||
### Context types
|
||||
|
||||
Defined in `packages/workflow-protocol/src/types.ts`:
|
||||
|
||||
```typescript
|
||||
// Phase 1: Moderator sees accumulated state only
|
||||
type ModeratorContext<M> = {
|
||||
threadId: string;
|
||||
start: StartStep;
|
||||
steps: RoleStep<M>[];
|
||||
type StepContext = {
|
||||
role: string;
|
||||
output: unknown; // CAS node payload, expanded (not hash)
|
||||
detail: CasRef;
|
||||
agent: string;
|
||||
};
|
||||
|
||||
// Phase 2: Agent knows its identity
|
||||
type AgentContext<M> = ModeratorContext<M> & {
|
||||
currentRole: { name: string; systemPrompt: string };
|
||||
type ModeratorContext = {
|
||||
start: StartNodePayload; // { workflow: CasRef, prompt: string }
|
||||
steps: StepContext[]; // chronological, oldest first
|
||||
};
|
||||
|
||||
// Phase 3: Extractor has agent output
|
||||
type ExtractContext<M> = AgentContext<M> & {
|
||||
agentContent: string;
|
||||
type AgentContext = ModeratorContext & {
|
||||
threadId: ThreadId;
|
||||
role: string;
|
||||
store: Store;
|
||||
workflow: WorkflowPayload;
|
||||
outputFormatInstruction: string;
|
||||
};
|
||||
|
||||
// ThreadContext is an alias for AgentContext (backward compat)
|
||||
type ThreadContext<M> = AgentContext<M>;
|
||||
```
|
||||
|
||||
### Key Properties
|
||||
### Key properties
|
||||
|
||||
- **Moderator is synchronous and pure** — no I/O, no state mutation
|
||||
- **Agent gets context, not instructions** — reads `ctx.currentRole.systemPrompt`
|
||||
- **Extractor is a general tool** — not limited to post-agent extraction; agents can use it too (e.g. Cursor agent extracts workspace path before execution)
|
||||
- **extractPrompt is a call parameter**, not context state — different callers use different prompts
|
||||
- **Moderator** — pure status-based map lookup; no LLM call, no I/O beyond CAS reads. Looks up `graph[lastRole][lastOutput.status]` to get the next target.
|
||||
- **Agent** — receives `AgentContext` with thread history + role system prompt + output format instruction. Raw output is frontmatter markdown.
|
||||
- **Extractor** — two-layer: tries frontmatter fast-path first (zero LLM cost), falls back to LLM extract if frontmatter is absent or invalid.
|
||||
- **Stateless** — each `uwf thread step` is an atomic, self-contained operation. No in-memory state between steps.
|
||||
|
||||
## Agent Information Sources
|
||||
## Agent CLI protocol
|
||||
|
||||
An agent has exactly three information sources:
|
||||
Each agent is an external command invoked by `uwf thread step`:
|
||||
|
||||
1. **Prior knowledge** — LLM training, agent memory, agent skills
|
||||
2. **Thread context** — `AgentContext` (start, steps, currentRole)
|
||||
3. **Derived information** — from 1 & 2 (e.g. tool calls, shell commands)
|
||||
|
||||
No hidden environment parameters. If an agent needs something (like a workspace path), it extracts it from context using `ExtractFn`.
|
||||
|
||||
## Bundle Contract
|
||||
|
||||
A workflow bundle is a single `.esm.js` file with two named exports:
|
||||
|
||||
```typescript
|
||||
// Named exports (no default export)
|
||||
export const descriptor: WorkflowDescriptor;
|
||||
export const run: WorkflowFn;
|
||||
|
||||
type WorkflowFn = (
|
||||
input: { prompt: string; steps: RoleOutput[] },
|
||||
options: { threadId: string; maxRounds: number },
|
||||
) => AsyncGenerator<RoleOutput, WorkflowResult>;
|
||||
```bash
|
||||
<agent-cmd> <thread-id> <role>
|
||||
```
|
||||
|
||||
### Constraints
|
||||
Contract:
|
||||
1. `uwf thread step` determines the next role via the moderator
|
||||
2. Agent CLI is spawned with `(thread-id, role)` as positional args
|
||||
3. `workflow-util-agent` (`createAgent`) handles the boilerplate:
|
||||
- Parses argv
|
||||
- Loads `.env` from storage root
|
||||
- Builds `AgentContext` by walking the CAS chain from `threads.yaml` head
|
||||
- Resolves the role's `meta` schema and builds `outputFormatInstruction`
|
||||
- Calls the agent's `run` function
|
||||
- Runs two-layer extract on the raw output
|
||||
- Writes `StepNode` to CAS (output + detail + prev link)
|
||||
- Prints the new `StepNode` CAS hash to stdout
|
||||
4. `uwf thread step` reads stdout, updates `threads.yaml` head pointer, re-evaluates moderator for `done`
|
||||
5. Exit 0 = success, non-zero = failure
|
||||
|
||||
- Single `.esm.js` file
|
||||
- No dynamic `import()`
|
||||
- All static imports must be Node built-in modules only
|
||||
- XXH64 hash (Crockford Base32) = globally unique version ID
|
||||
Agent resolution priority: `--agent` CLI override → `config.yaml` per-workflow/role override → `config.yaml` `defaultAgent`.
|
||||
|
||||
### Why AsyncGenerator?
|
||||
## Agent output format: frontmatter markdown (RFC #351)
|
||||
|
||||
- Each `yield` → engine writes to `.data.jsonl`, checks abort/pause
|
||||
- `return` → engine marks thread complete
|
||||
- Fork = pass historical steps as `input.steps` to a new generator
|
||||
- Zero injection — bundle doesn't import from the engine
|
||||
Agents produce **frontmatter markdown** — YAML frontmatter for structured meta, followed by a markdown body for content:
|
||||
|
||||
## Storage Layout
|
||||
```markdown
|
||||
---
|
||||
status: done
|
||||
next: reviewer
|
||||
confidence: 0.9
|
||||
artifacts:
|
||||
- src/auth.ts
|
||||
scope: role
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
Fixed the login redirect by updating the auth middleware...
|
||||
```
|
||||
|
||||
The `outputFormatInstruction` (built by `buildOutputFormatInstruction` in `workflow-util-agent`) is prepended to the role's system prompt, so the deliverable format is the first thing the agent sees. It lists the expected frontmatter fields derived from the role's `meta` JSON Schema.
|
||||
|
||||
## Two-layer extract
|
||||
|
||||
Structured output extraction uses a two-layer strategy (`workflow-util-agent`):
|
||||
|
||||
### Layer 1: frontmatter fast path (`frontmatter.ts`)
|
||||
|
||||
1. Parse YAML frontmatter from raw agent output (`parseFrontmatterMarkdown`)
|
||||
2. Validate required fields (`validateFrontmatter`)
|
||||
3. Build a candidate object from frontmatter fields (`status`, `next`, `confidence`, `artifacts`, `scope`)
|
||||
4. `store.put()` the candidate against the role's `meta` schema
|
||||
5. Validate with `json-cas` schema validation
|
||||
6. If valid → return `outputHash` (zero LLM cost)
|
||||
|
||||
### Layer 2: LLM extract fallback (`extract.ts`)
|
||||
|
||||
If the fast path returns `null` (no frontmatter, invalid, or doesn't satisfy schema):
|
||||
|
||||
1. Resolve extract model alias from config (`modelOverrides.extract` → `models.extract` → `defaultModel`)
|
||||
2. Call OpenAI-compatible chat completion with JSON mode
|
||||
3. System prompt: "Extract structured data matching this JSON Schema: ..."
|
||||
4. User message: the raw agent output
|
||||
5. Parse response, `store.put()`, validate
|
||||
6. Return `outputHash`
|
||||
|
||||
## Prompt injection
|
||||
|
||||
`workflow-util-agent` prepends two pieces of context to the agent's system prompt:
|
||||
|
||||
1. **Deliverable format instruction** — generated from the role's `meta` schema, tells the agent exactly what frontmatter fields to produce and the expected format
|
||||
2. **Scope constraint** — "Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope."
|
||||
|
||||
This ensures agents produce parseable frontmatter output without requiring per-agent format knowledge.
|
||||
|
||||
## CAS node types
|
||||
|
||||
### Workflow
|
||||
|
||||
```yaml
|
||||
type: <workflow-schema-hash>
|
||||
payload:
|
||||
name: "solve-issue"
|
||||
description: "End-to-end issue resolution"
|
||||
roles:
|
||||
planner:
|
||||
description: "Creates implementation plan"
|
||||
goal: "You are a planning agent..."
|
||||
capabilities: [planning, issue-analysis]
|
||||
procedure: "Analyze the issue and create a plan."
|
||||
output: "Output the plan summary."
|
||||
meta: "5GWKR8TN1V3JA" # cas_ref → JSON Schema node
|
||||
conditions:
|
||||
notApproved:
|
||||
description: "Reviewer rejected"
|
||||
expression: "steps[-1].output.approved = false"
|
||||
graph:
|
||||
$START:
|
||||
- role: "planner"
|
||||
condition: null
|
||||
```
|
||||
|
||||
### StartNode
|
||||
|
||||
```yaml
|
||||
type: <start-node-schema-hash>
|
||||
payload:
|
||||
workflow: "4KNM2PXR3B1QW" # cas_ref → Workflow
|
||||
prompt: "Fix the login bug..."
|
||||
```
|
||||
|
||||
### StepNode
|
||||
|
||||
```yaml
|
||||
type: <step-node-schema-hash>
|
||||
payload:
|
||||
start: "4TNVW8KR2B3MA" # cas_ref → StartNode
|
||||
prev: "2MXBG6PN4A8JR" # cas_ref → previous StepNode (null for first step)
|
||||
role: "developer"
|
||||
output: "9KRVW3TN5F1QA" # cas_ref → structured output (validated against meta schema)
|
||||
detail: "7BQST3VW9F2MA" # cas_ref → execution detail (raw turns, session data)
|
||||
agent: "uwf-hermes" # agent command used (plain string)
|
||||
```
|
||||
|
||||
### Chain structure
|
||||
|
||||
```
|
||||
threads.yaml: { "01J7K9...4T": "8FWKR3TN5V1QA" }
|
||||
│
|
||||
▼
|
||||
StepNode (step 3)
|
||||
├── start ──→ StartNode
|
||||
│ ├── workflow → Workflow (CAS)
|
||||
│ └── prompt: "Fix..."
|
||||
├── prev ──→ StepNode (step 2)
|
||||
│ ├── prev ──→ StepNode (step 1)
|
||||
│ │ └── prev: null
|
||||
│ └── ...
|
||||
├── role: "reviewer"
|
||||
├── output → CAS({ approved: true })
|
||||
├── detail → CAS(session turns)
|
||||
└── agent: "uwf-hermes"
|
||||
```
|
||||
|
||||
## Storage layout
|
||||
|
||||
```
|
||||
~/.uncaged/workflow/
|
||||
├── bundles/
|
||||
│ ├── C9NMV6V2TQT81.esm.js # Crockford Base32 of XXH64
|
||||
│ └── C9NMV6V2TQT81.yaml # Role descriptor
|
||||
├── logs/ # One folder per bundle hash
|
||||
│ └── C9NMV6V2TQT81/
|
||||
│ ├── 01KQXKW…YG.data.jsonl # Thread state
|
||||
│ └── 01KQXKW…YG.info.jsonl # Debug log
|
||||
└── workflow.yaml # Registry
|
||||
├── cas/ # json-cas filesystem store (all CAS nodes)
|
||||
├── config.yaml # Provider, model, agent configuration
|
||||
├── threads.yaml # Active thread head pointers: threadId → CasRef
|
||||
├── history.jsonl # Archived thread records
|
||||
├── registry.yaml # Workflow name → CAS hash mapping
|
||||
└── .env # API keys (loaded by dotenv)
|
||||
```
|
||||
|
||||
### ID Encoding: Crockford Base32
|
||||
### Mutable state
|
||||
|
||||
Only three files carry mutable state:
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `threads.yaml` | `Record<ThreadId, CasRef>` — maps active thread IDs to head node hash |
|
||||
| `history.jsonl` | Append-only log of completed threads (`thread`, `workflow`, `head`, `completedAt`) |
|
||||
| `registry.yaml` | Workflow name → current CAS hash |
|
||||
|
||||
Everything else is immutable CAS content.
|
||||
|
||||
### ID encoding: Crockford Base32
|
||||
|
||||
- Case-insensitive, filesystem-safe, no ambiguous chars (0/O, 1/I/L)
|
||||
- Bundle hash: XXH64 → 13-char
|
||||
- Thread ID: ULID → 26-char (10 timestamp + 16 random)
|
||||
- CAS hash: XXH64 → 13-char Crockford Base32
|
||||
- Thread ID: ULID → 26-char Crockford Base32 (10 timestamp + 16 random)
|
||||
|
||||
### Registry (`workflow.yaml`)
|
||||
### Config (`config.yaml`)
|
||||
|
||||
```yaml
|
||||
workflows:
|
||||
providers:
|
||||
openrouter:
|
||||
baseUrl: "https://openrouter.ai/api/v1"
|
||||
apiKey: "sk-..."
|
||||
|
||||
models:
|
||||
sonnet:
|
||||
provider: "openrouter"
|
||||
name: "anthropic/claude-sonnet-4"
|
||||
gpt4o-mini:
|
||||
provider: "openai"
|
||||
name: "gpt-4o-mini"
|
||||
|
||||
agents:
|
||||
hermes:
|
||||
command: "uwf-hermes"
|
||||
args: []
|
||||
cursor:
|
||||
command: "uwf-cursor"
|
||||
args: []
|
||||
|
||||
defaultAgent: "hermes"
|
||||
agentOverrides:
|
||||
solve-issue:
|
||||
hash: "C9NMV6V2TQT81"
|
||||
timestamp: 1714963200000
|
||||
history:
|
||||
- hash: "A7BKR3M1NPQ40"
|
||||
timestamp: 1714876800000
|
||||
developer: "cursor"
|
||||
|
||||
defaultModel: "sonnet"
|
||||
modelOverrides:
|
||||
extract: "gpt4o-mini"
|
||||
```
|
||||
|
||||
### Thread JSONL
|
||||
## CLI commands
|
||||
|
||||
**`.data.jsonl`** — Line 1: start record, Line 2+: role outputs
|
||||
Binary: `uwf`
|
||||
|
||||
```jsonc
|
||||
// Start record
|
||||
{ "name": "solve-issue", "hash": "C9NMV6V2TQT81", "threadId": "01KQXKW…",
|
||||
"parameters": { "prompt": "Fix bug #3", "options": { "maxRounds": 5 } },
|
||||
"timestamp": 1714963200000 }
|
||||
// Role output
|
||||
{ "role": "planner", "content": "...", "meta": { "phases": [...] }, "timestamp": ... }
|
||||
```
|
||||
### Thread commands
|
||||
|
||||
**`.info.jsonl`** — Structured debug log
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf thread start <workflow> -p <prompt>` | Create a thread (StartNode → CAS, head → threads.yaml). No execution. |
|
||||
| `uwf thread step <thread-id> [--agent <cmd>]` | Execute one moderator→agent→extract cycle. |
|
||||
| `uwf thread show <thread-id>` | Show thread head pointer and done status. |
|
||||
| `uwf thread list [--all]` | List active threads (`--all` includes archived). |
|
||||
| `uwf thread steps <thread-id>` | List all steps in chronological order. |
|
||||
| `uwf thread read <thread-id> [--quota <chars>] [--before <hash>]` | Render thread as human-readable markdown. |
|
||||
| `uwf thread fork <step-hash>` | Fork a thread from a specific CAS node. |
|
||||
| `uwf thread step-details <step-hash>` | Dump full detail node as YAML. |
|
||||
| `uwf thread kill <thread-id>` | Terminate and archive a thread. |
|
||||
|
||||
```jsonc
|
||||
{ "tag": "4KNMR2PX", "content": "Loading bundle...", "timestamp": ... }
|
||||
```
|
||||
### Workflow commands
|
||||
|
||||
Tags are 8-char Crockford Base32 (40-bit random), one per call site. `grep "4KNMR2PX"` → instant code location.
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf workflow put <file.yaml>` | Register a workflow from YAML definition. |
|
||||
| `uwf workflow show <id>` | Show workflow by name or CAS hash. |
|
||||
| `uwf workflow list` | List registered workflows. |
|
||||
|
||||
## Execution Model
|
||||
### CAS commands
|
||||
|
||||
- **No daemon.** `uncaged-workflow run <name>` starts a worker process
|
||||
- Same bundle's threads share one process (memory efficiency)
|
||||
- Process exits when all threads complete
|
||||
- Thread termination via IPC within the process
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf cas get <hash>` | Read a CAS node. |
|
||||
| `uwf cas put <type-hash> <data>` | Store a node, print its hash. |
|
||||
| `uwf cas has <hash>` | Check if a hash exists. |
|
||||
| `uwf cas refs <hash>` | List direct CAS references. |
|
||||
| `uwf cas walk <hash>` | Recursive traversal from a node. |
|
||||
| `uwf cas reindex` | Rebuild type index from all nodes. |
|
||||
| `uwf cas schema list` | List registered schemas. |
|
||||
| `uwf cas schema get <hash>` | Show a schema by type hash. |
|
||||
|
||||
## CLI Commands
|
||||
### Setup
|
||||
|
||||
| Priority | Command | Description |
|
||||
|----------|---------|-------------|
|
||||
| P1 | `add <name> <file.esm.js>` | Register a bundle |
|
||||
| P1 | `list` | List registered workflows |
|
||||
| P1 | `show <name>` | Show workflow details |
|
||||
| P1 | `remove <name>` | Remove a workflow |
|
||||
| P1 | `run <name> [--prompt] [--max-rounds]` | Start a thread |
|
||||
| P1 | `threads [name]` | List threads |
|
||||
| P1 | `thread <id>` | Show thread state |
|
||||
| P1 | `thread rm <id>` | Delete a thread |
|
||||
| P1 | `ps` | List running threads |
|
||||
| P1 | `kill <thread-id>` | Terminate a running thread |
|
||||
| P2 | `history <name>` | Show version history |
|
||||
| P2 | `rollback <name> [hash]` | Switch to a previous version |
|
||||
| P2 | `pause <thread-id>` | Pause a running thread |
|
||||
| P2 | `resume <thread-id>` | Resume a paused thread |
|
||||
| P3 | `fork <thread-id> [--from-role <role>]` | Fork from historical state |
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf setup [--provider --base-url --api-key --model --agent]` | Configure provider/model/agent (interactive if no flags). |
|
||||
|
||||
All commands implemented and tested. ✅
|
||||
## Toolchain
|
||||
|
||||
## Design Decisions
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| **bun** | Package manager + runtime |
|
||||
| **TypeScript** | Type checking (strict mode) |
|
||||
| **Biome** | Lint + format |
|
||||
| **vitest** | Test runner |
|
||||
|
||||
## Design decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| **Role = pure data** | Decouples definition from execution; same role with different agents |
|
||||
| **Agent bound at runtime** | WorkflowDefinition is reusable; agent choice is deployment concern |
|
||||
| **Three-phase context** | Each phase sees only what it needs; clean separation |
|
||||
| **ExtractFn as general tool** | Agents use it for pre-execution extraction; engine uses it for meta |
|
||||
| **Single-file ESM** | Hash = version, no dependency hell, self-contained |
|
||||
| **No daemon** | OS handles process lifecycle; unnecessary complexity |
|
||||
| **Crockford Base32** | Filesystem-safe, readable, compact |
|
||||
| **No concurrency in registry** | Different workflows have different constraints; belongs at workflow/role level |
|
||||
| **No dryRun** | Tests use mock agents + mock fetch; simpler architecture |
|
||||
| **YAML workflow definitions** | Human-readable, versionable, no build step required. JSON Schema inline in YAML, registered as CAS nodes on `workflow put`. |
|
||||
| **Stateless single-step CLI** | Each `uwf thread step` is atomic — no in-memory state, no daemon, no long-running process. OS handles lifecycle. |
|
||||
| **CAS-backed thread state** | Immutable linked nodes enable fork, replay, and GC without copying data. Content-addressed deduplication across threads. |
|
||||
| **Status-based moderator** | Status-based map routing — `graph[role][status]` lookup against last output. No LLM cost for routing decisions. |
|
||||
| **Frontmatter markdown output** | Agents produce structured meta (YAML frontmatter) alongside free-form content (markdown body). Enables zero-cost extraction when frontmatter is well-formed. |
|
||||
| **Two-layer extract** | Fast path avoids LLM calls when agents follow the format; LLM fallback handles messy output gracefully. |
|
||||
| **Prompt injection for format** | Output format instruction prepended to system prompt ensures agents produce parseable output without per-agent configuration. |
|
||||
| **JSON Schema (not Zod)** | Schemas are CAS-native data — storable, hashable, validatable through `json-cas`. No code generation, no runtime library dependency. |
|
||||
| **Agent as external command** | Agents are independent CLI binaries (`uwf-hermes`, `uwf-cursor`). Swappable per workflow/role via config. No tight coupling to the engine. |
|
||||
| **No daemon** | Process starts, does one step, exits. Simpler failure model, no connection management. |
|
||||
| **Crockford Base32** | Filesystem-safe, case-insensitive, readable, compact. |
|
||||
|
||||
@@ -0,0 +1,779 @@
|
||||
# Built-in Role Agent 调研
|
||||
|
||||
## 目标
|
||||
|
||||
实现一个内置的 role agent(暂称 `uwf-builtin`),不依赖 hermes/openclaw 等外部 agent 进程。
|
||||
直接使用 workflow config 中配置的 model,自己实现 agent run loop 和关键 toolkit。
|
||||
|
||||
---
|
||||
|
||||
## 关键问题
|
||||
|
||||
### Q1: Agent 接口协议
|
||||
|
||||
现有 agent 是怎么被 CLI 调用的?输入(argv、环境变量)和输出(stdout、CAS)格式是什么?
|
||||
|
||||
**调研要点:**
|
||||
- `cli-workflow` 里 `spawnAgent` 的完整实现
|
||||
- AgentConfig 类型定义
|
||||
- agent 进程的 exit code 约定
|
||||
- 环境变量传递(UWF_STORAGE_ROOT 等)
|
||||
|
||||
**答案:**
|
||||
|
||||
#### 调用链
|
||||
|
||||
`uwf thread step` → `cmdThreadStepOnce` → moderator 求值下一 role → `resolveAgentConfig` → `spawnAgent`。
|
||||
|
||||
#### AgentConfig 类型
|
||||
|
||||
```146:149:packages/workflow-protocol/src/types.ts
|
||||
export type AgentConfig = {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
```
|
||||
|
||||
在 `config.yaml` 的 `agents` 段注册,例如 `hermes: { command: "uwf-hermes", args: [] }`。
|
||||
|
||||
#### spawnAgent 行为
|
||||
|
||||
```627:653:packages/cli-workflow/src/commands/thread.ts
|
||||
function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): CasRef {
|
||||
const argv = [...agent.args, threadId, role];
|
||||
let stdout: string;
|
||||
try {
|
||||
stdout = execFileSync(agent.command, argv, {
|
||||
encoding: "utf8",
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (e) {
|
||||
// ... stderr 拼进 fail 消息
|
||||
}
|
||||
|
||||
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
|
||||
if (!isCasRef(line)) {
|
||||
fail(`agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
```
|
||||
|
||||
| 项目 | 约定 |
|
||||
|------|------|
|
||||
| **argv** | `[...agent.args, <thread-id>, <role>]`,即 `process.argv[2]`=threadId,`process.argv[3]`=role(与 `createAgent` 的 `parseArgv` 一致) |
|
||||
| **stdin** | 忽略 |
|
||||
| **stdout** | 纯文本,**最后一行**必须是新 `StepNode` 的 CAS hash(13 字符 Crockford Base32) |
|
||||
| **stderr** | 失败时 CLI 会附带 stderr;成功时无约定 |
|
||||
| **exit code** | `0` = 成功;非 0 时 `execFileSync` 抛错,step 失败 |
|
||||
| **环境变量** | 继承父进程 `process.env`(含 storage root、API key 等) |
|
||||
| **链头更新** | **不由 agent 负责**;agent 只写 CAS StepNode,CLI 在拿到 stdout hash 后更新 `threads.yaml` |
|
||||
|
||||
Agent 解析优先级(`resolveAgentConfig`):
|
||||
|
||||
1. CLI `--agent` override(整段 command + args 字符串)
|
||||
2. `config.agentOverrides[workflow.name][role]`
|
||||
3. `config.defaultAgent`
|
||||
|
||||
#### 环境变量:Storage Root
|
||||
|
||||
文档中写的 `UWF_STORAGE_ROOT` **在当前代码中不存在**。实际优先级(`workflow-util-agent` / `cli-workflow` 一致):
|
||||
|
||||
```33:43:packages/workflow-util-agent/src/storage.ts
|
||||
export function resolveStorageRoot(): string {
|
||||
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
if (internal !== undefined && internal !== "") {
|
||||
return internal;
|
||||
}
|
||||
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
|
||||
if (userOverride !== undefined && userOverride !== "") {
|
||||
return userOverride;
|
||||
}
|
||||
return getDefaultStorageRoot();
|
||||
}
|
||||
```
|
||||
|
||||
Agent 子进程通过继承的 `process.env` 与父 CLI 共享同一 storage root;`createAgent` 内还会 `loadDotenv({ path: getEnvPath(storageRoot) })` 加载 `~/.uncaged/workflow/.env`。
|
||||
|
||||
#### Agent 侧职责(设计文档 + 实现)
|
||||
|
||||
- 读 `threads.yaml` 链头,构建 context,执行 role
|
||||
- 将 `StepNode` 写入 CAS(`output` / `detail` / `agent` / `prev` / `start`)
|
||||
- stdout 打印 step hash
|
||||
- **不**更新 `threads.yaml`
|
||||
|
||||
---
|
||||
|
||||
### Q2: createAgent 工厂
|
||||
|
||||
workflow-util-agent 的 `createAgent` 做了什么?它的完整生命周期是什么?
|
||||
|
||||
**调研要点:**
|
||||
- `AgentOptions` 类型的 `run` 和 `continue` 回调签名
|
||||
- `AgentRunResult` 的完整定义
|
||||
- retry 逻辑(frontmatter 校验失败后的重试机制)
|
||||
- `persistStep` 写入 CAS 的 StepNode 结构
|
||||
|
||||
**答案:**
|
||||
|
||||
#### 类型定义
|
||||
|
||||
```4:35:packages/workflow-util-agent/src/types.ts
|
||||
export type AgentContext = ModeratorContext & {
|
||||
threadId: ThreadId;
|
||||
role: string;
|
||||
store: Store;
|
||||
workflow: WorkflowPayload;
|
||||
outputFormatInstruction: string;
|
||||
};
|
||||
|
||||
export type AgentRunResult = {
|
||||
output: string;
|
||||
detailHash: CasRef;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export type AgentContinueFn = (
|
||||
sessionId: string,
|
||||
message: string,
|
||||
store: AgentContext["store"],
|
||||
) => Promise<AgentRunResult>;
|
||||
|
||||
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
|
||||
|
||||
export type AgentOptions = {
|
||||
name: string;
|
||||
run: AgentRunFn;
|
||||
continue: AgentContinueFn;
|
||||
};
|
||||
```
|
||||
|
||||
- **`run(ctx)`**:首次执行,返回原始 agent 文本 `output`、审计用 `detailHash`、用于续聊的 `sessionId`。
|
||||
- **`continue(sessionId, message, store)`**:在同一 session 上追加用户消息(用于 frontmatter 纠错),再次返回 `AgentRunResult`。
|
||||
|
||||
`createAgent(options)` 返回 `() => Promise<void>`,作为 agent CLI 的 `main`(见 `uwf-hermes` 的 `cli.ts`)。
|
||||
|
||||
#### 生命周期(按执行顺序)
|
||||
|
||||
```101:152:packages/workflow-util-agent/src/run.ts
|
||||
export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
return async function main(): Promise<void> {
|
||||
const { threadId, role } = parseArgv(process.argv);
|
||||
const storageRoot = resolveStorageRoot();
|
||||
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||
|
||||
const ctx = await buildContextWithMeta(threadId, role);
|
||||
// 1. 校验 role 存在
|
||||
// 2. 从 CAS 取 frontmatter JSON Schema → buildOutputFormatInstruction → ctx.outputFormatInstruction
|
||||
|
||||
let agentResult = await options.run(ctx);
|
||||
|
||||
let outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
|
||||
for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && outputHash === null; retry++) {
|
||||
const correctionMessage = "Your previous response did not contain valid YAML frontmatter...";
|
||||
agentResult = await options.continue(agentResult.sessionId, correctionMessage, ctx.meta.store);
|
||||
outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
}
|
||||
|
||||
if (outputHash === null) { fail(...); }
|
||||
|
||||
const stepHash = await persistStep({ ctx, outputHash, detailHash: agentResult.detailHash, agentName });
|
||||
process.stdout.write(`${stepHash}\n`);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
| 阶段 | 行为 |
|
||||
|------|------|
|
||||
| 解析 argv | `argv[2]=threadId`, `argv[3]=role`,缺失则 `stderr` + `exit(1)` |
|
||||
| Context | `buildContextWithMeta` + 可选 `outputFormatInstruction` |
|
||||
| Run | `options.run(ctx)` |
|
||||
| Extract | **仅** `tryFrontmatterFastPath`(见 Q4);**不**调用 `extract()` LLM fallback |
|
||||
| Retry | 最多 `MAX_FRONTMATTER_RETRIES = 2` 次 `continue` + 再试 fast-path |
|
||||
| Persist | `persistStep` → `writeStepNode` |
|
||||
| 输出 | stdout 一行 step CAS hash |
|
||||
|
||||
#### StepNode 写入结构
|
||||
|
||||
```44:68:packages/workflow-util-agent/src/run.ts
|
||||
async function writeStepNode(options: {
|
||||
store: AgentStore["store"];
|
||||
schemas: AgentStore["schemas"];
|
||||
startHash: CasRef;
|
||||
prevHash: CasRef | null;
|
||||
role: string;
|
||||
outputHash: CasRef;
|
||||
detailHash: CasRef;
|
||||
agentName: string;
|
||||
}): Promise<CasRef> {
|
||||
const payload: StepNodePayload = {
|
||||
start: options.startHash,
|
||||
prev: options.prevHash,
|
||||
role: options.role,
|
||||
output: options.outputHash,
|
||||
detail: options.detailHash,
|
||||
agent: options.agentName,
|
||||
};
|
||||
// store.put(stepNode schema) + validate
|
||||
}
|
||||
```
|
||||
|
||||
`agentName` 经 `agentLabel(name)` 规范化:已有 `uwf-` 前缀则原样,否则加 `uwf-`(如 `hermes` → `uwf-hermes`)。
|
||||
|
||||
`prevHash`:若链头仍是 `StartNode` 则为 `null`,否则为当前 head step hash。
|
||||
|
||||
---
|
||||
|
||||
### Q3: Context Builder
|
||||
|
||||
`buildContextWithMeta` 构建了什么上下文给 agent?
|
||||
|
||||
**调研要点:**
|
||||
- `AgentContext` 完整类型定义(所有字段)
|
||||
- context 构建过程(CAS chain walk)
|
||||
- `outputFormatInstruction` 怎么生成的
|
||||
- role definition 怎么获取(从 workflow YAML)
|
||||
|
||||
**答案:**
|
||||
|
||||
#### AgentContext 字段
|
||||
|
||||
继承 `ModeratorContext`:
|
||||
|
||||
```60:68:packages/workflow-protocol/src/types.ts
|
||||
export type ModeratorContext = {
|
||||
start: StartNodePayload;
|
||||
steps: StepContext[];
|
||||
};
|
||||
```
|
||||
|
||||
```48:51:packages/workflow-protocol/src/types.ts
|
||||
export type StartNodePayload = {
|
||||
workflow: CasRef;
|
||||
prompt: string;
|
||||
};
|
||||
```
|
||||
|
||||
```61:63:packages/workflow-protocol/src/types.ts
|
||||
export type StepContext = Omit<StepRecord, "output"> & {
|
||||
output: unknown;
|
||||
};
|
||||
```
|
||||
|
||||
`AgentContext` 额外字段:
|
||||
|
||||
| 字段 | 类型 | 含义 |
|
||||
|------|------|------|
|
||||
| `threadId` | `ThreadId` | 当前线程 |
|
||||
| `role` | `string` | 本步要执行的角色名 |
|
||||
| `store` | `Store` | CAS store(读写节点) |
|
||||
| `workflow` | `WorkflowPayload` | 已从 CAS 加载的 workflow 定义 |
|
||||
| `outputFormatInstruction` | `string` | 由 `createAgent` 根据 role 的 frontmatter schema 生成;`buildContext*` 初始为 `""` |
|
||||
|
||||
`buildContextWithMeta` 还返回 `meta`:
|
||||
|
||||
```148:154:packages/workflow-util-agent/src/context.ts
|
||||
export type BuildContextMeta = {
|
||||
storageRoot: string;
|
||||
store: Store;
|
||||
schemas: AgentStore["schemas"];
|
||||
headHash: CasRef;
|
||||
chain: ChainState;
|
||||
};
|
||||
```
|
||||
|
||||
#### CAS chain walk
|
||||
|
||||
1. 从 `threads.yaml[threadId]` 取 `headHash`
|
||||
2. `walkChain`:若 head 是 `StartNode`,`stepsNewestFirst=[]`;否则沿 `prev` 收集所有 `StepNode`, newest-first
|
||||
3. `buildHistory`:反转为时间序,`expandOutput` 把每步 `output` CasRef 展开为 JSON payload(供 prompt / moderator 使用)
|
||||
4. `loadWorkflow`:从 `start.workflow` CasRef 加载 `WorkflowPayload`
|
||||
|
||||
#### Role definition 来源
|
||||
|
||||
- 作者写在 workflow YAML 的 `roles.<name>`(`goal`, `capabilities`, `procedure`, `output`, `frontmatter` 等)
|
||||
- `uwf workflow put` 时 `frontmatter` 内联 JSON Schema 经 `putSchema` 存入 CAS,workflow 里存的是 **CasRef**
|
||||
- Agent 运行时:`ctx.workflow.roles[ctx.role]` → `RoleDefinition`
|
||||
|
||||
#### outputFormatInstruction
|
||||
|
||||
在 `createAgent` 中,若 `getSchema(store, roleDef.frontmatter)` 非空,则:
|
||||
|
||||
```typescript
|
||||
ctx.outputFormatInstruction = buildOutputFormatInstruction(frontmatterSchema);
|
||||
```
|
||||
|
||||
`buildOutputFormatInstruction` 根据 JSON Schema 的 `properties` 生成「必须以 `---` YAML frontmatter 开头」的说明和示例字段列表(见 `build-output-format-instruction.ts`)。
|
||||
|
||||
各 agent 实现(Hermes / Claude Code)在组装 prompt 时把该块放在最前,再接 `buildRolePrompt(roleDef)`。
|
||||
|
||||
---
|
||||
|
||||
### Q4: Extract Pipeline
|
||||
|
||||
agent 输出怎么被处理成结构化数据?
|
||||
|
||||
**调研要点:**
|
||||
- frontmatter fast-path 的完整逻辑
|
||||
- LLM extract fallback 的实现(`extract.ts`)
|
||||
- frontmatter schema 从哪里来(role 定义里的 `frontmatter` 字段)
|
||||
- 校验失败时的 correction prompt 是什么
|
||||
|
||||
**答案:**
|
||||
|
||||
#### Schema 来源
|
||||
|
||||
Workflow YAML 中每个 role 的 `frontmatter:` 段是 JSON Schema 对象;注册时:
|
||||
|
||||
```66:76:packages/cli-workflow/src/commands/workflow.ts
|
||||
async function resolveFrontmatterRef(..., frontmatter: unknown): Promise<CasRef> {
|
||||
// 校验为 JSON Schema → putSchema → 返回 CasRef
|
||||
}
|
||||
```
|
||||
|
||||
运行时 `roleDef.frontmatter` 即该 schema 的 CAS hash;structured `output` 节点用**同一 schema** 写入 CAS。
|
||||
|
||||
#### Frontmatter fast-path(createAgent 实际使用的路径)
|
||||
|
||||
```148:195:packages/workflow-util-agent/src/frontmatter.ts
|
||||
export async function tryFrontmatterFastPath(
|
||||
raw: string,
|
||||
outputSchema: CasRef,
|
||||
store: Store,
|
||||
): Promise<FrontmatterFastPathResult | null>
|
||||
```
|
||||
|
||||
流程:
|
||||
|
||||
1. `parseFrontmatterMarkdown(raw)` → 标准 agent 字段(`status`, `next`, `confidence`, `artifacts`, `scope`)+ body
|
||||
2. `validateFrontmatter` 失败 → `null`
|
||||
3. `getSchema(store, outputSchema)` + `extractSchemaFields` 得到 role 需要的属性名
|
||||
4. `buildCandidate`:从标准 frontmatter + YAML 原始字段拼出符合 schema 的对象
|
||||
5. `store.put(outputSchema, candidate)` + `validate` → 成功则 `{ body, outputHash }`
|
||||
|
||||
**永不抛错**,失败返回 `null`。
|
||||
|
||||
#### LLM extract fallback(已实现但未接入 createAgent)
|
||||
|
||||
```135:181:packages/workflow-util-agent/src/extract.ts
|
||||
export async function extract(
|
||||
rawOutput: string,
|
||||
outputSchema: CasRef,
|
||||
config: WorkflowConfig,
|
||||
): Promise<ExtractResult>
|
||||
```
|
||||
|
||||
- 模型:`resolveExtractModelAlias(config)` → `modelOverrides.extract` → `models.extract` → `models.default` → `defaultModel`
|
||||
- HTTP:`POST {baseUrl}/chat/completions`,`response_format: { type: "json_object" }`
|
||||
- System:要求按 JSON Schema 从 agent 输出提取单个 JSON 对象
|
||||
- 校验通过后 `store.put(outputSchema, structured)`
|
||||
|
||||
**重要:`createAgent` 当前未调用 `extract()`**。fast-path 失败且 2 次 `continue` 仍失败则直接 `fail()`。builtin agent 若希望无 frontmatter 也能跑,需在 kit 或 builtin 层显式接入 `extract()`。
|
||||
|
||||
#### Correction prompt(retry)
|
||||
|
||||
```125:128:packages/workflow-util-agent/src/run.ts
|
||||
const correctionMessage =
|
||||
"Your previous response did not contain valid YAML frontmatter matching the role schema.\n" +
|
||||
"You MUST begin your response with a YAML frontmatter block (--- delimited).\n" +
|
||||
"Please output ONLY the corrected frontmatter block followed by your work.";
|
||||
```
|
||||
|
||||
通过 `options.continue(sessionId, correctionMessage, store)` 发给外部 agent;builtin 需在自有 message 历史里 append 同等语义的 user 消息。
|
||||
|
||||
---
|
||||
|
||||
### Q5: Model 配置与 LLM 调用
|
||||
|
||||
workflow 怎么配置和使用 model?
|
||||
|
||||
**调研要点:**
|
||||
- `WorkflowConfig` 中 providers/models/defaultModel/modelOverrides 的完整定义
|
||||
- `resolveModel` 函数的实现
|
||||
- `chatCompletionText` 的实现(OpenAI 兼容 HTTP 客户端)
|
||||
- 有没有 streaming 支持?tool calling 支持?
|
||||
|
||||
**答案:**
|
||||
|
||||
#### WorkflowConfig
|
||||
|
||||
```136:160:packages/workflow-protocol/src/types.ts
|
||||
export type ProviderConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
export type ModelConfig = {
|
||||
provider: ProviderAlias;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type WorkflowConfig = {
|
||||
providers: Record<ProviderAlias, ProviderConfig>;
|
||||
models: Record<ModelAlias, ModelConfig>;
|
||||
agents: Record<AgentAlias, AgentConfig>;
|
||||
defaultAgent: AgentAlias;
|
||||
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
|
||||
defaultModel: ModelAlias;
|
||||
modelOverrides: Record<Scenario, ModelAlias> | null;
|
||||
};
|
||||
```
|
||||
|
||||
示例见 `docs/architecture.md`(`providers` / `models` / `defaultModel` / `modelOverrides.extract`)。
|
||||
|
||||
#### resolveModel
|
||||
|
||||
```32:50:packages/workflow-util-agent/src/extract.ts
|
||||
export function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider {
|
||||
const modelEntry = config.models[alias];
|
||||
const providerEntry = config.providers[modelEntry.provider];
|
||||
const apiKey = providerEntry.apiKey;
|
||||
return { baseUrl: providerEntry.baseUrl, apiKey, model: modelEntry.name };
|
||||
}
|
||||
```
|
||||
|
||||
`ResolvedLlmProvider = { baseUrl, apiKey, model }`。
|
||||
|
||||
Extract 专用别名解析:
|
||||
|
||||
```18:30:packages/workflow-util-agent/src/extract.ts
|
||||
export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias {
|
||||
return config.modelOverrides?.extract ?? (config.models.extract ? "extract" : config.models.default ? "default" : config.defaultModel);
|
||||
}
|
||||
```
|
||||
|
||||
**尚无** `modelOverrides` 按 role/workflow 解析 agent 主模型的函数;builtin 首版可用 `config.defaultModel`,扩展时可加 `modelOverrides.agent` 或与 `agentOverrides` 对称的表。
|
||||
|
||||
#### chatCompletionText
|
||||
|
||||
```87:124:packages/workflow-util-agent/src/extract.ts
|
||||
async function chatCompletionText(
|
||||
provider: ResolvedLlmProvider,
|
||||
messages: Array<{ role: "system" | "user"; content: string }>,
|
||||
): Promise<string>
|
||||
```
|
||||
|
||||
| 能力 | 现状 |
|
||||
|------|------|
|
||||
| 协议 | OpenAI 兼容 `POST /chat/completions` |
|
||||
| Streaming | **无**(一次性 `response.text()`) |
|
||||
| Tool calling | **无**(无 `tools` / `tool_calls` 字段) |
|
||||
| 多模态 | **无**(仅 text `content`) |
|
||||
| Extract 专用 | `response_format: { type: "json_object" }` |
|
||||
|
||||
builtin agent 的 run loop 需要**新写**带 `tools` 的 completion 客户端(可放在 `workflow-agent-builtin` 或扩展 `workflow-util-agent` 的 `llm/` 模块),不能复用当前 `chatCompletionText` 而不改。
|
||||
|
||||
---
|
||||
|
||||
### Q6: Hermes Agent 参考实现
|
||||
|
||||
`uwf-hermes` 是怎么实现 `run` 和 `continue` 的?
|
||||
|
||||
**调研要点:**
|
||||
- prompt 怎么组装的(outputFormatInstruction + rolePrompt + task + history)
|
||||
- hermes CLI 的调用参数
|
||||
- session management(resume)
|
||||
- 输出怎么捕获
|
||||
|
||||
**答案:**
|
||||
|
||||
#### Prompt 组装
|
||||
|
||||
```40:53:packages/workflow-agent-hermes/src/hermes.ts
|
||||
export function buildHermesPrompt(ctx: AgentContext): string {
|
||||
const roleDef = ctx.workflow.roles[ctx.role];
|
||||
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
|
||||
const parts: string[] = [];
|
||||
if (ctx.outputFormatInstruction !== "") {
|
||||
parts.push(ctx.outputFormatInstruction, "");
|
||||
}
|
||||
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
|
||||
const historyBlock = buildHistorySummary(ctx.steps);
|
||||
if (historyBlock !== "") {
|
||||
parts.push("", historyBlock);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
```
|
||||
|
||||
`buildRolePrompt` 生成 `## Goal` / `## Capabilities` / `## Prepare`(含 `generateCliReference()`)/ `## Procedure` / `## Output`。
|
||||
|
||||
`buildHistorySummary`:每步 `role`、`JSON.stringify(step.output)`、`agent`。
|
||||
|
||||
Hermes 把**整段 prompt 作为单条 user 消息**传给 `hermes chat -q`(无独立 system channel)。
|
||||
|
||||
#### Hermes CLI 参数
|
||||
|
||||
首次:
|
||||
|
||||
```88:97:packages/workflow-agent-hermes/src/hermes.ts
|
||||
spawnHermes(["chat", "-q", prompt, "--yolo", "--max-turns", "90", "--quiet"]);
|
||||
```
|
||||
|
||||
续聊:
|
||||
|
||||
```100:114:packages/workflow-agent-hermes/src/hermes.ts
|
||||
spawnHermes(["chat", "--resume", sessionId, "-q", message, "--yolo", "--max-turns", "90", "--quiet"]);
|
||||
```
|
||||
|
||||
#### Session
|
||||
|
||||
- stdout/stderr 中解析 `session_id: <id>`(`parseSessionIdFromStdout`)
|
||||
- 会话文件:`~/.hermes/sessions/session_<id>.json`
|
||||
- `loadHermesSession` → `storeHermesSessionDetail`:每 assistant/tool 消息写成 CAS turn 节点,汇总为 `detail`;**output 文本** = 最后一条非空 `assistant` 的 `content`
|
||||
|
||||
#### 与 createAgent 的衔接
|
||||
|
||||
```157:164:packages/workflow-agent-hermes/src/hermes.ts
|
||||
export function createHermesAgent(): () => Promise<void> {
|
||||
return createAgent({ name: "hermes", run: runHermes, continue: continueHermes });
|
||||
}
|
||||
```
|
||||
|
||||
`uwf-hermes` 入口:`createHermesAgent()` 即 main。
|
||||
|
||||
Claude Code 包(`workflow-agent-claude-code`)结构相同:`buildClaudeCodePrompt` 同构,`claude -p` + `--resume` + JSON stdout 解析。
|
||||
|
||||
---
|
||||
|
||||
### Q7: Toolkit 需求分析
|
||||
|
||||
要实现一个自给自足的 agent,最少需要哪些 tool?
|
||||
|
||||
**调研要点:**
|
||||
- 现有 workflow example(solve-issue.yaml)里 role 都做什么任务
|
||||
- hermes agent 在 workflow 场景下常用哪些 tool
|
||||
- 哪些 tool 是 agent loop 必须的(如 file read/write、shell exec、web fetch)
|
||||
|
||||
**答案:**
|
||||
|
||||
#### solve-issue.yaml 角色能力
|
||||
|
||||
| Role | capabilities | 隐含需求 |
|
||||
|------|----------------|----------|
|
||||
| planner | issue-analysis, planning | 读上下文/仓库、总结,通常不需写代码 |
|
||||
| developer | file-edit, shell, testing | **读文件、写文件、执行命令** |
|
||||
| reviewer | code-review, static-analysis | 读 diff/文件、静态分析(可读+可选 shell) |
|
||||
|
||||
#### Hermes 侧
|
||||
|
||||
Hermes 自带完整 agent runtime(`--yolo`、max-turns),tool 集由 Hermes 项目定义,workflow 不配置。从 session JSON 可见 `tool_calls` 被记入 detail,常见包括文件与 shell 类工具。
|
||||
|
||||
#### Builtin 最小 toolkit 建议
|
||||
|
||||
| 优先级 | Tool | 用途 |
|
||||
|--------|------|------|
|
||||
| P0 | `read_file` | 读仓库/配置/issue 上下文 |
|
||||
| P0 | `write_file` / `edit_file` | developer 改代码 |
|
||||
| P0 | `run_command` | 测试、构建、git(需 cwd + timeout + 输出截断) |
|
||||
| P1 | `list_dir` / `glob` | 导航代码库 |
|
||||
| P1 | `grep` | 搜索符号/引用 |
|
||||
| P2 | `fetch_url` | 查文档(planner 偶尔需要) |
|
||||
|
||||
**不需要**在 builtin 里实现 moderator / workflow 路由工具——仍由 `uwf thread step` + status-based moderator 负责。
|
||||
|
||||
#### Agent loop 必须能力
|
||||
|
||||
1. 多轮 LLM 调用 + **OpenAI-style tool_calls** 解析与执行
|
||||
2. 将 tool 结果 append 回 messages
|
||||
3. 终止条件:模型不再请求 tool,或达到 `maxTurns`
|
||||
4. 最终响应须含合法 YAML frontmatter(满足 Q4),供 `createAgent` fast-path
|
||||
|
||||
---
|
||||
|
||||
## 方案草案
|
||||
|
||||
(调研完成后基于以上答案撰写)
|
||||
|
||||
### 架构设计
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph cli ["cli-workflow"]
|
||||
Step["uwf thread step"]
|
||||
Spawn["spawnAgent(uwf-builtin, threadId, role)"]
|
||||
Step --> Spawn
|
||||
end
|
||||
|
||||
subgraph builtin_pkg ["@uncaged/workflow-agent-builtin"]
|
||||
Main["createBuiltinAgent() = createAgent({...})"]
|
||||
Prompt["buildBuiltinPrompt(ctx)"]
|
||||
Loop["runBuiltinLoop(provider, messages, tools)"]
|
||||
Tools["Toolkit: read/write/exec/..."]
|
||||
Detail["storeBuiltinDetail(turns)"]
|
||||
Main --> Prompt
|
||||
Main --> Loop
|
||||
Loop --> Tools
|
||||
Loop --> Detail
|
||||
end
|
||||
|
||||
subgraph kit ["workflow-util-agent"]
|
||||
Ctx["buildContextWithMeta"]
|
||||
FM["tryFrontmatterFastPath"]
|
||||
Persist["persistStep"]
|
||||
Ctx --> Main
|
||||
Main --> FM
|
||||
FM --> Persist
|
||||
end
|
||||
|
||||
subgraph cas ["CAS / config"]
|
||||
Config["config.yaml models/providers"]
|
||||
CAS["cas/ + threads.yaml"]
|
||||
end
|
||||
|
||||
Spawn --> Main
|
||||
Config --> Loop
|
||||
CAS --> Ctx
|
||||
Persist --> CAS
|
||||
Spawn -->|"stdout: step hash"| Step
|
||||
```
|
||||
|
||||
**新包**:`packages/workflow-agent-builtin`,bin `uwf-builtin`,仅依赖 `workflow-util-agent`、`workflow-protocol`、`workflow-util`(可选 `@uncaged/json-cas` 写 detail schema)。
|
||||
|
||||
**分层**:
|
||||
|
||||
| 层 | 职责 |
|
||||
|----|------|
|
||||
| `createAgent`(kit) | argv、context、frontmatter extract、StepNode、stdout 协议 — **不变** |
|
||||
| `builtin/agent.ts` | `run` / `continue` 实现 |
|
||||
| `builtin/llm.ts` | OpenAI 兼容 chat + tools(可后续抽到 kit) |
|
||||
| `builtin/tools/*.ts` | 各 tool 的 JSON Schema + handler |
|
||||
| `builtin/prompt.ts` | 复用 Hermes 的 prompt 拼接逻辑(或抽到 kit 的 `buildAgentPrompt`) |
|
||||
| `builtin/detail.ts` | 类似 Hermes:每轮 assistant/tool 写入 CAS detail |
|
||||
|
||||
**配置集成**:
|
||||
|
||||
```yaml
|
||||
agents:
|
||||
builtin:
|
||||
command: "uwf-builtin"
|
||||
args: []
|
||||
defaultAgent: "builtin" # 或 agentOverrides 按 role 指定
|
||||
```
|
||||
|
||||
模型:首版 `resolveModel(config, config.defaultModel)`;后续可增加 `modelOverrides.agent` 或 per-role 映射。
|
||||
|
||||
---
|
||||
|
||||
### Agent Run Loop
|
||||
|
||||
伪代码(单次 `run(ctx)`):
|
||||
|
||||
```
|
||||
1. provider ← resolveModel(loadWorkflowConfig(), defaultModel)
|
||||
2. system ← buildBuiltinPrompt(ctx) // outputFormatInstruction + buildRolePrompt + Task + History
|
||||
3. messages ← [{ role: "system", content: system }]
|
||||
4. sessionId ← newULID() // 内存或临时目录,供 continue 使用
|
||||
5. turns ← []
|
||||
|
||||
6. for turn in 1..MAX_TURNS:
|
||||
response ← chatCompletionWithTools(provider, messages, TOOL_DEFINITIONS)
|
||||
record assistant message + tool_calls in turns
|
||||
|
||||
if response has no tool_calls:
|
||||
finalText ← response.content
|
||||
break
|
||||
|
||||
for each tool_call:
|
||||
result ← executeTool(tool_call, { cwd: process.cwd() })
|
||||
messages.push tool result
|
||||
record in turns
|
||||
|
||||
7. if no finalText with valid frontmatter after loop:
|
||||
optionally one-shot "finalize" message without tools
|
||||
|
||||
8. detailHash ← storeBuiltinDetail(store, sessionId, turns, metadata)
|
||||
9. return { output: finalText, detailHash, sessionId }
|
||||
```
|
||||
|
||||
**`continue(sessionId, message, store)`**:
|
||||
|
||||
- 从内存/磁盘恢复 `messages` + `turns`
|
||||
- `messages.push({ role: "user", content: message })`(correction 或续聊)
|
||||
- 从步骤 6 继续,步数上限可单独设小一点(如 3)
|
||||
- 返回新的 `AgentRunResult`
|
||||
|
||||
**与 frontmatter 的配合**:
|
||||
|
||||
- system prompt 已含 `outputFormatInstruction`;最后一轮可强制 user:`Now output your final answer with YAML frontmatter only if you have not yet.`
|
||||
- 仍依赖 `createAgent` 的 fast-path + 最多 2 次 continue
|
||||
|
||||
**安全**:
|
||||
|
||||
- `run_command`:白名单或需 `UWF_BUILTIN_ALLOW_SHELL=1`,默认工作区限定在 `process.cwd()` 或 `start` 中将来扩展的 `workspace` 字段
|
||||
- 路径:禁止 `..` 逃逸出 workspace root
|
||||
|
||||
---
|
||||
|
||||
### Toolkit 设计
|
||||
|
||||
统一注册表:
|
||||
|
||||
```typescript
|
||||
type BuiltinTool = {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: JSONSchema; // object type
|
||||
execute: (args: unknown, ctx: ToolContext) => Promise<string>;
|
||||
};
|
||||
|
||||
type ToolContext = {
|
||||
cwd: string;
|
||||
storageRoot: string;
|
||||
};
|
||||
```
|
||||
|
||||
| Tool name | OpenAI function | 行为摘要 |
|
||||
|-----------|-----------------|----------|
|
||||
| `read_file` | `read_file` | `{ path }` → UTF-8 文本,大小上限 |
|
||||
| `write_file` | `write_file` | `{ path, content }` → 写盘,返回确认 |
|
||||
| `edit_file` | 可选 | search/replace 块,减少 token |
|
||||
| `run_command` | `run_command` | `{ command, cwd? }` → stdout/stderr 截断 |
|
||||
| `list_dir` | `list_dir` | `{ path }` → 条目列表 |
|
||||
| `grep` | `grep` | `{ pattern, path? }` → 匹配行 |
|
||||
|
||||
**LLM 请求形状**(扩展 extract 客户端):
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "...",
|
||||
"messages": [...],
|
||||
"tools": [{ "type": "function", "function": { "name", "description", "parameters" } }],
|
||||
"tool_choice": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
解析 `choices[0].message.tool_calls`,执行后以 `{ role: "tool", tool_call_id, content }` 回传。
|
||||
|
||||
**不提供** streaming 首版;detail CAS 记录每轮 tool 名/参数/结果摘要供 `uwf thread step-details` 调试。
|
||||
|
||||
---
|
||||
|
||||
### 与现有架构的集成
|
||||
|
||||
| 集成点 | 方式 |
|
||||
|--------|------|
|
||||
| CLI 协议 | 实现标准 agent CLI:`uwf-builtin <thread-id> <role>`,stdout 一行 step hash,exit 0/1 |
|
||||
| 工厂 | `export function createBuiltinAgent()` → `createAgent({ name: "builtin", run, continue })` |
|
||||
| Context / Prompt | 复用 `buildContextWithMeta`、`buildRolePrompt`、`buildOutputFormatInstruction`;prompt 布局对齐 `buildHermesPrompt` |
|
||||
| 结构化输出 | 优先 YAML frontmatter fast-path;可选后续在 `createAgent` 增加 `extract()` fallback 开关 |
|
||||
| 配置 | `config.yaml` 增加 `agents.builtin`;`uwf setup` 可选默认 agent |
|
||||
| 存储 | `resolveStorageRoot()` + `loadWorkflowConfig` + `getEnvPath`;与 Hermes 相同,**不**改 `threads.yaml` 写入方 |
|
||||
| 测试 | 单元测试:tool handlers、prompt 组装、mock LLM tool loop;集成测试:临时 storage root + fake provider |
|
||||
| 发布 | 新包 `@uncaged/workflow-agent-builtin`,bin `uwf-builtin`,加入 `scripts/publish-all.mjs` |
|
||||
|
||||
**明确不做**:
|
||||
|
||||
- 不替代 moderator / 不在 agent 内调用 `uwf thread step`
|
||||
- 不依赖 Hermes/OpenClaw/Claude Code 二进制
|
||||
- 首版不实现 streaming、不实现 MCP
|
||||
|
||||
**建议实现顺序**:
|
||||
|
||||
1. `llm.ts`:tool calling HTTP 客户端 + 单测
|
||||
2. P0 tools + `runBuiltinLoop`
|
||||
3. `createBuiltinAgent` + detail CAS
|
||||
4. `config` / docs / `examples` 可选 `agentOverrides` 演示
|
||||
5. (可选)`createAgent` 接入 `extract()` fallback
|
||||
@@ -0,0 +1,73 @@
|
||||
# Issue #418: ACP session/resume 返回空文本
|
||||
|
||||
## 调研日期: 2026-05-23
|
||||
|
||||
## 根因
|
||||
|
||||
`session/resume` 在 restore 路径下 `_make_agent()` 失败,异常被静默吞掉。
|
||||
|
||||
### 完整调用链
|
||||
|
||||
```
|
||||
resume_session(sid)
|
||||
→ update_cwd(sid)
|
||||
→ get_session(sid) → _restore(sid)
|
||||
→ _make_agent()
|
||||
→ resolve_runtime_provider("custom") 失败(line 548-561)
|
||||
→ AIAgent() 抛出 "No LLM provider configured"(line 564)
|
||||
→ except Exception 静默吞掉(line 482-484)→ return None
|
||||
→ return None
|
||||
→ state is None → fallback: create_session()(新 sid,无历史)
|
||||
```
|
||||
|
||||
### 关键代码位置(acp_adapter/session.py)
|
||||
|
||||
- `_restore()` line 426-498: 从 DB 恢复 session,但 except 太宽泛
|
||||
- `_make_agent()` line 520-568: provider 解析在 restore 路径下不完整
|
||||
- Line 548-561: `resolve_runtime_provider("custom")` 失败后,`base_url` 虽然从 DB 取到了但没传给 AIAgent
|
||||
|
||||
### 实测行为
|
||||
|
||||
1. Phase 1: `session/new` + `prompt` → 正常,有 `agent_message_chunk`
|
||||
2. Phase 2: `session/resume` + `prompt`
|
||||
- resume 返回成功,但 `available_commands_update` 里 sessionId 是新的(create_session fallback)
|
||||
- 用原始 sid 发 prompt → `stopReason: "refusal"`(session 不在内存中)
|
||||
- 用新 sid 发 prompt → 能跑但无历史(agent 回答"不知道 secret code")
|
||||
|
||||
### 验证脚本
|
||||
|
||||
```python
|
||||
# 直接调用 _restore 验证
|
||||
cd ~/.hermes/hermes-agent
|
||||
python3 -c "
|
||||
import sys; sys.path.insert(0, '.')
|
||||
from acp_adapter.session import SessionManager
|
||||
sm = SessionManager()
|
||||
result = sm._restore('SESSION_ID_HERE')
|
||||
print(result) # None — _make_agent 抛异常被吞掉
|
||||
"
|
||||
```
|
||||
|
||||
### 两个 bug
|
||||
|
||||
1. **`_make_agent` provider fallback 不完整**: restore 时 DB 里有 `base_url` 和 `api_mode`,但 `resolve_runtime_provider` 失败后这些值没被正确传递给 AIAgent
|
||||
2. **`_restore` 的 except 太宽泛**: 静默吞掉所有异常,连 warning 都只在 debug 级别,导致 resume 失败完全无感知
|
||||
|
||||
### Hermes 版本
|
||||
|
||||
- v0.10.0 (2026.4.16) — 初始测试
|
||||
- v0.14.0 (2026.5.16) — 更新后重新测试,bug 仍在
|
||||
- 代码路径: ~/.hermes/hermes-agent/acp_adapter/session.py
|
||||
|
||||
### v0.14.0 测试结果 (2026-05-23)
|
||||
|
||||
- `_restore` 仍因 `custom` provider 解析失败返回 None
|
||||
- 日志更清晰了:`WARNING: Failed to recreate agent for ACP session ...`
|
||||
- resume fallback 创建新 session(新 sid),但 agent 居然能回答之前的问题(可能通过 memory/session search)
|
||||
- 核心问题不变:sessionId 变了,client 用旧 sid 发 prompt → refusal
|
||||
|
||||
### 上游 Issue
|
||||
|
||||
- https://github.com/NousResearch/hermes-agent/issues/13489 — 已评论根因分析
|
||||
- https://github.com/NousResearch/hermes-agent/issues/8083 — resume 静默创建新 session
|
||||
- https://github.com/NousResearch/hermes-agent/issues/18452 — _make_agent fallback 不完整
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
description: Ban dynamic import() in production code — use static imports instead
|
||||
globs: packages/*/src/**/*.ts
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# No Dynamic Import in Production Code
|
||||
|
||||
## Rule
|
||||
|
||||
Do NOT use `await import()` or dynamic `import()` expressions in production source code.
|
||||
Always use static top-level `import` statements.
|
||||
|
||||
## Exception (must include a comment explaining why)
|
||||
|
||||
1. **Bundle loader** — loads user-authored workflow bundles whose paths are only known at runtime
|
||||
|
||||
When suppressing, add a comment directly above:
|
||||
|
||||
```ts
|
||||
// Dynamic import required: user bundle path resolved at runtime
|
||||
const mod = await import(bundlePath);
|
||||
```
|
||||
|
||||
## Test Files
|
||||
|
||||
Test files (`__tests__/**`) are exempt.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Workflow-as-Agent Implementation Plan
|
||||
|
||||
> ⚠️ This plan references the pre-split package structure. File paths have changed.
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Enable workflows to invoke other workflows as agents, backed by global CAS and refs tracking.
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
# RFC: CAS-Based Thread Storage
|
||||
|
||||
> Status: Draft
|
||||
> Author: 小橘 🍊(NEKO Team)
|
||||
> Date: 2026-05-09
|
||||
|
||||
## Summary
|
||||
|
||||
Replace `.data.jsonl` with a fully CAS-based thread state chain. Threads become linked lists of immutable CAS nodes, indexed by a per-bundle `threads.json`.
|
||||
|
||||
## Motivation
|
||||
|
||||
`.data.jsonl` is a flat append-only file with three different row formats (start, role step, end). This makes forking expensive (copy file), deduplication impossible (forked threads repeat shared history), and GC complex (must parse every row to find CAS refs).
|
||||
|
||||
Threads are inherently immutable append-only sequences — a natural fit for CAS hash chains, similar to git's commit DAG.
|
||||
|
||||
## Design
|
||||
|
||||
### Node Types
|
||||
|
||||
Two CAS node types, using the existing `{ type, payload, refs }` CAS blob structure:
|
||||
|
||||
#### StartNode
|
||||
|
||||
Contains workflow-level parameters. **No threadId** (because the same StartNode can be shared across forks). Prompt is stored as a CAS blob and referenced via `refs[0]`.
|
||||
|
||||
```
|
||||
CAS blob:
|
||||
{
|
||||
type: "start",
|
||||
payload: {
|
||||
name: "solve-issue",
|
||||
hash: "BUNDLE_HASH",
|
||||
maxRounds: 10,
|
||||
depth: 0
|
||||
},
|
||||
refs: [
|
||||
<prompt_hash> // refs[0]: initial task prompt (CAS blob)
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- No `role`, `content`, `meta` — this is not a step, it's workflow metadata
|
||||
- Prompt is **not** inline — it lives in CAS and is referenced by hash
|
||||
|
||||
#### StateNode
|
||||
|
||||
One per role step (including `__end__`).
|
||||
|
||||
```
|
||||
CAS blob:
|
||||
{
|
||||
type: "state",
|
||||
payload: {
|
||||
role: "coder",
|
||||
meta: { ... },
|
||||
start: "<start_hash>",
|
||||
content: "<content_merkle_hash>",
|
||||
ancestors: ["<parent_hash>", "<grandparent_hash>", ...],
|
||||
compact: null,
|
||||
timestamp: 1234567890
|
||||
},
|
||||
refs: [<start_hash>, <content_hash>, <parent_hash>, ...]
|
||||
}
|
||||
```
|
||||
|
||||
**Payload is the source of truth.** Application code reads named fields from payload. `refs[]` is a **GC index** — automatically derived from payload by collecting all CAS hashes. GC only scans `refs[]` without understanding payload structure.
|
||||
|
||||
**Payload fields:**
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|-------|------|---------|
|
||||
| `role` | `string` | Role name, or `"__end__"` for completion |
|
||||
| `meta` | `object` | Structured metadata extracted from agent output |
|
||||
| `start` | `string` | StartNode hash |
|
||||
| `content` | `string` | Content Merkle node hash (carries role artifact refs) |
|
||||
| `ancestors` | `string[]` | `[parent, grandparent, ...]` — up to 11 entries (1 parent + 10 skip-list). Empty for first step after start. `ancestors[0]` is the direct parent. |
|
||||
| `compact` | `string \| null` | CAS hash of a compacted summary of all nodes before this one. When present, LLM context assembly can use this instead of walking the full chain. |
|
||||
| `timestamp` | `number` | Unix timestamp in ms |
|
||||
|
||||
### Content Merkle Node
|
||||
|
||||
The content at `refs[2]` of each StateNode is itself a CAS Merkle node. This is where **role artifact references** live:
|
||||
|
||||
```
|
||||
CAS blob:
|
||||
{
|
||||
type: "content",
|
||||
payload: "<role output text>",
|
||||
refs: [
|
||||
<artifact_hash_1>, // e.g. a commit, a file, a sub-result
|
||||
<artifact_hash_2>,
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The Extractor is responsible for producing both `meta` and `refs` from raw agent output:
|
||||
|
||||
```
|
||||
Agent raw output
|
||||
↓
|
||||
Extractor → { meta, contentPayload, refs[] }
|
||||
↓
|
||||
CAS put content Merkle: { type: "content", payload: contentPayload, refs }
|
||||
↓ contentHash
|
||||
StateNode: { ..., refs: [start, parent, contentHash, ...ancestors] }
|
||||
```
|
||||
|
||||
This keeps StateNode refs fixed and simple. All role-specific artifact references are encapsulated in the content Merkle node. GC follows: `thread head → StateNode.refs → content Merkle.refs → artifacts`, full chain recursive.
|
||||
|
||||
### End Node
|
||||
|
||||
An end is just a StateNode with `role: "__end__"`:
|
||||
|
||||
```
|
||||
{
|
||||
type: "state",
|
||||
payload: {
|
||||
role: "__end__",
|
||||
meta: { returnCode: 0, summary: "completed successfully" },
|
||||
start: "<start_hash>",
|
||||
content: "<content_hash>",
|
||||
ancestors: ["<parent_hash>", ...],
|
||||
compact: null,
|
||||
timestamp: 1234567891
|
||||
},
|
||||
refs: [<start_hash>, <content_hash>, <parent_hash>, ...]
|
||||
}
|
||||
```
|
||||
|
||||
### Thread Index: `threads.json`
|
||||
|
||||
Per-bundle directory, one `threads.json` file. **Only active (in-progress) threads** live here:
|
||||
|
||||
```
|
||||
~/.uncaged/workflow/bundles/<hash>/threads.json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"01JTHREAD1AAAAAAAAAAAAAAA": {
|
||||
"head": "<latest_state_node_hash>",
|
||||
"start": "<start_node_hash>",
|
||||
"updatedAt": 1234567891
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When a thread completes (`__end__`), it is **removed from `threads.json`** and appended to a date-partitioned history file:
|
||||
|
||||
```
|
||||
~/.uncaged/workflow/bundles/<hash>/history/{YYYY-MM-DD}.jsonl
|
||||
```
|
||||
|
||||
Each line:
|
||||
|
||||
```json
|
||||
{"threadId":"01JTHREAD1AAAAAAAAAAAAAAA","head":"<end_node_hash>","start":"<start_node_hash>","completedAt":1234567891}
|
||||
```
|
||||
|
||||
Benefits:
|
||||
- `threads.json` stays small — only in-flight threads
|
||||
- Dashboard watches `threads.json` for real-time updates; completed threads don't trigger watches
|
||||
- History is queryable by date but not actively monitored
|
||||
- GC roots = all heads from `threads.json` + all heads from `history/*.jsonl`
|
||||
|
||||
### Ancestor Skip-List
|
||||
|
||||
Each StateNode carries up to 11 entries in `payload.ancestors` (1 parent + 10 skip-list, newest first):
|
||||
|
||||
```
|
||||
Node 15: ancestors = [node14, node13, node12, node11, node10, node9, node8, node7, node6, node5, node4]
|
||||
^parent ^--- skip-list (10 most recent) ---^
|
||||
```
|
||||
|
||||
This enables:
|
||||
- **Paginated fetch**: jump to any recent ancestor without walking the full chain
|
||||
- **Partial replay**: fetch last N steps without loading the entire history
|
||||
- The list is capped at 10 to keep node size bounded
|
||||
|
||||
### Fork
|
||||
|
||||
Forking a thread at step N:
|
||||
|
||||
1. Create new threadId
|
||||
2. Create a new StateNode whose `parent` (refs[1]) points to the fork point's StateNode
|
||||
3. Register the new threadId in `threads.json` with its own head
|
||||
4. **Zero data duplication** — the forked thread shares all ancestor nodes via CAS
|
||||
|
||||
### Compact
|
||||
|
||||
When a StateNode has `payload.compact` set:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "state",
|
||||
"payload": {
|
||||
"role": "coder",
|
||||
"meta": { ... },
|
||||
"compact": "<cas_hash_of_summary>",
|
||||
"timestamp": 1234
|
||||
},
|
||||
"refs": [...]
|
||||
}
|
||||
```
|
||||
|
||||
This means: "everything before this node has been summarized into the blob at `compact`". When building LLM context:
|
||||
|
||||
1. Walk back from head
|
||||
2. If a node has `compact`, stop walking — use the compact summary + all nodes after it
|
||||
3. If no compact found, use full chain
|
||||
|
||||
This enables long-running threads without unbounded context growth.
|
||||
|
||||
### GC
|
||||
|
||||
Simple mark-and-sweep:
|
||||
|
||||
1. **Roots**: all `head` and `start` hashes from `threads.json` + all `history/*.jsonl` files
|
||||
2. **Mark**: from each root, recursively mark all reachable hashes via `refs[]` (including content Merkle → artifact refs)
|
||||
3. **Sweep**: delete unmarked CAS blobs
|
||||
|
||||
No per-row format parsing needed. GC only needs to understand `refs[]`.
|
||||
|
||||
### refs[] Derivation
|
||||
|
||||
`refs[]` is auto-derived from payload at write time via a `collectRefs(payload)` function that extracts all CAS hash strings from named fields (`start`, `content`, `ancestors`, `compact`). Application code never reads `refs[]` — it reads named payload fields. This makes `refs[]` a pure GC optimization with zero semantic coupling.
|
||||
|
||||
### Extract Phase
|
||||
|
||||
The Extractor is expanded from the current design. Currently it only extracts `meta` from agent output. In the new design it extracts:
|
||||
|
||||
| Output | Purpose |
|
||||
|--------|---------|
|
||||
| `meta` | Structured metadata (same as before) |
|
||||
| `contentPayload` | The text payload for the content Merkle node |
|
||||
| `refs[]` | CAS hashes of artifacts produced by this role step |
|
||||
|
||||
The `refs[]` become the content Merkle node's refs, enabling GC to trace all role-produced artifacts.
|
||||
|
||||
## What Stays Unchanged
|
||||
|
||||
- `.info.jsonl` — debug logging stays as-is (high-frequency append, not suitable for CAS)
|
||||
- CAS blob storage format (`~/.uncaged/workflow/cas/`)
|
||||
- Bundle registry (`workflow.yaml`)
|
||||
|
||||
## Migration
|
||||
|
||||
Breaking change. Old `.data.jsonl` files become incompatible. No backward compat fallback (per project convention).
|
||||
|
||||
## Changes by Package
|
||||
|
||||
| Package | Changes |
|
||||
|---------|---------|
|
||||
| `workflow-protocol` | Replace `StartStep`, `RoleStep` types with `StartNode`, `StateNode`. Add `ContentMerkleNode` type. Expand `ExtractResult` to include `refs[]`. |
|
||||
| `workflow-cas` | Add `findReachableHashes(roots)` for GC mark phase |
|
||||
| `workflow-execute` | Rewrite engine to write CAS nodes + update `threads.json` instead of appending JSONL. Move completed threads to `history/`. Simplify `gc.ts`. Simplify `fork-thread.ts`. Expand extract phase to produce refs. |
|
||||
| `workflow-runtime` | `ThreadContext` built by walking chain from head. `start.prompt` resolved from CAS via StartNode.refs[0]. |
|
||||
| `cli-workflow` | `thread list/show/rm` read from `threads.json` + `history/`. SSE watches `threads.json`. |
|
||||
| `workflow-dashboard` | Watch `threads.json` instead of `.data.jsonl` |
|
||||
| Templates & Agents | Update extract definitions to produce `refs[]`. Update `ctx.start.content` → CAS resolved. |
|
||||
@@ -0,0 +1,197 @@
|
||||
# RFC: Merkle Call Stack — Cross-Thread DAG Linking
|
||||
|
||||
**Author:** 小橘 🍊(NEKO Team)
|
||||
**Date:** 2026-05-11
|
||||
**Status:** Draft
|
||||
|
||||
## Problem
|
||||
|
||||
当 `workflowAsAgent` 在父 workflow 中 spawn 子 workflow 时,父子 thread 之间没有任何 Merkle 链接:
|
||||
|
||||
1. **子 thread 不知道自己从哪来** — start node 只有 prompt hash,无法追溯父 thread 的上下文(preparer 分析出的 repoPath、conventions 等)
|
||||
2. **父 thread 不知道子 thread 在哪** — developer role 的 state node 里只有 agent 返回的文本,child thread root hash 埋在字符串里,不是结构化 ref
|
||||
3. **上下文传递靠序列化到 prompt** — 父 workflow 前置 role 的产出只能通过拼字符串传给子 workflow,丢失了 Merkle DAG 的可遍历性
|
||||
|
||||
## Proposal
|
||||
|
||||
在 CAS 节点中建立父子 thread 之间的 **双向 Merkle 链接**,形成调用栈结构。
|
||||
|
||||
### 新增字段
|
||||
|
||||
#### StartNodePayload(子 → 父)
|
||||
|
||||
```typescript
|
||||
type StartNodePayload = {
|
||||
name: string;
|
||||
hash: string;
|
||||
depth: number;
|
||||
parentState: string | null; // NEW: 父 thread 调用时的 head state hash
|
||||
};
|
||||
```
|
||||
|
||||
`parentState` 指向子 workflow 被 spawn 时,父 thread 的最后一个 state node hash。这是"调用发生时的调用栈帧"。
|
||||
|
||||
#### StateNodePayload(父 → 子)
|
||||
|
||||
```typescript
|
||||
type StateNodePayload = {
|
||||
role: string;
|
||||
meta: Record<string, unknown>;
|
||||
start: string;
|
||||
content: string;
|
||||
ancestors: string[];
|
||||
compact: string | null;
|
||||
timestamp: number;
|
||||
childThread: string | null; // NEW: 子 thread 最终 state hash(执行结果)
|
||||
};
|
||||
```
|
||||
|
||||
`childThread` 指向子 thread 完成后的**最终 state hash**(不是 start)——语义上是"函数返回值",从这里沿 ancestors 可回溯子 thread 的完整执行历史。
|
||||
|
||||
### refs 同步
|
||||
|
||||
新增的 hash 也必须放进 `refs[]`:
|
||||
|
||||
- `StartNode.refs`: `[promptHash, parentState]`(parentState 非 null 时)
|
||||
- `StateNode.refs`: `[...existingRefs, childThread]`(childThread 非 null 时)
|
||||
|
||||
原因:GC 的 `findReachableHashes` 只走 `refs`,不解析 payload 字段。字段提供语义,refs 保证可达性。
|
||||
|
||||
### 具体 DAG 结构
|
||||
|
||||
以 `solve-issue`(fix #191)为例,developer role 委托给 `develop` 子 workflow:
|
||||
|
||||
```
|
||||
父 thread: solve-issue
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
content("fix #191")
|
||||
hash: ABCD1234
|
||||
|
||||
start(solve-issue)
|
||||
hash: START001
|
||||
payload: { name: "solve-issue", hash: BUNDLE_SI, depth: 0, parentState: null }
|
||||
refs: [ABCD1234]
|
||||
|
||||
state(preparer)
|
||||
hash: STATE_P1
|
||||
payload: { role: "preparer", meta: { repoPath: "...", ... }, childThread: null, ... }
|
||||
refs: [PREP_CONTENT]
|
||||
|
||||
state(developer) ──────── 父→子 ────────
|
||||
hash: STATE_D1 │
|
||||
payload: { role: "developer", meta: { ... }, childThread: ★CSTATE_END, ... }
|
||||
refs: [DEV_CONTENT, ★CSTATE_END] │
|
||||
│
|
||||
state(submitter) │
|
||||
hash: STATE_S1 │
|
||||
payload: { role: "submitter", ..., childThread: null } │
|
||||
│
|
||||
│
|
||||
子 thread: develop │
|
||||
═══════════════════════════════════════════════════════════ │
|
||||
│
|
||||
content("fix #191") (CAS 去重,可能同 ABCD1234) │
|
||||
hash: CPROMPT1 │
|
||||
──────── 子→父 ──────── │
|
||||
start(develop) │ │
|
||||
hash: CHILD_START │ │
|
||||
payload: { name: "develop", hash: BUNDLE_DEV, depth: 1, │
|
||||
parentState: ★STATE_P1 } │ │
|
||||
refs: [CPROMPT1, ★STATE_P1] │ │
|
||||
│ │
|
||||
state(planner) │ │
|
||||
hash: CSTATE_1 │ │
|
||||
... │ │
|
||||
│ │
|
||||
state(coder) │ │
|
||||
hash: CSTATE_2 │ │
|
||||
... │ │
|
||||
│ │
|
||||
state(reviewer) → state(tester) → state(committer) │
|
||||
│ │
|
||||
hash: ★CSTATE_END ◄─────────────────┼─────────────────────────┘
|
||||
```
|
||||
|
||||
### 遍历路径
|
||||
|
||||
**子 thread agent 获取父上下文(上行):**
|
||||
```
|
||||
当前 step → start(CHILD_START)
|
||||
→ refs[1] = STATE_P1(父 preparer 的 state)
|
||||
→ payload.meta.repoPath = "/home/.../workflow"
|
||||
→ refs → PREP_CONTENT(完整 preparer 输出)
|
||||
→ payload.start = START001(父的 start node)
|
||||
→ refs[0] = ABCD1234(原始 prompt)
|
||||
```
|
||||
|
||||
**从父 thread 追踪子 thread 执行(下行):**
|
||||
```
|
||||
STATE_D1(父 developer state)
|
||||
→ payload.childThread = CSTATE_END
|
||||
→ 子 thread 最终 state
|
||||
→ 沿 ancestors 回溯:committer → tester → reviewer → coder → planner
|
||||
→ payload.start = CHILD_START(子 thread 入口)
|
||||
```
|
||||
|
||||
**完整调用栈还原:**
|
||||
```
|
||||
任意节点 → 沿 start 找到所属 thread 的 StartNode
|
||||
→ parentState 非 null?沿 parentState 进入父 thread
|
||||
→ 递归直到 parentState = null(顶层 workflow)
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Protocol + CAS 层
|
||||
|
||||
1. `workflow-protocol/src/cas-types.ts` — `StartNodePayload` 加 `parentState: string | null`,`StateNodePayload` 加 `childThread: string | null`
|
||||
2. `workflow-cas/src/nodes.ts` — `putStartNode` 接受可选 `parentStateHash`,放入 refs;`putStateNode` 接受可选 `childThreadHash`,放入 refs
|
||||
3. `workflow-cas/src/nodes.ts` — 解析逻辑兼容新字段(缺失时视为 null)
|
||||
|
||||
### Phase 2: Engine 层
|
||||
|
||||
4. `workflow-execute/src/engine/engine.ts` — `executeThread` 接受 `parentStateHash: string | null`,传给 `putStartNode`
|
||||
5. `workflow-execute/src/workflow-as-agent.ts` — spawn 子 thread 时传入父 thread 当前 head state hash 作为 `parentStateHash`;子 thread 完成后返回最终 state hash
|
||||
6. Engine 写 developer role 的 state node 时,把子 thread 最终 hash 写入 `childThread` 字段
|
||||
|
||||
### Phase 3: Agent 可观测性
|
||||
|
||||
7. Agent prompt 构建(`buildAgentPrompt`)— 当 start node 有 `parentState` 时,提示 agent 可通过 `cas get` 遍历父上下文
|
||||
8. CLI `thread show` — 显示 parentState / childThread 链接关系
|
||||
|
||||
### Phase 4: 验证
|
||||
|
||||
9. 已有测试适配新字段(向后兼容,旧节点 parentState/childThread 为 null)
|
||||
10. 新增集成测试:workflowAsAgent 场景下验证双向链接正确写入
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### 为什么 childThread 指向 end 而不是 start?
|
||||
|
||||
- 语义是"函数返回值"——父 role 执行完才产出 state,此时子 thread 已跑完
|
||||
- 从 end 沿 ancestors 可回溯到 start;反过来 start 写入时子 thread 还没跑完,无法知道 end
|
||||
|
||||
### 为什么 parentState 指向 state 而不是 start?
|
||||
|
||||
- 指向父 thread 调用点的**前一个 state**(即调用发生时的 head)
|
||||
- 这是子 workflow 能看到的父上下文的"切面"——所有已完成的前置 role 都可达
|
||||
- 如果是第一个 role 就 spawn 子 workflow(没有前置 state),parentState 指向父的 start node
|
||||
|
||||
### 为什么同时放字段和 refs?
|
||||
|
||||
- `refs[]` 服务于 GC(`findReachableHashes` 只遍历 refs)和通用 DAG 遍历
|
||||
- `payload.parentState` / `payload.childThread` 服务于语义读取(明确知道哪个 ref 是什么)
|
||||
- 不改 GC 逻辑,只加字段,GC 自然正确
|
||||
|
||||
### 向后兼容
|
||||
|
||||
- 新字段默认 `null`,旧节点解析时缺失字段视为 `null`
|
||||
- 不影响已有 thread 的遍历和 GC
|
||||
- `depth` 可通过沿 parentState 链上溯来交叉验证(数据自证)
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **多子 thread** — 如果一个 role 需要 spawn 多个子 workflow(目前不存在这个场景),`childThread` 应该改成 `childThreads: string[]` 还是保持单个?
|
||||
2. **Agent prompt 注入深度** — 子 workflow 的 agent 应该自动遍历多少层父上下文?全部还是限制深度?
|
||||
3. **CLI 展示** — `thread show` 要不要递归展示整个调用栈,还是只显示直接链接?
|
||||
@@ -0,0 +1,224 @@
|
||||
# Dashboard Workflow Graph Visualization
|
||||
|
||||
**Issue**: #198
|
||||
**Status**: In Progress
|
||||
**Author**: xingyue
|
||||
|
||||
## Overview
|
||||
|
||||
在 Dashboard 的 ThreadDetail 页面中嵌入一个交互式流程图,将 workflow 的 `ModeratorTable` 可视化为有向图。用户可以一眼看到角色流转结构和当前执行进度。
|
||||
|
||||
## 数据层(✅ 已完成 — PR #201)
|
||||
|
||||
### WorkflowGraph 类型
|
||||
|
||||
`WorkflowDefinition.moderator`(函数)已替换为 `WorkflowDefinition.table`(声明式 `ModeratorTable`),`buildDescriptor` 自动从 table 提取 graph:
|
||||
|
||||
```ts
|
||||
type WorkflowGraphEdge = {
|
||||
from: string; // source role 或 "__start__"
|
||||
to: string; // target role 或 "__end__"
|
||||
condition: string; // condition.name 或 "FALLBACK"
|
||||
conditionDescription: string | null;
|
||||
};
|
||||
|
||||
type WorkflowGraph = {
|
||||
edges: readonly WorkflowGraphEdge[];
|
||||
};
|
||||
|
||||
type WorkflowDescriptor = {
|
||||
description: string;
|
||||
roles: Record<string, WorkflowRoleDescriptor>;
|
||||
graph: WorkflowGraph; // 必填,新 bundle 自动生成
|
||||
};
|
||||
```
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
ModeratorTable (WorkflowDefinition.table)
|
||||
→ buildDescriptor() 自动提取 graph
|
||||
→ descriptor.yaml 持久化(hash.yaml)
|
||||
→ CLI serve /workflows/:name API 返回 descriptor
|
||||
→ Dashboard 前端拿到 graph
|
||||
```
|
||||
|
||||
### 剩余数据层工作
|
||||
|
||||
**serve API 需要返回 descriptor**:当前 `GET /workflows/:name` 只返回 registry entry(hash + timestamp),不含 descriptor。需要从 `bundles/{hash}.yaml` 读取 descriptor 并返回给前端。
|
||||
|
||||
方案:在 `routes-workflow.ts` 的 `GET /workflows/:name` 响应中附带 `descriptor` 字段。或者:thread-detail 发现 workflow name 后,请求 `GET /workflows/:name/descriptor` 拿到 graph。
|
||||
|
||||
## 前端渲染
|
||||
|
||||
### 库选型:React Flow + dagre
|
||||
|
||||
| 库 | 优势 | 劣势 |
|
||||
|---|---|---|
|
||||
| **React Flow** ✅ | React 原生、自定义节点/边、dagre 自动布局、~50KB gzip | 需要学 API |
|
||||
| Mermaid | 声明式简单 | 无交互、无法高亮当前步骤 |
|
||||
| D3 | 完全控制 | 太底层,手撸成本高 |
|
||||
| Cytoscape | 图论强 | React 集成差 |
|
||||
|
||||
**依赖新增**:
|
||||
|
||||
```json
|
||||
{
|
||||
"@xyflow/react": "^12",
|
||||
"@dagrejs/dagre": "^1"
|
||||
}
|
||||
```
|
||||
|
||||
### 图结构映射
|
||||
|
||||
```
|
||||
WorkflowGraph.edges → React Flow nodes + edges
|
||||
|
||||
节点(自动从 edges 推导):
|
||||
- __start__ → 圆形小节点(入口)
|
||||
- role → 圆角矩形,显示 role name + description
|
||||
- __end__ → 圆形小节点(终止)
|
||||
|
||||
边:
|
||||
- FALLBACK → 虚线(dashed),无 label
|
||||
- condition → 实线,label = condition
|
||||
hover tooltip = conditionDescription
|
||||
```
|
||||
|
||||
### 布局
|
||||
|
||||
使用 dagre 自动计算 TB(top-to-bottom)方向布局:
|
||||
|
||||
```ts
|
||||
import Dagre from "@dagrejs/dagre";
|
||||
|
||||
function layoutGraph(nodes, edges) {
|
||||
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
|
||||
|
||||
for (const node of nodes) {
|
||||
g.setNode(node.id, { width: 180, height: 60 });
|
||||
}
|
||||
for (const edge of edges) {
|
||||
g.setEdge(edge.source, edge.target);
|
||||
}
|
||||
|
||||
Dagre.layout(g);
|
||||
|
||||
return nodes.map((node) => {
|
||||
const pos = g.node(node.id);
|
||||
return { ...node, position: { x: pos.x - 90, y: pos.y - 30 } };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 运行时高亮
|
||||
|
||||
ThreadDetail 已有 `records: ThreadRecord[]`,其中 `RoleRecord.role` 就是当前/历史执行的 role。
|
||||
|
||||
高亮逻辑:
|
||||
|
||||
```ts
|
||||
function getNodeStates(records: ThreadRecord[]): Map<string, "completed" | "active"> {
|
||||
const states = new Map<string, "completed" | "active">();
|
||||
const roleRecords = records.filter((r) => r.type === "role");
|
||||
|
||||
for (let i = 0; i < roleRecords.length; i++) {
|
||||
const role = roleRecords[i].role;
|
||||
states.set(role, i === roleRecords.length - 1 ? "active" : "completed");
|
||||
}
|
||||
|
||||
// 如果有 workflow-result,最后一个 role 也是 completed
|
||||
if (records.some((r) => r.type === "workflow-result")) {
|
||||
for (const [k] of states) {
|
||||
states.set(k, "completed");
|
||||
}
|
||||
states.set("__end__", "completed");
|
||||
}
|
||||
|
||||
states.set("__start__", "completed");
|
||||
return states;
|
||||
}
|
||||
```
|
||||
|
||||
节点样式:
|
||||
|
||||
| 状态 | 样式 |
|
||||
|------|------|
|
||||
| default | `border: var(--color-border)`, 暗色背景 |
|
||||
| completed | `border: var(--color-success)`, 绿色边框 + ✓ 图标 |
|
||||
| active | `border: var(--color-accent)`, 蓝色边框 + 脉冲动画 |
|
||||
|
||||
边高亮:当 source 和 target 都至少 completed 时,边变绿。
|
||||
|
||||
## 组件结构
|
||||
|
||||
```
|
||||
workflow-dashboard/src/
|
||||
components/
|
||||
workflow-graph/
|
||||
types.ts — NodeState 等前端类型
|
||||
index.ts — export { WorkflowGraph }
|
||||
workflow-graph.tsx — 主组件,React Flow canvas
|
||||
role-node.tsx — 自定义 role 节点
|
||||
terminal-node.tsx — START/END 圆形节点
|
||||
condition-edge.tsx — 自定义边(虚线/实线 + label)
|
||||
use-layout.ts — dagre 布局 hook
|
||||
```
|
||||
|
||||
### 集成到 ThreadDetail
|
||||
|
||||
在 ThreadDetail 中,records 列表上方插入可折叠的图面板:
|
||||
|
||||
```tsx
|
||||
// thread-detail.tsx
|
||||
{graph && (
|
||||
<div className="mb-4 border rounded-lg overflow-hidden" style={{ height: 300 }}>
|
||||
<WorkflowGraph graph={graph} nodeStates={getNodeStates(records)} />
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
图高度固定 300px,React Flow 支持 pan + zoom,不影响下方 records 滚动。
|
||||
|
||||
## 实施计划
|
||||
|
||||
### ~~Phase 0: 数据层~~ ✅ Done (PR #201)
|
||||
|
||||
- [x] `WorkflowDefinition.moderator` → `table` (ModeratorTable)
|
||||
- [x] `WorkflowDescriptor` 新增 `graph: WorkflowGraph`
|
||||
- [x] `buildDescriptor` 自动提取 graph
|
||||
- [x] `validateWorkflowDescriptor` 校验 graph
|
||||
|
||||
### Phase 1: API + 静态图渲染
|
||||
|
||||
1. serve API:`GET /workflows/:name` 返回 descriptor(含 graph),或新增 `GET /workflows/:name/descriptor`
|
||||
2. Dashboard `api.ts` 新增 `getWorkflowDescriptor(agent, name)` 函数
|
||||
3. 安装 `@xyflow/react` + `@dagrejs/dagre`
|
||||
4. 实现 `workflow-graph/` 组件集
|
||||
5. ThreadDetail 中集成:从 thread-start record 拿 workflow name → 请求 descriptor → 渲染图
|
||||
|
||||
**产出**:打开 ThreadDetail 看到 workflow 流程图,无高亮。
|
||||
|
||||
### Phase 2: 运行时高亮
|
||||
|
||||
1. ThreadDetail 根据 records 计算 nodeStates
|
||||
2. 节点/边样式响应状态变化
|
||||
3. SSE live 模式下实时更新高亮
|
||||
|
||||
**产出**:正在运行的 thread 能看到当前执行到哪个 role。
|
||||
|
||||
### Phase 3: 交互增强
|
||||
|
||||
1. 点击节点滚动到对应 role 的 RecordCard
|
||||
2. 边 hover 显示 conditionDescription tooltip
|
||||
3. 节点 hover 显示 role description + schema summary
|
||||
|
||||
**产出**:图和记录列表联动。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **自循环边**:如 `coder → coder (FALLBACK)`,React Flow 支持自循环,dagre 需要特殊处理(self-edge 用 loop 路径)
|
||||
- **大图性能**:dagre 在 <50 节点时性能无忧,workflow 通常 <10 个 role
|
||||
- **暗色主题**:Dashboard 已使用 CSS variables,节点/边样式复用现有色板
|
||||
- **不提交 pnpm-lock.yaml**
|
||||
@@ -0,0 +1,191 @@
|
||||
# workflow-agent-react — ReAct Agent Package
|
||||
|
||||
**Status**: RFC v3
|
||||
**Author**: 小橘 🍊
|
||||
|
||||
## Problem
|
||||
|
||||
现有的 agent 包都依赖外部 CLI 进程:
|
||||
|
||||
| Package | 机制 | 能力 |
|
||||
|---------|------|------|
|
||||
| `workflow-agent-hermes` | spawn `hermes chat` | 完整工具链(文件、终端、浏览器…) |
|
||||
| `workflow-agent-cursor` | spawn `cursor-agent` | IDE 级别代码编辑 |
|
||||
| `workflow-agent-llm` | 单轮 chat completion | 纯文本,无工具 |
|
||||
|
||||
缺少一个 **内置 ReAct agent**:用 LLM + tool calling 循环执行任务,不依赖外部 CLI,工具集由调用方注入。
|
||||
|
||||
## 核心设计变更:AdapterFn 替代 AgentFn
|
||||
|
||||
### 现状的问题
|
||||
|
||||
当前 `AgentFn` 返回 `string`,engine 再用额外一轮 LLM 调用 extract meta:
|
||||
|
||||
```
|
||||
Agent(ctx) → string → Extract(string, schema) → meta // 浪费一轮 LLM
|
||||
```
|
||||
|
||||
### 新抽象:AdapterFn
|
||||
|
||||
```typescript
|
||||
type RoleFn<T> = (ctx: ThreadContext) => Promise<T>;
|
||||
|
||||
type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
|
||||
```
|
||||
|
||||
- **`prompt`** — role 的 system prompt,描述角色职责和输出要求
|
||||
- **`schema`** — role 的 meta schema,定义输出格式
|
||||
- **`ThreadContext`** — threadId, depth, bundleHash, start, steps
|
||||
|
||||
prompt 和 schema 是一对:prompt 说"你要输出什么",schema 定义"输出的格式"。它们属于 role definition,由 `createWorkflow` 在每个 role 执行时传给 adapter。
|
||||
|
||||
### AgentContext 不再需要
|
||||
|
||||
`AgentContext` 在 `ThreadContext` 上扩展了 `currentRole: { name, systemPrompt }`。prompt 现在直接传给 adapter,`AgentContext` 可以删除。
|
||||
|
||||
### createWorkflow 签名变更
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
type AgentBinding = {
|
||||
agent: AgentFn;
|
||||
overrides: Partial<Record<string, AgentFn>> | null;
|
||||
};
|
||||
|
||||
// After
|
||||
type AdapterBinding = {
|
||||
adapter: AdapterFn;
|
||||
overrides: Partial<Record<string, AdapterFn>> | null;
|
||||
};
|
||||
```
|
||||
|
||||
engine 对每个 role 的执行逻辑:
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const result = await agent({ ...threadCtx, currentRole: { name, systemPrompt } });
|
||||
const meta = await extract(result, role.metaSchema, provider); // 额外一轮 LLM
|
||||
|
||||
// After
|
||||
const roleFn = adapter(role.systemPrompt, role.metaSchema);
|
||||
const meta = await roleFn(threadCtx); // 直接拿到类型安全的 T
|
||||
```
|
||||
|
||||
## `createReactAdapter` — 复用 workflow-reactor
|
||||
|
||||
AdapterFn 的终止条件是"拿到符合 schema 的 T"——和 `workflow-reactor` 的 `ThreadReactorFn` 完全一致。因此 react adapter 是对 reactor 的**薄包装**,不需要自己实现 ReAct 循环。
|
||||
|
||||
```typescript
|
||||
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
|
||||
import type { ThreadContext, LlmProvider } from "@uncaged/workflow-protocol";
|
||||
import type { ToolDefinition } from "@uncaged/workflow-reactor";
|
||||
|
||||
type ReactToolHandler = (name: string, args: string) => Promise<string>;
|
||||
|
||||
type ReactAdapterConfig = {
|
||||
provider: LlmProvider;
|
||||
tools: readonly ToolDefinition[];
|
||||
toolHandler: ReactToolHandler;
|
||||
maxRounds: number;
|
||||
};
|
||||
|
||||
function createReactAdapter(config: ReactAdapterConfig): AdapterFn {
|
||||
return <T>(prompt: string, schema: z.ZodType<T>) => {
|
||||
const reactor = createThreadReactor<ThreadContext>({
|
||||
llm: createLlmFn(config.provider),
|
||||
staticTools: config.tools,
|
||||
structuredToolFromSchema: (s) => buildStructuredTool(s),
|
||||
systemPromptForStructuredTool: () => prompt,
|
||||
toolHandler: (call, ctx) =>
|
||||
config.toolHandler(call.function.name, call.function.arguments),
|
||||
maxRounds: config.maxRounds,
|
||||
});
|
||||
|
||||
return async (ctx: ThreadContext): Promise<T> => {
|
||||
const input = buildThreadInput(ctx);
|
||||
const result = await reactor({ thread: ctx, input, schema });
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
return result.value;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
整个包就是:**一个工厂函数 + 类型定义 + thread 输入构造**。
|
||||
|
||||
## `agentToAdapter` — 向后兼容
|
||||
|
||||
把现有 `AgentFn`(hermes/cursor)包装成 `AdapterFn`:
|
||||
|
||||
```typescript
|
||||
function agentToAdapter(agent: AgentFn, extractProvider: LlmProvider): AdapterFn {
|
||||
return <T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
|
||||
return async (ctx: ThreadContext): Promise<T> => {
|
||||
const agentCtx = { ...ctx, currentRole: { name: "agent", systemPrompt: prompt } };
|
||||
const result = await agent(agentCtx);
|
||||
const output = typeof result === "string" ? result : result.output;
|
||||
return extract(output, schema, extractProvider);
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
hermes/cursor agent 内部不改,bundle-entry 层多包一层即可。
|
||||
|
||||
## 包结构
|
||||
|
||||
```
|
||||
packages/workflow-agent-react/
|
||||
src/
|
||||
types.ts # ReactAdapterConfig, ReactToolHandler
|
||||
create-react-adapter.ts # AdapterFn 工厂(包装 reactor)
|
||||
thread-input.ts # ThreadContext → user message string
|
||||
index.ts
|
||||
__tests__/
|
||||
create-react-adapter.test.ts
|
||||
package.json
|
||||
```
|
||||
|
||||
依赖:
|
||||
- `@uncaged/workflow-protocol` — `ThreadContext`, `LlmProvider`
|
||||
- `@uncaged/workflow-reactor` — `createLlmFn`, `createThreadReactor`, types
|
||||
|
||||
## 影响范围
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
| 改动 | 影响 |
|
||||
|------|------|
|
||||
| `AgentBinding` → `AdapterBinding` | `createWorkflow` 调用方(所有 bundle-entry) |
|
||||
| `AgentContext` 删除 | `buildAgentPrompt`(util-agent)改为接收 `ThreadContext` |
|
||||
| extract 从 engine 下沉到 adapter | `workflow-execute` 简化 |
|
||||
|
||||
### 需修改的包
|
||||
|
||||
1. `workflow-protocol` — 删除 `AgentContext`/`AgentFn`/`AgentFnResult`/`AgentBinding`,新增 `AdapterFn`/`RoleFn`/`AdapterBinding`
|
||||
2. `workflow-runtime` — 更新 re-export
|
||||
3. `workflow-execute` — engine 调用 `adapter(prompt, schema)` 替代 `agent(ctx) + extract`
|
||||
4. `workflow-util-agent` — `buildAgentPrompt` → `buildThreadInput`,接收 `ThreadContext`
|
||||
5. 所有 bundle-entry — `agent:` → `adapter:`
|
||||
|
||||
### 不受影响
|
||||
|
||||
- `workflow-cas` / `workflow-register` / `workflow-reactor` / `workflow-dashboard`
|
||||
- `workflow-agent-hermes` / `workflow-agent-cursor`(内部不改,外部用 `agentToAdapter` 包装)
|
||||
|
||||
## Phases
|
||||
|
||||
1. **Phase 1**: protocol 类型 + `createWorkflow` 签名变更 + `agentToAdapter`
|
||||
2. **Phase 2**: `workflow-agent-react` 包(包装 reactor)
|
||||
3. **Phase 3**: 工具集实现(read/write/patch/shell) + smoke test 闭环
|
||||
|
||||
## 工具集(后续讨论)
|
||||
|
||||
| 工具 | 说明 | 优先级 |
|
||||
|------|------|--------|
|
||||
| `read_file` | 读文件 | P0 |
|
||||
| `write_file` | 写文件 | P0 |
|
||||
| `patch_file` | find-and-replace 编辑 | P0 |
|
||||
| `shell_exec` | 执行 shell 命令 | P0 |
|
||||
| `search_files` | grep / find | P1 |
|
||||
| `list_files` | ls | P1 |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,387 @@
|
||||
# 设计文档:office-agent 文档生成/编辑 Workflow 体系
|
||||
|
||||
**日期:** 2026-05-18
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
在 monorepo 中新增三个包,实现通过 `office-agent` CLI 生成或编辑 Word 文档的完整 workflow 体系。
|
||||
|
||||
| 包 | npm name | 职责 |
|
||||
|---|---|---|
|
||||
| `workflow-template-document` | `@uncaged/workflow-template-document` | 纯结构:角色定义、meta schema、调度表、descriptor |
|
||||
| `workflow-agent-office` | `@uncaged/workflow-agent-office` | writer 角色执行器:调用 `office-agent` CLI |
|
||||
| `workflow-agent-docx-diff` | `@uncaged/workflow-agent-docx-diff` | differ 角色执行器:调用 `docx-diff` CLI |
|
||||
|
||||
Template 只定义结构,不含执行逻辑。执行器与 template 解耦。
|
||||
|
||||
---
|
||||
|
||||
## 一、`workflow-template-document`
|
||||
|
||||
### Thread 启动输入
|
||||
|
||||
```typescript
|
||||
// src/types.ts
|
||||
type DocumentStartInput = {
|
||||
prompt: string; // 用户指令
|
||||
inputDocx: string | null; // null = 生成模式;本机绝对路径 = 编辑模式
|
||||
};
|
||||
```
|
||||
|
||||
start.content 为 JSON `{ prompt, inputDocx }` 或纯文本(fallback:generate 模式,整段作为 prompt)。
|
||||
|
||||
### 角色与 Meta
|
||||
|
||||
`WriterMeta` 使用 discriminated union,在 schema 层区分两种模式:
|
||||
|
||||
```typescript
|
||||
const writerMetaSchema = z.discriminatedUnion("mode", [
|
||||
z.object({
|
||||
mode: z.literal("generate"),
|
||||
outputDocx: z.string(), // 生成产物绝对路径
|
||||
sourceDocx: z.null(),
|
||||
}),
|
||||
z.object({
|
||||
mode: z.literal("edit"),
|
||||
outputDocx: z.string(), // 修改后产物:<outputDir>/modified.docx
|
||||
sourceDocx: z.string(), // 原始副本:<outputDir>/original.docx
|
||||
}),
|
||||
]);
|
||||
type WriterMeta = z.infer<typeof writerMetaSchema>;
|
||||
|
||||
// differ:仅编辑模式执行
|
||||
const differMetaSchema = z.object({
|
||||
sourceDocx: z.string(),
|
||||
modifiedDocx: z.string(),
|
||||
diffDocx: z.string(),
|
||||
});
|
||||
type DifferMeta = z.infer<typeof differMetaSchema>;
|
||||
```
|
||||
|
||||
两个角色的 `systemPrompt` 均为 `""`。
|
||||
|
||||
### 调度表
|
||||
|
||||
```
|
||||
START → writer ──(mode = "edit")──→ differ → END
|
||||
↘(mode = "generate")→ END
|
||||
```
|
||||
|
||||
### 公开导出
|
||||
|
||||
template 导出两个对象供消费方使用:
|
||||
|
||||
- `documentWorkflowDefinition: WorkflowDefinition<DocumentMeta>` — 传入 `createWorkflow` 的 `def` 参数
|
||||
- `buildDocumentDescriptor(): WorkflowDescriptor` — bundle 导出用
|
||||
|
||||
```typescript
|
||||
// bundle 侧用法
|
||||
export const descriptor = buildDocumentDescriptor();
|
||||
export const run = createWorkflow(documentWorkflowDefinition, { adapter, overrides });
|
||||
```
|
||||
|
||||
### 包文件结构
|
||||
|
||||
```
|
||||
packages/workflow-template-document/
|
||||
src/
|
||||
types.ts # DocumentStartInput
|
||||
roles/
|
||||
writer.ts # writerMetaSchema, WriterMeta, writerRole
|
||||
differ.ts # differMetaSchema, DifferMeta, differRole
|
||||
index.ts
|
||||
roles.ts # DocumentMeta, documentRoles
|
||||
moderator.ts # writerIsEditMode condition + documentTable
|
||||
definition.ts # documentWorkflowDefinition
|
||||
descriptor.ts # buildDocumentDescriptor()
|
||||
index.ts
|
||||
__tests__/
|
||||
moderator.test.ts
|
||||
package.json
|
||||
tsconfig.json
|
||||
```
|
||||
|
||||
### 依赖
|
||||
|
||||
```json
|
||||
{
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"@uncaged/workflow-register": "workspace:^",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、`workflow-agent-office`
|
||||
|
||||
### office-agent CLI 接口
|
||||
|
||||
```bash
|
||||
# 生成模式:在 CWD 生成 output.docx
|
||||
office-agent create "<prompt>" -o output.docx
|
||||
|
||||
# 编辑模式:在 CWD 对 modified.docx 进行修改(覆写)
|
||||
office-agent edit modified.docx "<instruction>"
|
||||
```
|
||||
|
||||
- 两个命令均为阻塞调用(CLI 内部消费 SSE,退出即完成)
|
||||
- 输出文件落到调用方设定的 CWD
|
||||
- 退出码 0 = 成功,非零 = 失败
|
||||
|
||||
### 文件命名约定
|
||||
|
||||
| 模式 | 文件 | 路径 |
|
||||
|---|---|---|
|
||||
| generate | 输出 | `<outputDir>/output.docx` |
|
||||
| edit | 原始副本(workflow-owned 快照) | `<outputDir>/original.docx` |
|
||||
| edit | 修改后产物 | `<outputDir>/modified.docx` |
|
||||
|
||||
edit 模式先将 `inputDocx` 复制为 `original.docx`(不可变快照),再复制为 `modified.docx`,对 `modified.docx` 调用 CLI。agent 覆写 `modified.docx`,`original.docx` 保持不变。differ 对比这两个 workflow-owned 文件,不依赖用户原始路径。
|
||||
|
||||
### 执行流程
|
||||
|
||||
**生成模式(`inputDocx = null`):**
|
||||
1. `mkdir -p <outputDir>`(`<config.outputDir>/<ctx.threadId>`)
|
||||
2. `const command = config.command ?? "office-agent"`
|
||||
3. `spawnCli(command, ["create", prompt, "-o", "output.docx"], { cwd: outputDir, timeoutMs })`
|
||||
4. 验证 `outputDir/output.docx` 存在
|
||||
5. 返回 `JSON.stringify({ mode: "generate", outputDocx, sourceDocx: null })`
|
||||
|
||||
**编辑模式(`inputDocx ≠ null`):**
|
||||
1. `mkdir -p <outputDir>`
|
||||
2. `copyFile(inputDocx, <outputDir>/original.docx)`
|
||||
3. `copyFile(inputDocx, <outputDir>/modified.docx)`
|
||||
4. `const command = config.command ?? "office-agent"`
|
||||
5. `spawnCli(command, ["edit", "modified.docx", prompt], { cwd: outputDir, timeoutMs })`
|
||||
6. 验证 `outputDir/modified.docx` 存在
|
||||
7. 返回 `JSON.stringify({ mode: "edit", outputDocx: modifiedPath, sourceDocx: originalPath })`
|
||||
|
||||
### AdapterFn 实现(直接实现,不经过 runtime.extract)
|
||||
|
||||
CLI 产出确定性 JSON,直接 `schema.parse(JSON.parse(raw))` 跳过 LLM extraction:
|
||||
|
||||
```typescript
|
||||
export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
|
||||
return <T>(_systemPrompt: string, schema: z.ZodType<T>) =>
|
||||
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const { prompt, inputDocx } = parseStartInput(ctx.start.content);
|
||||
const raw = await runOfficeAgent(config, ctx.threadId, prompt, inputDocx);
|
||||
const meta = schema.parse(JSON.parse(raw)) as T;
|
||||
return { meta, childThread: null };
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
`_systemPrompt` 为 writer 角色的 systemPrompt(空字符串),实际指令从 `ctx.start.content` 解析。
|
||||
|
||||
### 配置
|
||||
|
||||
```typescript
|
||||
type OfficeAgentConfig = {
|
||||
outputDir: string; // 输出根目录,runner 在此下按 threadId 建子目录
|
||||
command: string | null; // null → runner 内 resolve 为 "office-agent"
|
||||
timeout: number | null; // null → 不设超时;单位 ms
|
||||
};
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
|
||||
```typescript
|
||||
if (!result.ok) {
|
||||
const e = result.error;
|
||||
if (e.kind === "non_zero_exit")
|
||||
throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`);
|
||||
if (e.kind === "timeout")
|
||||
throw new Error("office-agent: timed out");
|
||||
// "spawn_failed"
|
||||
throw new Error(`office-agent: spawn failed: ${e.message}`);
|
||||
}
|
||||
if (!existsSync(expectedPath))
|
||||
throw new Error(`office-agent: output file not found: ${expectedPath}`);
|
||||
```
|
||||
|
||||
### packageDescriptor
|
||||
|
||||
```typescript
|
||||
// src/package-descriptor.ts
|
||||
export const packageDescriptor: PackageDescriptor = {
|
||||
name: "@uncaged/workflow-agent-office",
|
||||
version: "0.1.0",
|
||||
capabilities: ["office-agent-cli", "docx-generate", "docx-edit"],
|
||||
configSchema: {
|
||||
type: "object",
|
||||
required: ["outputDir"],
|
||||
properties: {
|
||||
outputDir: { type: "string", description: "Root directory for workflow outputs." },
|
||||
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to office-agent CLI; null uses PATH." },
|
||||
timeout: { anyOf: [{ type: "number" }, { type: "null" }], description: "Timeout in ms; null means no limit." },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 包文件结构
|
||||
|
||||
```
|
||||
packages/workflow-agent-office/
|
||||
src/
|
||||
types.ts # OfficeAgentConfig, OfficeAgentOpt
|
||||
runner.ts # runOfficeAgent()(spawnCli 封装 + 文件验证)
|
||||
agent.ts # createOfficeAgent(): AdapterFn
|
||||
package-descriptor.ts # packageDescriptor
|
||||
index.ts
|
||||
__tests__/
|
||||
runner.test.ts
|
||||
agent.test.ts
|
||||
package.json
|
||||
tsconfig.json
|
||||
```
|
||||
|
||||
### 依赖
|
||||
|
||||
```json
|
||||
{
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"@uncaged/workflow-util-agent": "workspace:^"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、`workflow-agent-docx-diff`
|
||||
|
||||
`differ` 角色专用执行器。从 `ctx.steps` 读取 `WriterMeta`,调用本地 `docx-diff` CLI。
|
||||
|
||||
### docx-diff 退出码约定
|
||||
|
||||
| 退出码 | 含义 | runner 处理 |
|
||||
|---|---|---|
|
||||
| 0 | 无差异 | 正常,验证 diffDocx 存在 |
|
||||
| 1 | 有差异 | 正常(显式处理为成功),验证 diffDocx 存在 |
|
||||
| 2+ | 错误 | throw |
|
||||
|
||||
runner 收到 `SpawnCliError { kind: "non_zero_exit", exitCode: 1 }` 时视为成功,验证文件后继续;`exitCode >= 2` 才 throw。
|
||||
|
||||
### 执行流程
|
||||
|
||||
```
|
||||
1. 从 ctx.steps 找到 writer 步骤,读取 WriterMeta
|
||||
2. 验证 mode === "edit"(否则 throw)
|
||||
3. diffDocx = join(dirname(writer.outputDocx), "diff.docx")
|
||||
4. const command = config.command ?? "docx-diff"
|
||||
5. spawnCli(command,
|
||||
[writer.sourceDocx, writer.outputDocx, "--output", "docx", "--out-file", diffDocx],
|
||||
{ cwd: null, timeoutMs: null })
|
||||
exit 0 或 1 → 验证 diffDocx 存在
|
||||
exit 2+ → throw
|
||||
6. 返回 JSON.stringify({ sourceDocx, modifiedDocx: writer.outputDocx, diffDocx })
|
||||
```
|
||||
|
||||
### AdapterFn 实现(直接实现,不经过 runtime.extract)
|
||||
|
||||
```typescript
|
||||
export function createDocxDiffAgent(config: DocxDiffAgentConfig = { command: null }): AdapterFn {
|
||||
return <T>(_prompt: string, schema: z.ZodType<T>) =>
|
||||
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const writerStep = ctx.steps.find(s => s.role === "writer");
|
||||
if (!writerStep) throw new Error("differ: no writer step found");
|
||||
const writerMeta = writerStep.meta as WriterMeta;
|
||||
if (writerMeta.mode !== "edit")
|
||||
throw new Error("differ: writer did not run in edit mode");
|
||||
const raw = await runDocxDiff(config, writerMeta);
|
||||
const meta = schema.parse(JSON.parse(raw)) as T;
|
||||
return { meta, childThread: null };
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 配置
|
||||
|
||||
```typescript
|
||||
type DocxDiffAgentConfig = {
|
||||
command: string | null; // null → runner 内 resolve 为 "docx-diff"
|
||||
};
|
||||
```
|
||||
|
||||
### packageDescriptor
|
||||
|
||||
```typescript
|
||||
export const packageDescriptor: PackageDescriptor = {
|
||||
name: "@uncaged/workflow-agent-docx-diff",
|
||||
version: "0.1.0",
|
||||
capabilities: ["docx-diff-cli", "docx-diff-report"],
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to docx-diff CLI; null uses PATH." },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 包文件结构
|
||||
|
||||
```
|
||||
packages/workflow-agent-docx-diff/
|
||||
src/
|
||||
types.ts # DocxDiffAgentConfig
|
||||
runner.ts # runDocxDiff()(exit 1 处理 + 文件验证)
|
||||
agent.ts # createDocxDiffAgent(): AdapterFn
|
||||
package-descriptor.ts # packageDescriptor
|
||||
index.ts
|
||||
__tests__/
|
||||
runner.test.ts
|
||||
agent.test.ts
|
||||
package.json
|
||||
tsconfig.json
|
||||
```
|
||||
|
||||
### 依赖
|
||||
|
||||
```json
|
||||
{
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util-agent": "workspace:^",
|
||||
"@uncaged/workflow-template-document": "workspace:^"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、外部 bundle(外部 workspace 消费)
|
||||
|
||||
```typescript
|
||||
import { createOfficeAgent } from "@uncaged/workflow-agent-office";
|
||||
import { createDocxDiffAgent } from "@uncaged/workflow-agent-docx-diff";
|
||||
import {
|
||||
buildDocumentDescriptor,
|
||||
documentWorkflowDefinition,
|
||||
} from "@uncaged/workflow-template-document";
|
||||
import { createWorkflow } from "@uncaged/workflow-runtime";
|
||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
|
||||
import { join } from "node:path";
|
||||
|
||||
const outputDir = join(getDefaultWorkflowStorageRoot(), "outputs");
|
||||
|
||||
export const descriptor = buildDocumentDescriptor();
|
||||
export const run = createWorkflow(documentWorkflowDefinition, {
|
||||
adapter: createOfficeAgent({ outputDir, command: null, timeout: null }),
|
||||
overrides: { differ: createDocxDiffAgent() },
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- 重试逻辑(失败直接 throw)
|
||||
- office-agent server 的启停管理(假设 server 已在运行)
|
||||
- docx-diff HTML/terminal 格式输出(仅 docx)
|
||||
- 跨机器执行(`inputDocx` 须为本机有效绝对路径)
|
||||
@@ -0,0 +1,67 @@
|
||||
# Sync README
|
||||
|
||||
When updating README.md files in this monorepo, follow these conventions.
|
||||
|
||||
## Scope
|
||||
|
||||
- Root `README.md` — project overview and navigation hub
|
||||
- Per-package `packages/*/README.md` — each package self-contained
|
||||
|
||||
## Root README Structure
|
||||
|
||||
The root README should have these sections in order:
|
||||
|
||||
1. **Title and one-liner** — stateless workflow engine driven by single-step CLI
|
||||
2. **Overview** — 2-3 paragraphs explaining what it does and key concepts
|
||||
3. **Architecture** — dependency layer diagram (text-based)
|
||||
4. **Packages** — table with ALL packages from packages/ directory, columns: Package, Description, Type (cli/lib/agent/app)
|
||||
5. **Quick Start** — install, build, register workflow, start thread, run step
|
||||
6. **CLI Reference** — brief command list, detailed usage in cli-workflow README
|
||||
7. **Development** — bun install / build / check / test
|
||||
|
||||
## Per-Package README Structure
|
||||
|
||||
Each package README should have:
|
||||
|
||||
1. **Title** — package name
|
||||
2. **One-line description** — matching package.json
|
||||
3. **Overview** — what it does, where it sits in the architecture, dependencies
|
||||
4. **Installation** — bun add (for libs) or "included as binary" (for cli/agents)
|
||||
5. **API** (lib packages) — all exports from src/index.ts with type signatures, grouped by category, minimal usage examples
|
||||
6. **CLI Usage** (cli/agent packages) — command reference with examples
|
||||
7. **Internal Structure** — brief src/ file organization
|
||||
8. **Configuration** (if applicable)
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1: Gather current state
|
||||
For each package read:
|
||||
- package.json (name, version, description, dependencies, bin)
|
||||
- src/index.ts (public API exports)
|
||||
- Existing README.md (preserve hand-written content worth keeping)
|
||||
|
||||
### Step 2: Update root README
|
||||
- Ensure ALL packages in packages/ directory are listed in the table
|
||||
- Update CLI command reference from uwf --help output
|
||||
- Keep Quick Start examples valid
|
||||
|
||||
### Step 3: Write/update each package README
|
||||
- Follow the per-package structure
|
||||
- API section MUST match actual src/index.ts exports — never invent
|
||||
- For agent packages: document CLI binary name, how it is invoked
|
||||
- For lib packages: document exported types and functions
|
||||
- Internal structure: list actual files in src/
|
||||
|
||||
### Step 4: Verify
|
||||
- All relative links work
|
||||
- Package names match package.json
|
||||
- No references to removed/renamed packages
|
||||
- bun run build still passes
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Only document what src/index.ts actually exports
|
||||
- Root README summarizes, package READMEs go into detail
|
||||
- Verify CLI examples against actual commands
|
||||
- Preserve existing good prose when updating
|
||||
- English for all README content
|
||||
@@ -0,0 +1,517 @@
|
||||
# `uwf` — Stateless Workflow CLI
|
||||
|
||||
> 将 workflow 引擎降维为无状态单步 CLI。Workflow 是纯数据(CAS 节点),执行是单步原子操作,agent 是可插拔外部命令。
|
||||
|
||||
---
|
||||
|
||||
## 1. CLI Design
|
||||
|
||||
### 1.1 命令总览
|
||||
|
||||
```
|
||||
# thread 组
|
||||
uwf thread start <workflow> -p <prompt> # 创建 thread,不执行
|
||||
uwf thread step <thread-id> [--agent] # 单步执行
|
||||
uwf thread show <thread-id> # thread-id → head 查询
|
||||
uwf thread list [--all] # 列出活跃 threads(--all 含已归档)
|
||||
uwf thread kill <thread-id> # 终结 thread,归档
|
||||
|
||||
# workflow 组
|
||||
uwf workflow put <file.yaml> # 注册 workflow(YAML → CAS)
|
||||
uwf workflow show <workflow-id> # 查看 workflow 定义
|
||||
uwf workflow list # 列出已注册 workflows
|
||||
```
|
||||
|
||||
两组对称,各 3-4 个子命令。CAS 操作交给 `json-cas` CLI,不在 `uwf` 中重复。
|
||||
|
||||
### 1.2 `uwf thread start`
|
||||
|
||||
```bash
|
||||
uwf thread start <workflow> -p "Fix the login bug described in issue #42"
|
||||
```
|
||||
|
||||
- `<workflow>` — workflow 名或 CAS hash
|
||||
- `-p` — 用户 prompt(必填)
|
||||
|
||||
**输出(JSON to stdout):**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"workflow": "4KNM2PXR3B1QW", // workflow CAS hash (XXH64, 13-char Crockford Base32)
|
||||
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T" // ULID
|
||||
}
|
||||
```
|
||||
|
||||
**做的事:**
|
||||
1. 解析 workflow(名字查 registry → CAS hash)
|
||||
2. 生成 thread ULID
|
||||
3. 写 StartNode 到 CAS
|
||||
4. 在 threads.yaml 中记录链头 → StartNode hash
|
||||
5. 输出 JSON
|
||||
|
||||
### 1.3 `uwf thread step`
|
||||
|
||||
```bash
|
||||
uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T
|
||||
uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T --agent "bunx uwf-cursor"
|
||||
```
|
||||
|
||||
**输出(JSON to stdout):**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"workflow": "4KNM2PXR3B1QW",
|
||||
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T",
|
||||
"head": "8FWKR3TN5V1QA", // 新链头 StepNode 的 CAS hash
|
||||
"done": false // true = moderator 返回 END,thread 已归档
|
||||
}
|
||||
```
|
||||
|
||||
`done: true` 时 head 仍然有值(最后一个 StepNode),但 thread 已从 threads.yaml 移除。
|
||||
对已结束或不存在的 thread 调用 step 会报错(非 active thread)。
|
||||
|
||||
详细信息通过 `uwf thread show <thread-id>` 或 `json-cas get <head>` 查看。
|
||||
|
||||
**做的事:**
|
||||
1. 读链头 → 当前 StepNode(或 StartNode)
|
||||
2. 收集 thread 历史(遍历链)
|
||||
3. 调 moderator:status-based map lookup → 得到下一个 role(或 END)
|
||||
4. 若 END → 归档 thread,输出最后链头,退出
|
||||
5. 确定 agent command(`--agent` override > config.yaml per-workflow/role > config.yaml defaultAgent)
|
||||
6. 调用:`<agent-cmd> <thread-id> <role>`,捕获 stdout 得到新 StepNode hash
|
||||
7. 更新链头指针
|
||||
8. 再次调 moderator(基于新 StepNode)判断 done
|
||||
9. 输出 JSON
|
||||
|
||||
### 1.4 `uwf thread show`
|
||||
|
||||
```bash
|
||||
uwf thread show 01J7K9M2XNPQR5VWBCDF8G3H4T
|
||||
```
|
||||
|
||||
**输出(JSON to stdout):**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"workflow": "4KNM2PXR3B1QW",
|
||||
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T",
|
||||
"head": "8FWKR3TN5V1QA",
|
||||
"done": false
|
||||
}
|
||||
```
|
||||
|
||||
纯 thread-id → head 查询。详细内容用 `json-cas get <head>` 或 `json-cas walk <head>` 查看。
|
||||
|
||||
### 1.5 Agent CLI 协议
|
||||
|
||||
每个 agent 是一个命令,接受 thread-id 和 role 两个参数:
|
||||
|
||||
```bash
|
||||
uwf-hermes <thread-id> <role>
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- `uwf step` 负责 moderator 决策,将 role 传给 agent CLI
|
||||
- agent-kit 根据 thread + role 从 CAS 读 goal / capabilities / procedure / output / meta
|
||||
- agent-kit 组装完整 prompt(role goal/capabilities/procedure/output + thread context + user prompt from StartNode)
|
||||
- agent 执行实际逻辑,agent-kit 负责 extract
|
||||
- agent 将 StepNode 写入 CAS(含 output、detail、agent、prev),但**不挪链头指针**
|
||||
- stdout 输出新 StepNode 的 CAS hash(纯文本,一行)
|
||||
- 所有配置从环境变量读(LLM model、API key、extractor config)
|
||||
- exit 0 = 成功,非 0 = 失败
|
||||
|
||||
**stdout 输出:**
|
||||
|
||||
```
|
||||
8FWKR3TN5V1QA
|
||||
```
|
||||
|
||||
`uwf step` 拿到这个 hash 后更新链头指针、判断 done。
|
||||
|
||||
---
|
||||
|
||||
## 2. CAS 结构定义
|
||||
|
||||
### 2.1 类型层级
|
||||
|
||||
沿用 json-cas 的三层:bootstrap meta-schema → JSON Schema nodes → data nodes。
|
||||
|
||||
下面所有 CAS 节点都遵循 `{ type: cas_ref, payload: T, timestamp: number }` 的标准格式。
|
||||
`cas_ref` 类型的字符串字段在 json-cas 中已内置支持,不需要额外的 `$ref` 包装。
|
||||
|
||||
### 2.2 数据节点
|
||||
|
||||
#### `Workflow`
|
||||
|
||||
Roles 和 moderator 内联在 Workflow 中,只有 meta 独立为 CAS 节点(方便 json-cas 校验)。
|
||||
|
||||
```yaml
|
||||
type: <workflow-schema-hash>
|
||||
payload:
|
||||
name: "solve-issue"
|
||||
description: "End-to-end issue resolution"
|
||||
roles:
|
||||
planner:
|
||||
description: "Creates implementation plan"
|
||||
goal: "You are a planning agent..."
|
||||
capabilities: [planning, issue-analysis]
|
||||
procedure: "Analyze the issue and create a plan."
|
||||
output: "Output the plan summary."
|
||||
meta: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
|
||||
developer:
|
||||
description: "Implements code changes"
|
||||
goal: "You are a developer agent..."
|
||||
capabilities: [file-edit, shell]
|
||||
procedure: "Implement the plan."
|
||||
output: "List all files changed."
|
||||
meta: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
|
||||
reviewer:
|
||||
description: "Reviews code changes"
|
||||
goal: "You are a code reviewer..."
|
||||
capabilities: [code-review]
|
||||
procedure: "Review the implementation."
|
||||
output: "Approve or reject with comments."
|
||||
meta: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
|
||||
conditions:
|
||||
needsClarification:
|
||||
description: "Planner requests clarification from user"
|
||||
expression: "$exists(steps[-1].output.needsClarification)"
|
||||
notApproved:
|
||||
description: "Reviewer rejected the implementation"
|
||||
expression: "steps[-1].output.approved = false"
|
||||
graph:
|
||||
$START:
|
||||
- role: "planner"
|
||||
condition: null # 无条件(fallback)
|
||||
planner:
|
||||
- role: "developer"
|
||||
condition: "needsClarification"
|
||||
- role: "$END"
|
||||
condition: null
|
||||
developer:
|
||||
- role: "reviewer"
|
||||
condition: null
|
||||
reviewer:
|
||||
- role: "developer"
|
||||
condition: "notApproved"
|
||||
- role: "$END"
|
||||
condition: null
|
||||
```
|
||||
|
||||
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
|
||||
- `graph` — `Record<Role | "$START", Record<Status, Target>>`,每个 Target = `{ role, prompt }`
|
||||
- Status 来自上一个 role 输出的 `status` 字段,`$START` 用 `_` 作为初始 status
|
||||
- Prompt 模板使用 Mustache 渲染,变量来自 lastOutput
|
||||
- 不含 agent binding — agent 配置在 `~/.uncaged/workflow/config.yaml` 中管理
|
||||
|
||||
Moderator 的求值逻辑:
|
||||
|
||||
```typescript
|
||||
evaluate(graph, lastRole, lastOutput) → { role, prompt }
|
||||
// 1. status = lastRole === "$START" ? "_" : lastOutput.status
|
||||
// 2. target = graph[lastRole][status]
|
||||
// 3. prompt = mustache.render(target.prompt, lastOutput)
|
||||
```
|
||||
|
||||
注:routing 基于 `lastOutput.status` 字段的值,直接在 graph map 中查找对应的 Target。
|
||||
|
||||
#### `StartNode`(Thread 起点)
|
||||
|
||||
```yaml
|
||||
type: <start-node-schema-hash>
|
||||
payload:
|
||||
workflow: "4KNM2PXR3B1QW" # cas_ref → Workflow
|
||||
prompt: "Fix the login bug..."
|
||||
```
|
||||
|
||||
- 没有 thread-id — thread-id 是索引层面的事,不进 CAS 内容
|
||||
- 没有 agent binding — 运行时从 config.yaml 解析
|
||||
|
||||
#### `StepNode`(Thread 每一步)
|
||||
|
||||
```yaml
|
||||
type: <step-node-schema-hash>
|
||||
payload:
|
||||
start: "4TNVW8KR2B3MA" # cas_ref → StartNode(每个 step 都引用)
|
||||
prev: "2MXBG6PN4A8JR" # cas_ref → 前一个 StepNode,第一步为 null
|
||||
role: "developer"
|
||||
output: "9KRVW3TN5F1QA" # cas_ref → 结构化输出节点(符合 role 的 meta schema)
|
||||
detail: "7BQST3VW9F2MA" # cas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
|
||||
agent: "uwf-cursor" # 实际使用的 agent 命令(纯字符串)
|
||||
```
|
||||
|
||||
- `start` — 每个 StepNode 都直接引用 StartNode,方便随机访问
|
||||
- `prev` — 前一个 StepNode 的 cas_ref,第一步为 `null`(不指向 StartNode)
|
||||
- `output` — cas_ref,指向符合 role meta schema 的 CAS 节点,可用 json-cas 校验
|
||||
- `detail` — cas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
|
||||
- `agent` — 纯字符串,不是 CAS 节点
|
||||
|
||||
### 2.3 链式结构
|
||||
|
||||
```
|
||||
threads.yaml: { "01J7K9M2XNPQR5VWBCDF8G3H4T": "8FWKR3TN5V1QA" }
|
||||
│
|
||||
▼
|
||||
StepNode (step 3)
|
||||
├── start ──→ StartNode
|
||||
│ ├── workflow → CAS(Workflow)
|
||||
│ └── prompt: "Fix..."
|
||||
├── prev ──→ StepNode (step 2)
|
||||
│ ├── start ──→ (same StartNode)
|
||||
│ ├── prev ──→ StepNode (step 1)
|
||||
│ │ ├── start ──→ (same StartNode)
|
||||
│ │ ├── prev: null
|
||||
│ │ ├── role: "planner"
|
||||
│ │ └── ...
|
||||
│ ├── role: "developer"
|
||||
│ └── ...
|
||||
├── role: "reviewer"
|
||||
├── output → CAS({ approved: true })
|
||||
├── detail → CAS(raw output | sub-workflow terminal node)
|
||||
└── agent: "uwf-hermes"
|
||||
```
|
||||
|
||||
### 2.4 可变状态
|
||||
|
||||
系统两个顶层 YAML 文件和一个 env 文件:
|
||||
|
||||
```yaml
|
||||
# ~/.uncaged/workflow/config.yaml — 全局配置
|
||||
providers:
|
||||
openai:
|
||||
baseUrl: "https://api.openai.com/v1"
|
||||
apiKey: "sk-..."
|
||||
anthropic:
|
||||
baseUrl: "https://api.anthropic.com/v1"
|
||||
apiKey: "sk-ant-..."
|
||||
openrouter:
|
||||
baseUrl: "https://openrouter.ai/api/v1"
|
||||
apiKey: "sk-or-..."
|
||||
|
||||
models:
|
||||
sonnet:
|
||||
provider: "openrouter"
|
||||
name: "anthropic/claude-sonnet-4"
|
||||
gpt4o-mini:
|
||||
provider: "openai"
|
||||
name: "gpt-4o-mini"
|
||||
|
||||
agents:
|
||||
hermes:
|
||||
command: "uwf-hermes"
|
||||
args: []
|
||||
cursor:
|
||||
command: "uwf-cursor"
|
||||
args: []
|
||||
|
||||
defaultAgent: "hermes"
|
||||
agentOverrides:
|
||||
solve-issue:
|
||||
developer: "cursor"
|
||||
|
||||
defaultModel: "sonnet"
|
||||
modelOverrides:
|
||||
extract: "gpt4o-mini"
|
||||
```
|
||||
|
||||
```yaml
|
||||
# ~/.uncaged/workflow/threads.yaml — active thread 链头指针
|
||||
01J7K9M2XNPQR5VWBCDF8G3H4T: "8FWKR3TN5V1QA"
|
||||
01J8AB3QRMSTV6WKXZ2C4DF7GN: "3CNWT9KR6D2HV"
|
||||
```
|
||||
|
||||
Thread 结束时从 threads.yaml 移除。可选:追加到 `history.jsonl` 做归档。
|
||||
|
||||
```bash
|
||||
# ~/.uncaged/workflow/.env — 敏感信息(API keys)
|
||||
OPENAI_API_KEY=sk-...
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
OPENROUTER_API_KEY=sk-or-...
|
||||
```
|
||||
|
||||
- `config.yaml` — 非敏感配置(agent 命令、model 名、provider 名)
|
||||
- `.env` — 敏感信息(API keys),agent-kit 启动时自动加载
|
||||
- `threads.yaml` — 运行时状态
|
||||
|
||||
---
|
||||
|
||||
## 3. 包结构
|
||||
|
||||
全新包,不复用现有 packages,避免命名冲突。CAS 直接依赖 `@uncaged/json-cas`。
|
||||
|
||||
```
|
||||
packages/
|
||||
├── cli-workflow/ # @uncaged/cli-workflow — uwf CLI(thread/workflow 命令,含 src/moderator/)
|
||||
├── workflow-util-agent/ # @uncaged/workflow-util-agent — Agent CLI 框架(含 extractor)
|
||||
├── workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI
|
||||
├── workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — uwf-cursor CLI
|
||||
└── workflow-protocol/ # @uncaged/workflow-protocol — 共享类型定义
|
||||
```
|
||||
|
||||
**外部依赖:**
|
||||
- `@uncaged/json-cas` — CAS 存储、hash、schema 校验
|
||||
- `@uncaged/json-cas-fs` — 文件系统 CAS 后端
|
||||
|
||||
**现有包全部保留不动**,新旧并存,逐步迁移。
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键数据类型
|
||||
|
||||
Moderator 通过 status-based map lookup 进行路由。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
|
||||
|
||||
### 4.1 公共类型
|
||||
|
||||
```typescript
|
||||
/** CAS hash — XXH64, 13-char Crockford Base32 */
|
||||
type CasRef = string;
|
||||
|
||||
/** Thread ID — ULID, 26-char Crockford Base32 */
|
||||
type ThreadId = string;
|
||||
|
||||
/** 一个 step 的核心数据,被 StepNode payload 和 moderator 上下文共享 */
|
||||
type StepRecord = {
|
||||
role: string;
|
||||
output: CasRef; // cas_ref → 结构化输出节点(符合 role meta schema)
|
||||
detail: CasRef; // cas_ref → 执行详情(content node / 子 workflow terminal StepNode)
|
||||
agent: string; // 实际使用的 agent 命令(纯字符串)
|
||||
};
|
||||
```
|
||||
|
||||
### 4.2 Workflow 定义
|
||||
|
||||
```typescript
|
||||
type RoleDefinition = {
|
||||
description: string;
|
||||
goal: string;
|
||||
capabilities: string[];
|
||||
procedure: string;
|
||||
output: string;
|
||||
meta: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
|
||||
};
|
||||
|
||||
type Target = {
|
||||
role: string; // 目标 role 名 或 "$END"
|
||||
prompt: string; // Mustache 模板,渲染时注入 lastOutput
|
||||
};
|
||||
|
||||
type WorkflowPayload = {
|
||||
name: string;
|
||||
description: string;
|
||||
roles: Record<string, RoleDefinition>;
|
||||
graph: Record<string, Record<string, Target>>; // Record<Role | "$START", Record<Status, Target>>
|
||||
};
|
||||
```
|
||||
|
||||
### 4.3 Thread 节点
|
||||
|
||||
```typescript
|
||||
type StartNodePayload = {
|
||||
workflow: CasRef; // cas_ref → Workflow
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
type StepNodePayload = StepRecord & {
|
||||
start: CasRef; // cas_ref → StartNode(每个 step 都引用)
|
||||
prev: CasRef | null; // cas_ref → 前一个 StepNode,第一步为 null
|
||||
};
|
||||
```
|
||||
|
||||
### 4.4 Moderator 求值
|
||||
|
||||
Moderator 使用 `evaluate(graph, lastRole, lastOutput)` 进行同步 status-based routing:
|
||||
|
||||
```typescript
|
||||
// graph[lastRole][lastOutput.status] → Target { role, prompt }
|
||||
// $START 角色使用 "_" 作为初始 status
|
||||
// prompt 通过 Mustache 模板渲染,变量来自 lastOutput
|
||||
```
|
||||
|
||||
### 4.5 CLI 输出
|
||||
|
||||
```typescript
|
||||
/** uwf thread start */
|
||||
type StartOutput = {
|
||||
workflow: CasRef;
|
||||
thread: ThreadId;
|
||||
};
|
||||
|
||||
/** uwf thread step / uwf thread show */
|
||||
type StepOutput = {
|
||||
workflow: CasRef;
|
||||
thread: ThreadId;
|
||||
head: CasRef;
|
||||
done: boolean;
|
||||
};
|
||||
|
||||
/** uwf thread list */
|
||||
type ThreadListItem = {
|
||||
thread: ThreadId;
|
||||
workflow: CasRef;
|
||||
head: CasRef;
|
||||
};
|
||||
```
|
||||
|
||||
### 4.6 配置
|
||||
|
||||
```typescript
|
||||
/** Alias types for config references */
|
||||
type AgentAlias = string;
|
||||
type ModelAlias = string;
|
||||
type ProviderAlias = string;
|
||||
type WorkflowName = string;
|
||||
type RoleName = string;
|
||||
type Scenario = string; // e.g. "extract"
|
||||
|
||||
type ProviderConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string; // API key stored directly
|
||||
};
|
||||
|
||||
type ModelConfig = {
|
||||
provider: ProviderAlias;
|
||||
name: string; // e.g. "anthropic/claude-sonnet-4", "gpt-4o-mini"
|
||||
};
|
||||
|
||||
type AgentConfig = {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
/** ~/.uncaged/workflow/config.yaml */
|
||||
type WorkflowConfig = {
|
||||
providers: Record<ProviderAlias, ProviderConfig>;
|
||||
models: Record<ModelAlias, ModelConfig>;
|
||||
agents: Record<AgentAlias, AgentConfig>;
|
||||
defaultAgent: AgentAlias;
|
||||
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
|
||||
defaultModel: ModelAlias;
|
||||
modelOverrides: Record<Scenario, ModelAlias> | null;
|
||||
};
|
||||
|
||||
/** ~/.uncaged/workflow/threads.yaml */
|
||||
type ThreadsIndex = Record<ThreadId, CasRef>;
|
||||
// ^ thread-id ^ head StepNode/StartNode hash
|
||||
```
|
||||
|
||||
### 4.7 类型关系图
|
||||
|
||||
```
|
||||
WorkflowConfig (config.yaml)
|
||||
ThreadsIndex (threads.yaml) ← 唯二可变状态
|
||||
│
|
||||
│ thread-id → head hash
|
||||
▼
|
||||
StepNodePayload ──extends──→ StepRecord ←──maps to──→ StepContext
|
||||
│ │ │
|
||||
├── start → StartNodePayload│ │ (output 展开)
|
||||
├── prev → StepNodePayload │ │
|
||||
│ ├── role ├── role
|
||||
│ ├── output (CasRef) ├── output (展开)
|
||||
│ ├── detail (CasRef) ├── detail (CasRef)
|
||||
│ └── agent (string) └── agent (string)
|
||||
│
|
||||
└── start.workflow → WorkflowPayload
|
||||
├── roles: Record<name, RoleDefinition>
|
||||
└── graph: Record<role, Record<status, Target>>
|
||||
```
|
||||
@@ -0,0 +1,40 @@
|
||||
name: "analyze-topic"
|
||||
description: "Single-role topic analysis using four-phase role description"
|
||||
roles:
|
||||
analyst:
|
||||
description: "Analyzes a given topic and produces a structured summary"
|
||||
goal: |
|
||||
You are a research analyst with expertise in breaking down complex topics
|
||||
into clear, structured summaries. You think critically and cite key points.
|
||||
capabilities:
|
||||
- research
|
||||
- critical-thinking
|
||||
- structured-writing
|
||||
procedure: |
|
||||
Analyze the topic by:
|
||||
1. Identifying the main thesis or question
|
||||
2. Listing 3-5 key points with brief explanations
|
||||
3. Noting any counterarguments or caveats
|
||||
Keep your analysis concise (under 500 words).
|
||||
output: |
|
||||
Provide your analysis as markdown under the frontmatter.
|
||||
The frontmatter must include your structured findings.
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["_"]
|
||||
thesis:
|
||||
type: string
|
||||
keyPoints:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
caveats:
|
||||
type: string
|
||||
required: [$status, thesis, keyPoints]
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "analyst", prompt: "Analyze the topic in the task and produce a structured summary with key points." }
|
||||
analyst:
|
||||
_: { role: "$END", prompt: "Analysis complete. Finish the workflow." }
|
||||
@@ -0,0 +1,62 @@
|
||||
name: "debate"
|
||||
description: "Structured debate between two sides. Tests cross-process session resume."
|
||||
roles:
|
||||
against:
|
||||
description: "Argues against the proposition"
|
||||
goal: |
|
||||
You are a skilled debater arguing AGAINST the proposition.
|
||||
Be logical, cite evidence, and directly address your opponent's points.
|
||||
Keep each argument concise (under 200 words).
|
||||
capabilities:
|
||||
- argumentation
|
||||
- critical-thinking
|
||||
procedure: |
|
||||
1. If this is the opening, present your strongest argument against the proposition.
|
||||
2. If responding to the other side, directly counter their points with evidence and logic.
|
||||
3. If you find yourself genuinely convinced by the other side, you may concede.
|
||||
output: |
|
||||
Provide your argument in the frontmatter.
|
||||
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Otherwise set status to "continue".
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["continue", "conceded"]
|
||||
argument:
|
||||
type: string
|
||||
required: [$status, argument]
|
||||
for:
|
||||
description: "Argues for the proposition"
|
||||
goal: |
|
||||
You are a skilled debater arguing FOR the proposition.
|
||||
Be logical, cite evidence, and directly address your opponent's points.
|
||||
Keep each argument concise (under 200 words).
|
||||
capabilities:
|
||||
- argumentation
|
||||
- critical-thinking
|
||||
procedure: |
|
||||
1. Read the opposing side's latest argument carefully.
|
||||
2. Counter their points with evidence and logic.
|
||||
3. If you find yourself genuinely convinced by the other side, you may concede.
|
||||
output: |
|
||||
Provide your argument in the frontmatter.
|
||||
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Otherwise set status to "continue".
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["continue", "conceded"]
|
||||
argument:
|
||||
type: string
|
||||
required: [$status, argument]
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "against", prompt: "Present your opening argument against the proposition." }
|
||||
against:
|
||||
conceded: { role: "$END", prompt: "The against side conceded. Debate over." }
|
||||
continue: { role: "for", prompt: "Counter the opposing argument: {{{argument}}}" }
|
||||
for:
|
||||
conceded: { role: "$END", prompt: "The for side conceded. Debate over." }
|
||||
continue: { role: "against", prompt: "Counter the opposing argument: {{{argument}}}" }
|
||||
@@ -0,0 +1,198 @@
|
||||
name: "solve-issue"
|
||||
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds."
|
||||
roles:
|
||||
planner:
|
||||
description: "Analyzes issue and outputs a TDD test spec"
|
||||
goal: "You are a planning agent. You analyze Gitea issues and produce a TDD test specification that downstream roles will implement and verify."
|
||||
capabilities:
|
||||
- issue-analysis
|
||||
- planning
|
||||
procedure: |
|
||||
On first run (no previous steps):
|
||||
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
|
||||
2. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
|
||||
3. Assess whether the issue has enough information to produce a test spec
|
||||
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output $status=insufficient_info
|
||||
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
|
||||
|
||||
On subsequent runs (bounced back by tester with fix_spec):
|
||||
1. Read the tester's output from the previous step to understand what's wrong with the spec
|
||||
2. Revise the test spec accordingly
|
||||
|
||||
After producing the test spec:
|
||||
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
|
||||
2. Put the hash in frontmatter.plan (required when $status=ready)
|
||||
3. Set repoPath to the absolute path of the repository root
|
||||
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "ready" }
|
||||
plan: { type: string }
|
||||
repoPath: { type: string }
|
||||
required: [$status, plan, repoPath]
|
||||
- properties:
|
||||
$status: { const: "insufficient_info" }
|
||||
required: [$status]
|
||||
developer:
|
||||
description: "TDD implementation per test spec"
|
||||
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
|
||||
capabilities:
|
||||
- coding
|
||||
procedure: |
|
||||
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
|
||||
The repo path and other details are provided in your task prompt.
|
||||
|
||||
Before starting any work, set up an isolated worktree:
|
||||
1. cd into the repo path provided in your task prompt
|
||||
2. `git fetch origin` to get latest refs
|
||||
3. First time (no existing branch):
|
||||
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
|
||||
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
|
||||
4. If bounced back from reviewer or tester (branch already exists):
|
||||
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
|
||||
- `git fetch origin && git rebase origin/main`
|
||||
5. ALL subsequent work must happen inside the worktree directory.
|
||||
|
||||
Then implement TDD:
|
||||
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
|
||||
7. If bounced back from reviewer or tester: read the previous role's feedback in your task prompt
|
||||
8. Write tests first based on the spec
|
||||
9. Implement the code to make tests pass
|
||||
10. Ensure `bun run build` passes with no errors
|
||||
11. Run `bun test` to verify all tests pass
|
||||
|
||||
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
|
||||
or repeated attempts fail), set $status=failed with a reason.
|
||||
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "done" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "failed" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
reviewer:
|
||||
description: "Code standards compliance check"
|
||||
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
|
||||
capabilities:
|
||||
- code-review
|
||||
- static-analysis
|
||||
procedure: |
|
||||
The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
Before reviewing, verify the git branch:
|
||||
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
|
||||
2. If the branch doesn't correspond to the issue, flag it in your output and reject
|
||||
|
||||
Then perform code review:
|
||||
Hard checks (must all pass):
|
||||
3. `bun run build` — no build errors
|
||||
4. `bunx biome check` — no lint violations
|
||||
5. TypeScript strict mode — no type errors
|
||||
|
||||
Soft checks (review against project conventions if CLAUDE.md / .cursor/rules exist):
|
||||
- Naming conventions, module boundaries, code style
|
||||
- No `console.log` in production code
|
||||
- No dynamic imports in production code
|
||||
|
||||
Only review standards compliance. Do NOT test functionality.
|
||||
If rejecting, you MUST explain the specific reason in your output.
|
||||
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "approved" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "rejected" }
|
||||
comments: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, comments, worktree]
|
||||
tester:
|
||||
description: "Functional correctness verification"
|
||||
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
|
||||
capabilities:
|
||||
- testing
|
||||
procedure: |
|
||||
The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
1. Run `bun test` for automated test verification
|
||||
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner step in the thread history)
|
||||
3. Verify each scenario in the spec is covered and passing
|
||||
4. Determine outcome:
|
||||
- passed: all scenarios verified, tests pass
|
||||
- fix_code: tests fail or implementation doesn't match spec → send back to developer
|
||||
- fix_spec: the spec itself is wrong or incomplete → send back to planner
|
||||
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "passed" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "fix_code" }
|
||||
report: { type: string }
|
||||
required: [$status, report]
|
||||
- properties:
|
||||
$status: { const: "fix_spec" }
|
||||
report: { type: string }
|
||||
required: [$status, report]
|
||||
committer:
|
||||
description: "Commits and creates PR"
|
||||
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
|
||||
capabilities: []
|
||||
procedure: |
|
||||
The worktree path, branch name, and repo info are provided in your task prompt.
|
||||
cd into the worktree first.
|
||||
|
||||
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
|
||||
1. Stage all changes: `git add -A`
|
||||
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
|
||||
3. Push the branch: `git push -u origin <branch-name>`
|
||||
- If push hook fails: capture the error log in your output, mark hook_failed
|
||||
4. On push success: create a PR via `tea pr create --repo <owner/repo> --title "..." --description "..."`
|
||||
- Extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
|
||||
- PR description must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
||||
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
|
||||
5. After PR creation, clean up the worktree:
|
||||
- cd to the repo root (parent of .worktrees)
|
||||
- `git worktree remove <worktree-path>`
|
||||
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "committed" }
|
||||
prUrl: { type: string }
|
||||
required: [$status, prUrl]
|
||||
- properties:
|
||||
$status: { const: "hook_failed" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
|
||||
planner:
|
||||
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
|
||||
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
|
||||
developer:
|
||||
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
|
||||
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
|
||||
reviewer:
|
||||
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}." }
|
||||
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}." }
|
||||
tester:
|
||||
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit." }
|
||||
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec." }
|
||||
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}." }
|
||||
committer:
|
||||
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
|
||||
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
|
||||
@@ -0,0 +1,138 @@
|
||||
# @uncaged/cli-workflow
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [f74b482]
|
||||
- Updated dependencies [f74b482]
|
||||
- @uncaged/workflow-util@0.5.0-alpha.4
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.4
|
||||
- @uncaged/workflow-execute@0.5.0-alpha.4
|
||||
- @uncaged/workflow-gateway@0.5.0-alpha.4
|
||||
- @uncaged/workflow-register@0.5.0-alpha.4
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.3
|
||||
- @uncaged/workflow-execute@0.5.0-alpha.3
|
||||
- @uncaged/workflow-gateway@0.5.0-alpha.3
|
||||
- @uncaged/workflow-register@0.5.0-alpha.3
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||
- @uncaged/workflow-util@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.2
|
||||
- @uncaged/workflow-execute@0.5.0-alpha.2
|
||||
- @uncaged/workflow-gateway@0.5.0-alpha.2
|
||||
- @uncaged/workflow-register@0.5.0-alpha.2
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||
- @uncaged/workflow-util@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.1
|
||||
- @uncaged/workflow-execute@0.5.0-alpha.1
|
||||
- @uncaged/workflow-gateway@0.5.0-alpha.1
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||
- @uncaged/workflow-register@0.5.0-alpha.1
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||
- @uncaged/workflow-util@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.0
|
||||
- @uncaged/workflow-execute@0.5.0-alpha.0
|
||||
- @uncaged/workflow-register@0.5.0-alpha.0
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||
- @uncaged/workflow-util@0.5.0-alpha.0
|
||||
- @uncaged/workflow-gateway@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.5
|
||||
- @uncaged/workflow-cas@0.4.5
|
||||
- @uncaged/workflow-execute@0.4.5
|
||||
- @uncaged/workflow-gateway@0.4.5
|
||||
- @uncaged/workflow-register@0.4.5
|
||||
- @uncaged/workflow-runtime@0.4.5
|
||||
- @uncaged/workflow-util@0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.4
|
||||
- @uncaged/workflow-cas@0.4.4
|
||||
- @uncaged/workflow-execute@0.4.4
|
||||
- @uncaged/workflow-gateway@0.4.4
|
||||
- @uncaged/workflow-register@0.4.4
|
||||
- @uncaged/workflow-runtime@0.4.4
|
||||
- @uncaged/workflow-util@0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-cas@0.4.3
|
||||
- @uncaged/workflow-execute@0.4.3
|
||||
- @uncaged/workflow-gateway@0.4.3
|
||||
- @uncaged/workflow-protocol@0.4.3
|
||||
- @uncaged/workflow-register@0.4.3
|
||||
- @uncaged/workflow-runtime@0.4.3
|
||||
- @uncaged/workflow-util@0.4.3
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-cas@0.4.2
|
||||
- @uncaged/workflow-execute@0.4.2
|
||||
- @uncaged/workflow-gateway@0.4.2
|
||||
- @uncaged/workflow-protocol@0.4.2
|
||||
- @uncaged/workflow-register@0.4.2
|
||||
- @uncaged/workflow-runtime@0.4.2
|
||||
- @uncaged/workflow-util@0.4.2
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fix package exports for published packages and adopt changesets for version management.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-cas@0.4.0
|
||||
- @uncaged/workflow-execute@0.4.0
|
||||
- @uncaged/workflow-gateway@0.4.0
|
||||
- @uncaged/workflow-protocol@0.4.0
|
||||
- @uncaged/workflow-register@0.4.0
|
||||
- @uncaged/workflow-runtime@0.4.0
|
||||
- @uncaged/workflow-util@0.4.0
|
||||
@@ -0,0 +1,76 @@
|
||||
# @uncaged/cli-workflow
|
||||
|
||||
Command-line interface for the Uncaged workflow engine (`uncaged-workflow`).
|
||||
|
||||
The CLI reads and writes the workflow registry, starts and inspects threads, manages CAS blobs, and prints agent-oriented reference docs via `skill`. It uses the same storage layout as `@uncaged/workflow` (default `~/.uncaged/workflow`).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/cli-workflow
|
||||
```
|
||||
|
||||
In this monorepo: `"@uncaged/cli-workflow": "workspace:*"`. Depends on `"@uncaged/workflow": "workspace:*"`.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
uncaged-workflow workflow list
|
||||
uncaged-workflow run <name> --prompt "Your task"
|
||||
uncaged-workflow thread show <id>
|
||||
uncaged-workflow skill
|
||||
```
|
||||
|
||||
Invoking the CLI with no command (or from this repo: `bun packages/cli-workflow/src/cli.ts`) prints:
|
||||
|
||||
```
|
||||
uncaged-workflow — workflow engine CLI
|
||||
|
||||
Workflow registry:
|
||||
workflow add <name> <file.esm.js> [--types <path>] Register a workflow bundle in the registry
|
||||
workflow list List all registered workflows
|
||||
workflow show <name> Show details of a registered workflow
|
||||
workflow rm <name> Remove a workflow from the registry
|
||||
workflow history <name> Show version history of a workflow
|
||||
workflow rollback <name> [hash] Rollback a workflow to a previous version
|
||||
|
||||
Thread execution:
|
||||
thread run <name> [--prompt <text>] [--max-rounds N] Start a new thread executing a workflow
|
||||
thread list [name] List threads, optionally filtered by workflow name
|
||||
thread show <id> Show thread details and state
|
||||
thread rm <id> Remove a thread
|
||||
thread fork <thread-id> [--from-role <role>] Fork a thread, optionally from a specific role
|
||||
thread ps List running threads
|
||||
thread kill <thread-id> Kill a running thread
|
||||
thread live <thread-id> | --latest [--debug] [--role <name>] Attach to a thread and stream output live
|
||||
thread pause <thread-id> Pause a running thread
|
||||
thread resume <thread-id> Resume a paused thread
|
||||
|
||||
Content-addressable storage:
|
||||
cas get <hash> Retrieve content by hash from CAS
|
||||
cas put <content> Store content in CAS, prints hash
|
||||
cas list List all hashes in CAS
|
||||
cas rm <hash> Remove a CAS entry by hash
|
||||
cas gc Garbage-collect unreferenced CAS entries
|
||||
|
||||
Development:
|
||||
init workspace <name> Initialize a new workflow workspace
|
||||
init template <name> Initialize a new workflow template
|
||||
|
||||
Shortcuts:
|
||||
run <name> [...] → thread run
|
||||
live <id> [...] → thread live
|
||||
|
||||
Reference:
|
||||
skill [topic] Agent-consumable docs (cli, develop, author)
|
||||
|
||||
Use <command> --help for subcommand details.
|
||||
|
||||
Environment variables:
|
||||
WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)
|
||||
UNCAGED_WORKFLOW_STORAGE_ROOT Internal override (takes priority over WORKFLOW_STORAGE_ROOT)
|
||||
```
|
||||
|
||||
## API overview
|
||||
|
||||
This package is bin-only; programmatic use is via `@uncaged/workflow`. Entry: `src/cli.ts` → `runCli` in `src/cli-dispatch.js`.
|
||||
+30
-38
@@ -2,14 +2,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
createContentMerkleNode,
|
||||
getGlobalCasDir,
|
||||
getRegisteredWorkflow,
|
||||
readWorkflowRegistry,
|
||||
serializeMerkleNode,
|
||||
} from "@uncaged/workflow";
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
|
||||
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js";
|
||||
import {
|
||||
cmdAdd,
|
||||
@@ -22,10 +17,7 @@ import {
|
||||
} from "../src/commands/workflow/index.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
|
||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} };
|
||||
`;
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {}, graph: { edges: [] } };
|
||||
`;
|
||||
|
||||
function casStoredForm(raw: string): string {
|
||||
@@ -57,12 +49,12 @@ describe("cli workflow commands", () => {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}import fs from "node:fs";
|
||||
`${fixtureDescriptor}import fs from "node:fs";
|
||||
|
||||
export const run = async function* (input, options) {
|
||||
fs.existsSync(".");
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, input.prompt);
|
||||
const h = await cas.put(input.prompt);
|
||||
yield { role: "noop", contentHash: h, meta: { done: true }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
}
|
||||
@@ -158,11 +150,11 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
|
||||
schema: { type: "object", properties: { greeting: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
graph: { edges: [] },
|
||||
};
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, input.prompt);
|
||||
const h = await cas.put( input.prompt);
|
||||
yield { role: "greeter", contentHash: h, meta: { greeting: "hi" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "ok" };
|
||||
};
|
||||
@@ -201,9 +193,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -232,9 +224,9 @@ export const run = async function* (input, options) {
|
||||
const dtsPath = join(bundleDir, "types.d.ts");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -265,9 +257,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -288,16 +280,16 @@ export const run = async function* (input, options) {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v1");
|
||||
const h = await cas.put( "v1");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v1" };
|
||||
}
|
||||
`;
|
||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v2");
|
||||
const h = await cas.put( "v2");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v2" };
|
||||
}
|
||||
@@ -330,16 +322,16 @@ export const run = async function* (input, options) {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v1");
|
||||
const h = await cas.put( "v1");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v1" };
|
||||
}
|
||||
`;
|
||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v2");
|
||||
const h = await cas.put( "v2");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v2" };
|
||||
}
|
||||
@@ -382,9 +374,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -395,9 +387,9 @@ export const run = async function* (input, options) {
|
||||
expect(add1.ok).toBe(true);
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "y");
|
||||
const h = await cas.put( "y");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "y" };
|
||||
}
|
||||
@@ -450,9 +442,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -467,9 +459,9 @@ export const run = async function* (input, options) {
|
||||
const hash1 = add1.value.hash;
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "y");
|
||||
const h = await cas.put( "y");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "y" };
|
||||
}
|
||||
+5
-5
@@ -1,15 +1,15 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow";
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
|
||||
|
||||
import { createApp } from "../src/commands/serve/app.js";
|
||||
import { createApp } from "../src/commands/connect/app.js";
|
||||
|
||||
function casStoredForm(raw: string): string {
|
||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||
}
|
||||
|
||||
function buildApp(storageRoot: string) {
|
||||
const app = createApp(storageRoot);
|
||||
const app = createApp(storageRoot, null);
|
||||
return {
|
||||
fetch: (path: string, init?: RequestInit) =>
|
||||
app.fetch(new Request(`http://localhost${path}`, init)),
|
||||
@@ -115,7 +115,7 @@ describe("serve error handling", () => {
|
||||
});
|
||||
|
||||
test("global error handler returns 500 with JSON", async () => {
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent", null);
|
||||
app.get("/test-error", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
@@ -128,7 +128,7 @@ describe("serve error handling", () => {
|
||||
|
||||
describe("serve security", () => {
|
||||
test("CORS headers present on responses", async () => {
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent", null);
|
||||
const res2 = await app.fetch(
|
||||
new Request("http://localhost/healthz", {
|
||||
headers: { Origin: "http://localhost:5173" },
|
||||
+75
-73
@@ -1,66 +1,49 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, getContentMerklePayload, getGlobalCasDir } from "@uncaged/workflow";
|
||||
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import { FORK_BRANCH_ROLE, walkStateFramesNewestFirst } from "@uncaged/workflow-execute";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
import { cmdFork, cmdRun } from "../src/commands/thread/index.js";
|
||||
import { cmdAdd } from "../src/commands/workflow/index.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
import { resolveThreadRecord } from "../src/thread-scan.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||
|
||||
/** Three-role workflow that respects `input.steps` for fork/resume. */
|
||||
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
|
||||
export const descriptor = {
|
||||
const threeRoleBundleSource = `export const descriptor = {
|
||||
description: "fork-cli",
|
||||
roles: {
|
||||
planner: { description: "planner", schema: {} },
|
||||
coder: { description: "coder", schema: {} },
|
||||
reviewer: { description: "reviewer", schema: {} },
|
||||
},
|
||||
graph: { edges: [] },
|
||||
};
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
const has = (r) => input.steps.some((s) => s.role === r);
|
||||
if (!has("planner")) {
|
||||
const h = await putContentMerkleNode(cas, "p1");
|
||||
const h = await cas.put( "p1");
|
||||
yield { role: "planner", contentHash: h, meta: { k: "planner" }, refs: [h] };
|
||||
}
|
||||
if (!has("coder")) {
|
||||
const h = await putContentMerkleNode(cas, "c1");
|
||||
const h = await cas.put( "c1");
|
||||
yield { role: "coder", contentHash: h, meta: { k: "coder" }, refs: [h] };
|
||||
}
|
||||
if (!has("reviewer")) {
|
||||
const body = "rev-" + String(input.steps.length);
|
||||
const h = await putContentMerkleNode(cas, body);
|
||||
const h = await cas.put( body);
|
||||
yield { role: "reviewer", contentHash: h, meta: { k: "reviewer" }, refs: [h] };
|
||||
}
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
async function countDataJsonlLines(dataPath: string): Promise<number> {
|
||||
try {
|
||||
const text = await readFile(dataPath, "utf8");
|
||||
return text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "").length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilMinDataLines(dataPath: string, minLines: number): Promise<void> {
|
||||
for (let attempt = 0; attempt < 120; attempt++) {
|
||||
if ((await countDataJsonlLines(dataPath)) >= minLines) {
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilRunningAbsent(runningPath: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 120; attempt++) {
|
||||
if (!(await pathExists(runningPath))) {
|
||||
@@ -70,6 +53,41 @@ async function waitUntilRunningAbsent(runningPath: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilThreadCompletes(storageRoot: string, threadId: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 120; attempt++) {
|
||||
const row = await resolveThreadRecord(storageRoot, threadId);
|
||||
if (row?.source === "history") {
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
}
|
||||
|
||||
async function listMeaningfulRoleContents(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Array<{ role: string; content: string }>> {
|
||||
const row = await resolveThreadRecord(storageRoot, threadId);
|
||||
if (row === null) {
|
||||
return [];
|
||||
}
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const frames = await walkStateFramesNewestFirst(cas, row.head);
|
||||
const chronological = [...frames].reverse();
|
||||
const out: Array<{ role: string; content: string }> = [];
|
||||
for (const fr of chronological) {
|
||||
if (fr.payload.role === END || fr.payload.role === FORK_BRANCH_ROLE) {
|
||||
continue;
|
||||
}
|
||||
const content = await getContentMerklePayload(cas, fr.payload.content);
|
||||
out.push({
|
||||
role: fr.payload.role,
|
||||
content: content ?? "",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("cli fork", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
@@ -109,10 +127,12 @@ describe("cli fork", () => {
|
||||
return;
|
||||
}
|
||||
const sourceId = ran.value.threadId;
|
||||
const sourceData = join(storageRoot, "logs", hash, `${sourceId}.data.jsonl`);
|
||||
const sourceRunning = join(storageRoot, "logs", hash, `${sourceId}.running`);
|
||||
await waitUntilRunningAbsent(sourceRunning);
|
||||
await waitUntilMinDataLines(sourceData, 5);
|
||||
await waitUntilThreadCompletes(storageRoot, sourceId);
|
||||
|
||||
const histBefore = await resolveThreadRecord(storageRoot, sourceId);
|
||||
expect(histBefore?.source).toBe("history");
|
||||
|
||||
const forked = await cmdFork(storageRoot, sourceId, "planner");
|
||||
expect(forked.ok).toBe(true);
|
||||
@@ -120,25 +140,18 @@ describe("cli fork", () => {
|
||||
return;
|
||||
}
|
||||
const newId = forked.value.threadId;
|
||||
const newData = join(storageRoot, "logs", hash, `${newId}.data.jsonl`);
|
||||
const newRunning = join(storageRoot, "logs", hash, `${newId}.running`);
|
||||
await waitUntilRunningAbsent(newRunning);
|
||||
await waitUntilMinDataLines(newData, 5);
|
||||
await waitUntilThreadCompletes(storageRoot, newId);
|
||||
|
||||
const text = await readFile(newData, "utf8");
|
||||
const lines = text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(lines.length).toBe(5);
|
||||
const start = JSON.parse(lines[0] ?? "{}") as Record<string, unknown>;
|
||||
expect(start.threadId).toBe(newId);
|
||||
expect(start.forkFrom).toEqual({ threadId: sourceId });
|
||||
const forkHist = await resolveThreadRecord(storageRoot, newId);
|
||||
expect(forkHist?.source).toBe("history");
|
||||
expect(forkHist?.start).toBe(histBefore?.start);
|
||||
|
||||
const lastRoleLine = JSON.parse(lines[lines.length - 2] ?? "{}") as Record<string, unknown>;
|
||||
expect(lastRoleLine.role).toBe("reviewer");
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
expect(await getContentMerklePayload(cas, String(lastRoleLine.contentHash))).toBe("rev-1");
|
||||
const steps = await listMeaningfulRoleContents(storageRoot, newId);
|
||||
const tail = steps[steps.length - 1];
|
||||
expect(tail?.role).toBe("reviewer");
|
||||
expect(tail?.content).toBe("rev-1");
|
||||
});
|
||||
|
||||
test("fork without --from-role retries last role", async () => {
|
||||
@@ -160,10 +173,8 @@ describe("cli fork", () => {
|
||||
return;
|
||||
}
|
||||
const sourceId = ran.value.threadId;
|
||||
const sourceData = join(storageRoot, "logs", hash, `${sourceId}.data.jsonl`);
|
||||
const sourceRunning = join(storageRoot, "logs", hash, `${sourceId}.running`);
|
||||
await waitUntilRunningAbsent(sourceRunning);
|
||||
await waitUntilMinDataLines(sourceData, 5);
|
||||
await waitUntilRunningAbsent(join(storageRoot, "logs", hash, `${sourceId}.running`));
|
||||
await waitUntilThreadCompletes(storageRoot, sourceId);
|
||||
|
||||
const forked = await cmdFork(storageRoot, sourceId, null);
|
||||
expect(forked.ok).toBe(true);
|
||||
@@ -171,26 +182,17 @@ describe("cli fork", () => {
|
||||
return;
|
||||
}
|
||||
const newId = forked.value.threadId;
|
||||
const newData = join(storageRoot, "logs", hash, `${newId}.data.jsonl`);
|
||||
const newRunning = join(storageRoot, "logs", hash, `${newId}.running`);
|
||||
await waitUntilRunningAbsent(newRunning);
|
||||
await waitUntilMinDataLines(newData, 5);
|
||||
await waitUntilRunningAbsent(join(storageRoot, "logs", hash, `${newId}.running`));
|
||||
await waitUntilThreadCompletes(storageRoot, newId);
|
||||
|
||||
const text = await readFile(newData, "utf8");
|
||||
const lines = text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(lines.length).toBe(5);
|
||||
|
||||
const replayCoder = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||
expect(replayCoder.role).toBe("coder");
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
expect(await getContentMerklePayload(cas, String(replayCoder.contentHash))).toBe("c1");
|
||||
|
||||
const lastRoleLine = JSON.parse(lines[lines.length - 2] ?? "{}") as Record<string, unknown>;
|
||||
expect(lastRoleLine.role).toBe("reviewer");
|
||||
expect(await getContentMerklePayload(cas, String(lastRoleLine.contentHash))).toBe("rev-2");
|
||||
const steps = await listMeaningfulRoleContents(storageRoot, newId);
|
||||
expect(steps.length).toBeGreaterThanOrEqual(3);
|
||||
const coderReplay = steps[steps.length - 2];
|
||||
expect(coderReplay?.role).toBe("coder");
|
||||
expect(coderReplay?.content).toBe("c1");
|
||||
const tail = steps[steps.length - 1];
|
||||
expect(tail?.role).toBe("reviewer");
|
||||
expect(tail?.content).toBe("rev-2");
|
||||
});
|
||||
|
||||
test("fork rejects unknown role with available names", async () => {
|
||||
@@ -211,10 +213,10 @@ describe("cli fork", () => {
|
||||
return;
|
||||
}
|
||||
const sourceId = ran.value.threadId;
|
||||
const sourceData = join(storageRoot, "logs", added.value.hash, `${sourceId}.data.jsonl`);
|
||||
const sourceRunning = join(storageRoot, "logs", added.value.hash, `${sourceId}.running`);
|
||||
await waitUntilRunningAbsent(sourceRunning);
|
||||
await waitUntilMinDataLines(sourceData, 5);
|
||||
await waitUntilRunningAbsent(
|
||||
join(storageRoot, "logs", added.value.hash, `${sourceId}.running`),
|
||||
);
|
||||
await waitUntilThreadCompletes(storageRoot, sourceId);
|
||||
|
||||
const bad = await cmdFork(storageRoot, sourceId, "ghost-role");
|
||||
expect(bad.ok).toBe(false);
|
||||
+61
-67
@@ -1,48 +1,17 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
createCasStore,
|
||||
garbageCollectCas,
|
||||
getGlobalCasDir,
|
||||
putContentMerkleNode,
|
||||
} from "@uncaged/workflow";
|
||||
import { createCasStore, putStartNode } from "@uncaged/workflow-cas";
|
||||
import { garbageCollectCas, getBundleDir, upsertThreadEntry } from "@uncaged/workflow-execute";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { cmdThreadRemove } from "../src/commands/thread/index.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
|
||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
|
||||
async function writeDemoDataJsonl(params: {
|
||||
path: string;
|
||||
threadId: string;
|
||||
bundleHash: string;
|
||||
cas: ReturnType<typeof createCasStore>;
|
||||
activeHash: string;
|
||||
}): Promise<void> {
|
||||
const bodyHash = await putContentMerkleNode(params.cas, "p");
|
||||
const text = [
|
||||
JSON.stringify({
|
||||
name: "demo",
|
||||
hash: params.bundleHash,
|
||||
threadId: params.threadId,
|
||||
parameters: { prompt: "hi", options: { maxRounds: 5 } },
|
||||
timestamp: 100,
|
||||
}),
|
||||
JSON.stringify({
|
||||
role: "planner",
|
||||
contentHash: bodyHash,
|
||||
meta: {},
|
||||
refs: [params.activeHash, bodyHash],
|
||||
timestamp: 101,
|
||||
}),
|
||||
"",
|
||||
].join("\n");
|
||||
await writeFile(params.path, text, "utf8");
|
||||
}
|
||||
|
||||
describe("gc cli and garbageCollectCas", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
@@ -62,22 +31,30 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("garbageCollectCas keeps CAS entries referenced by thread refs", async () => {
|
||||
test("garbageCollectCas keeps CAS entries reachable from threads.json roots", async () => {
|
||||
const bundleHash = "C9NMV6V2TQT81";
|
||||
const threadId = "01AAA1111111111111111111";
|
||||
const logsDir = join(storageRoot, "logs", bundleHash);
|
||||
await mkdir(logsDir, { recursive: true });
|
||||
const bundleDir = getBundleDir(storageRoot, bundleHash);
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const activeHash = await cas.put("active-blob");
|
||||
const orphanHash = await cas.put("orphan-blob");
|
||||
|
||||
await writeDemoDataJsonl({
|
||||
path: join(logsDir, `${threadId}.data.jsonl`),
|
||||
threadId,
|
||||
bundleHash,
|
||||
const promptHash = await cas.put("prompt-text");
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
activeHash,
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
|
||||
await upsertThreadEntry(bundleDir, threadId, {
|
||||
head: startHash,
|
||||
start: startHash,
|
||||
updatedAt: 100,
|
||||
});
|
||||
|
||||
const gc = await garbageCollectCas(storageRoot);
|
||||
@@ -85,12 +62,12 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
if (!gc.ok) {
|
||||
return;
|
||||
}
|
||||
expect(gc.value.scannedThreads).toBe(1);
|
||||
expect(gc.value.activeRefs).toBe(2);
|
||||
expect(gc.value.scannedThreads).toBe(2);
|
||||
expect(gc.value.deletedEntries).toBe(1);
|
||||
expect(gc.value.deletedHashes).toEqual([orphanHash]);
|
||||
|
||||
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${activeHash}.txt`))).toBe(true);
|
||||
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${promptHash}.txt`))).toBe(true);
|
||||
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${startHash}.txt`))).toBe(true);
|
||||
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -113,19 +90,27 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
test("cli gc prints stats", async () => {
|
||||
const bundleHash = "C9NMV6V2TQT81";
|
||||
const threadId = "01BBB2222222222222222222";
|
||||
const logsDir = join(storageRoot, "logs", bundleHash);
|
||||
await mkdir(logsDir, { recursive: true });
|
||||
const bundleDir = getBundleDir(storageRoot, bundleHash);
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const activeHash = await cas.put("keep-me");
|
||||
const promptHash = await cas.put("prompt-text");
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
await cas.put("drop-me");
|
||||
|
||||
await writeDemoDataJsonl({
|
||||
path: join(logsDir, `${threadId}.data.jsonl`),
|
||||
threadId,
|
||||
bundleHash,
|
||||
cas,
|
||||
activeHash,
|
||||
await upsertThreadEntry(bundleDir, threadId, {
|
||||
head: startHash,
|
||||
start: startHash,
|
||||
updatedAt: 100,
|
||||
});
|
||||
|
||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||
@@ -134,23 +119,32 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(proc.status).toBe(0);
|
||||
expect(String(proc.stdout).trim()).toBe("scanned 1 threads, 2 active refs, deleted 1 entries");
|
||||
expect(String(proc.stdout).trim()).toBe("scanned 2 threads, 2 active refs, deleted 1 entries");
|
||||
});
|
||||
|
||||
test("thread rm triggers gc so unreferenced CAS is removed", async () => {
|
||||
const bundleHash = "C9NMV6V2TQT81";
|
||||
const threadId = "01CCC3333333333333333333";
|
||||
const logsDir = join(storageRoot, "logs", bundleHash);
|
||||
await mkdir(logsDir, { recursive: true });
|
||||
const bundleDir = getBundleDir(storageRoot, bundleHash);
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const activeHash = await cas.put("pinned-by-ref");
|
||||
await writeDemoDataJsonl({
|
||||
path: join(logsDir, `${threadId}.data.jsonl`),
|
||||
threadId,
|
||||
bundleHash,
|
||||
const promptHash = await cas.put("prompt-text");
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
activeHash,
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
|
||||
await upsertThreadEntry(bundleDir, threadId, {
|
||||
head: startHash,
|
||||
start: startHash,
|
||||
updatedAt: 100,
|
||||
});
|
||||
|
||||
const orphanHash = await cas.put("orphan-after-rm");
|
||||
@@ -160,6 +154,6 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
expect(removed.ok).toBe(true);
|
||||
|
||||
expect(await pathExists(orphanPath)).toBe(false);
|
||||
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${activeHash}.txt`))).toBe(false);
|
||||
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${promptHash}.txt`))).toBe(false);
|
||||
});
|
||||
});
|
||||
+8
@@ -58,6 +58,11 @@ describe("--help flag on groups", () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["init", "--help"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("setup --help returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["setup", "--help"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSkillTopics", () => {
|
||||
@@ -90,6 +95,8 @@ describe("formatCliUsage", () => {
|
||||
expect(u).toContain("Thread execution:");
|
||||
expect(u).toContain("Content-addressable storage:");
|
||||
expect(u).toContain("Development:");
|
||||
expect(u).toContain("Configuration:");
|
||||
expect(u).toContain("setup [--provider <name>]");
|
||||
expect(u).toContain("Shortcuts:");
|
||||
expect(u).toContain("Reference:");
|
||||
expect(u).toContain("skill [topic]");
|
||||
@@ -128,6 +135,7 @@ describe("formatSkillTopic('cli')", () => {
|
||||
expect(doc).toContain("### thread");
|
||||
expect(doc).toContain("### cas");
|
||||
expect(doc).toContain("### init");
|
||||
expect(doc).toContain("### setup");
|
||||
expect(doc).toContain("### Top-level shortcuts");
|
||||
});
|
||||
|
||||
+1
-1
@@ -50,7 +50,6 @@ describe("init template", () => {
|
||||
dependencies: Record<string, string>;
|
||||
};
|
||||
expect(pkg.type).toBe("module");
|
||||
expect(pkg.dependencies["@uncaged/workflow"]).toBeDefined();
|
||||
expect(pkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined();
|
||||
expect(pkg.dependencies.zod).toBeDefined();
|
||||
expect(pkg.name).toContain("review-pr");
|
||||
@@ -65,6 +64,7 @@ describe("init template", () => {
|
||||
|
||||
const moder = await readFile(join(tdir, "src", "moderator.ts"), "utf8");
|
||||
expect(moder).not.toContain("export default");
|
||||
expect(moder).toContain("ModeratorTable");
|
||||
});
|
||||
|
||||
test("finds workspace walking up from nested cwd", async () => {
|
||||
+19
-6
@@ -38,15 +38,23 @@ describe("init workspace", () => {
|
||||
|
||||
const rootPkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as {
|
||||
workspaces: string[];
|
||||
scripts: { bundle: string };
|
||||
};
|
||||
expect(rootPkg.workspaces).toEqual(["templates/*", "workflows"]);
|
||||
expect(rootPkg.scripts.bundle).toBe("bun run scripts/bundle.ts");
|
||||
|
||||
expect(await pathExists(join(root, "scripts", "bundle.ts"))).toBe(true);
|
||||
const bundleSrc = await readFile(join(root, "scripts", "bundle.ts"), "utf8");
|
||||
expect(bundleSrc).toContain("Bun.build");
|
||||
expect(bundleSrc).toContain("-entry.ts");
|
||||
expect(bundleSrc).toContain("distDir");
|
||||
|
||||
const wfPkg = JSON.parse(await readFile(join(root, "workflows", "package.json"), "utf8")) as {
|
||||
type: string;
|
||||
dependencies: Record<string, string>;
|
||||
};
|
||||
expect(wfPkg.type).toBe("module");
|
||||
expect(wfPkg.dependencies["@uncaged/workflow"]).toBeDefined();
|
||||
expect(wfPkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined();
|
||||
expect(wfPkg.dependencies.zod).toBeDefined();
|
||||
|
||||
const tsconfig = JSON.parse(await readFile(join(root, "tsconfig.json"), "utf8")) as {
|
||||
@@ -82,8 +90,8 @@ describe("init workspace", () => {
|
||||
for (const term of [
|
||||
"RoleDefinition",
|
||||
"WorkflowDefinition",
|
||||
"Moderator",
|
||||
"AgentFn",
|
||||
"ModeratorTable",
|
||||
"AdapterFn",
|
||||
"ExtractFn",
|
||||
"RoleMeta",
|
||||
]) {
|
||||
@@ -117,9 +125,6 @@ describe("init workspace", () => {
|
||||
});
|
||||
|
||||
test("errors on invalid workspace name", async () => {
|
||||
const slash = await cmdInitWorkspace(parent, "a/b");
|
||||
expect(slash.ok).toBe(false);
|
||||
|
||||
const dots = await cmdInitWorkspace(parent, "..");
|
||||
expect(dots.ok).toBe(false);
|
||||
|
||||
@@ -127,6 +132,14 @@ describe("init workspace", () => {
|
||||
expect(empty.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts nested path as workspace name", async () => {
|
||||
const nested = await cmdInitWorkspace(parent, "a/b");
|
||||
expect(nested.ok).toBe(true);
|
||||
if (nested.ok) {
|
||||
expect(nested.value.rootPath).toContain("a/b");
|
||||
}
|
||||
});
|
||||
|
||||
test("usage lists init subcommands", () => {
|
||||
const u = formatCliUsage();
|
||||
expect(u).toContain("init workspace <name>");
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
formatLiveDebugLine,
|
||||
formatLiveTimeLabel,
|
||||
LIVE_CONTENT_MAX_LINES,
|
||||
type LiveRoleRow,
|
||||
renderLiveRoleStepLines,
|
||||
} from "../src/commands/thread/index.js";
|
||||
import { parseLiveArgv } from "../src/live-argv.js";
|
||||
|
||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
|
||||
describe("live helpers", () => {
|
||||
test("formatLiveTimeLabel pads HH:MM:SS", () => {
|
||||
const label = formatLiveTimeLabel(new Date("2024-06-01T09:08:07.000Z").getTime());
|
||||
expect(label).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
test("formatLiveDebugLine flattens newlines in message", () => {
|
||||
const line = formatLiveDebugLine(0, "TAG1", "a\nb");
|
||||
expect(line).toContain("[TAG1]");
|
||||
expect(line).toContain("a b");
|
||||
expect(line).not.toContain("\n");
|
||||
});
|
||||
|
||||
test("renderLiveRoleStepLines truncates content to LIVE_CONTENT_MAX_LINES", () => {
|
||||
const lines = Array.from({ length: LIVE_CONTENT_MAX_LINES + 3 }, (_, i) => `L${i + 1}`);
|
||||
const row: LiveRoleRow = {
|
||||
role: "r",
|
||||
content: lines.join("\n"),
|
||||
meta: { k: "v" },
|
||||
timestamp: 0,
|
||||
};
|
||||
const out = renderLiveRoleStepLines(row, "r");
|
||||
const body = out.filter((l) => l.startsWith(" L"));
|
||||
expect(body.length).toBe(LIVE_CONTENT_MAX_LINES);
|
||||
expect(out.some((l) => l.includes("more line"))).toBe(true);
|
||||
expect(out.some((l) => l.startsWith(" meta: "))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLiveArgv", () => {
|
||||
test("parses thread id and flags in any order", () => {
|
||||
const a = parseLiveArgv(["01ABC", "--debug", "--role", "planner"]);
|
||||
expect(a.ok).toBe(true);
|
||||
if (a.ok) {
|
||||
expect(a.value.threadId).toBe("01ABC");
|
||||
expect(a.value.latest).toBe(false);
|
||||
expect(a.value.debug).toBe(true);
|
||||
expect(a.value.role).toBe("planner");
|
||||
}
|
||||
const b = parseLiveArgv(["--latest", "--role", "x"]);
|
||||
expect(b.ok).toBe(true);
|
||||
if (b.ok) {
|
||||
expect(b.value.latest).toBe(true);
|
||||
expect(b.value.threadId).toBe(null);
|
||||
expect(b.value.role).toBe("x");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects --latest with thread id", () => {
|
||||
const r = parseLiveArgv(["--latest", "01ABC"]);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("live CLI", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-live-"));
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevEnv === undefined) {
|
||||
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
} else {
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
|
||||
}
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("unknown thread id exits 1", () => {
|
||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||
const r = spawnSync(process.execPath, [cliEntryPath, "live", "01UNKNOWNXXXXXXXXXXXXXXXXX"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(r.status).toBe(1);
|
||||
expect(String(r.stderr ?? "")).toContain("thread not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("live --latest with empty storage", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let emptyRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
emptyRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-live-empty-"));
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = emptyRoot;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevEnv === undefined) {
|
||||
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
} else {
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
|
||||
}
|
||||
await rm(emptyRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("exits 1 when no threads exist", () => {
|
||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: emptyRoot };
|
||||
const r = spawnSync(process.execPath, [cliEntryPath, "live", "--latest"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(r.status).toBe(1);
|
||||
expect(String(r.stderr ?? "")).toContain("no threads");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { readWorkflowRegistry } from "@uncaged/workflow-register";
|
||||
|
||||
import { runCli } from "../src/cli-dispatch.js";
|
||||
import { cmdSetup } from "../src/commands/setup/index.js";
|
||||
|
||||
describe("setup command (CLI mode)", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-setup-"));
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevEnv === undefined) {
|
||||
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
} else {
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
|
||||
}
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("writes workflow.yaml with provider, models.default, and depth defaults", async () => {
|
||||
const r = await cmdSetup(storageRoot, {
|
||||
provider: "dashscope",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "sk-test123",
|
||||
defaultModel: "dashscope/qwen-plus",
|
||||
initWorkspaceName: null,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
expect(reg.ok).toBe(true);
|
||||
if (!reg.ok) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.config).not.toBeNull();
|
||||
if (reg.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.config.providers.dashscope).toEqual({
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "sk-test123",
|
||||
});
|
||||
expect(reg.value.config.models.default).toBe("dashscope/qwen-plus");
|
||||
expect(reg.value.config.maxDepth).toBe(3);
|
||||
expect(reg.value.config.supervisorInterval).toBe(3);
|
||||
|
||||
const raw = await readFile(join(storageRoot, "workflow.yaml"), "utf8");
|
||||
expect(raw).toContain("dashscope");
|
||||
expect(raw).toContain("qwen-plus");
|
||||
});
|
||||
|
||||
test("idempotent: second run updates apiKey and preserves workflows", async () => {
|
||||
const initialYaml = `config:
|
||||
maxDepth: 7
|
||||
supervisorInterval: 2
|
||||
providers:
|
||||
dashscope:
|
||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
apiKey: sk-old
|
||||
models:
|
||||
default: dashscope/qwen-plus
|
||||
workflows:
|
||||
keep-me:
|
||||
hash: "0000000000000"
|
||||
timestamp: 1
|
||||
history: []
|
||||
`;
|
||||
await writeFile(join(storageRoot, "workflow.yaml"), initialYaml, "utf8");
|
||||
|
||||
const r2 = await cmdSetup(storageRoot, {
|
||||
provider: "dashscope",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "sk-newkey",
|
||||
defaultModel: "dashscope/qwen-plus",
|
||||
initWorkspaceName: null,
|
||||
});
|
||||
expect(r2.ok).toBe(true);
|
||||
if (!r2.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
expect(reg.ok).toBe(true);
|
||||
if (!reg.ok || reg.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.config.providers.dashscope.apiKey).toBe("sk-newkey");
|
||||
expect(reg.value.config.maxDepth).toBe(7);
|
||||
expect(reg.value.config.supervisorInterval).toBe(2);
|
||||
expect(reg.value.workflows["keep-me"]).toBeDefined();
|
||||
if (reg.value.workflows["keep-me"] === undefined) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.workflows["keep-me"].hash).toBe("0000000000000");
|
||||
});
|
||||
|
||||
test("runCli setup dispatches with flags and exits 0", async () => {
|
||||
const code = await runCli(storageRoot, [
|
||||
"setup",
|
||||
"--provider",
|
||||
"openai",
|
||||
"--base-url",
|
||||
"https://api.openai.com/v1",
|
||||
"--api-key",
|
||||
"sk-test",
|
||||
"--default-model",
|
||||
"openai/gpt-4o",
|
||||
]);
|
||||
expect(code).toBe(0);
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
expect(reg.ok).toBe(true);
|
||||
if (!reg.ok || reg.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.config.providers.openai.apiKey).toBe("sk-test");
|
||||
expect(reg.value.config.models.default).toBe("openai/gpt-4o");
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow";
|
||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
|
||||
import { resolveWorkflowStorageRoot } from "../src/storage-env.js";
|
||||
|
||||
describe("resolveWorkflowStorageRoot", () => {
|
||||
+68
-67
@@ -1,10 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow";
|
||||
import { getBundleDir, readThreadsIndex } from "@uncaged/workflow-execute";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { cmdCasPut } from "../src/commands/cas/index.js";
|
||||
import {
|
||||
cmdKill,
|
||||
@@ -18,12 +19,10 @@ import {
|
||||
} from "../src/commands/thread/index.js";
|
||||
import { cmdAdd } from "../src/commands/workflow/index.js";
|
||||
import { pathExists, readTextFileIfExists } from "../src/fs-utils.js";
|
||||
import { resolveThreadRecord } from "../src/thread-scan.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
`;
|
||||
|
||||
const threadFixtureDescriptor = `export const descriptor = {
|
||||
description: "thread-cli",
|
||||
roles: {
|
||||
@@ -34,29 +33,28 @@ const threadFixtureDescriptor = `export const descriptor = {
|
||||
only: { description: "only", schema: {} },
|
||||
noop: { description: "noop", schema: {} },
|
||||
},
|
||||
graph: { edges: [] },
|
||||
};
|
||||
`;
|
||||
|
||||
const fastBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
let h = await cas.put( "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
h = await cas.put( "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const slowPlannerBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
let h = await cas.put( "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
h = await cas.put( "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
@@ -65,70 +63,54 @@ export const run = async function* (input, options) {
|
||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
|
||||
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
let h = await cas.put( "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
await new Promise((r) => setTimeout(r, 10000));
|
||||
h = await cas.put( "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const pauseResumeBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "f");
|
||||
let h = await cas.put( "f");
|
||||
yield { role: "first", contentHash: h, meta: {}, refs: [h] };
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
h = await putContentMerkleNode(cas, "s");
|
||||
h = await cas.put( "s");
|
||||
yield { role: "second", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const delayedFirstYieldBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (_input, options) {
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
async function countDataJsonlLines(dataPath: string): Promise<number> {
|
||||
try {
|
||||
const text = await readFile(dataPath, "utf8");
|
||||
return text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "").length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilMinDataLines(
|
||||
dataPath: string,
|
||||
minLines: number,
|
||||
maxAttempts: number,
|
||||
): Promise<void> {
|
||||
async function waitUntilRunningFileAbsent(runningPath: string, maxAttempts: number): Promise<void> {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if ((await countDataJsonlLines(dataPath)) >= minLines) {
|
||||
if (!(await pathExists(runningPath))) {
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilRunningFileAbsent(runningPath: string, maxAttempts: number): Promise<void> {
|
||||
async function waitUntilPredicate(
|
||||
predicate: () => Promise<boolean>,
|
||||
maxAttempts: number,
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (!(await pathExists(runningPath))) {
|
||||
if (await predicate()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
@@ -190,6 +172,9 @@ describe("cli thread commands", () => {
|
||||
}
|
||||
expect(threads.value.some((l) => l.includes(threadId))).toBe(true);
|
||||
|
||||
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
|
||||
await waitUntilRunningFileAbsent(runningPath, 120);
|
||||
|
||||
const shown = await cmdThreadShow(storageRoot, threadId);
|
||||
expect(shown.ok).toBe(true);
|
||||
if (!shown.ok) {
|
||||
@@ -197,11 +182,18 @@ describe("cli thread commands", () => {
|
||||
}
|
||||
expect(shown.value.includes('"threadId"')).toBe(true);
|
||||
|
||||
const parsed = JSON.parse(shown.value) as Record<string, unknown>;
|
||||
expect(parsed.parentState).toBeNull();
|
||||
const parsedSteps = parsed.steps as Array<Record<string, unknown>>;
|
||||
for (const step of parsedSteps) {
|
||||
expect(step).toHaveProperty("childThread");
|
||||
expect(step.childThread).toBeNull();
|
||||
}
|
||||
|
||||
const removed = await cmdThreadRemove(storageRoot, threadId);
|
||||
expect(removed.ok).toBe(true);
|
||||
|
||||
const dataPath = join(storageRoot, "logs", added.value.hash, `${threadId}.data.jsonl`);
|
||||
expect(await pathExists(dataPath)).toBe(false);
|
||||
expect(await resolveThreadRecord(storageRoot, threadId)).toBeNull();
|
||||
});
|
||||
|
||||
test("thread rm runs GC and removes CAS blobs not referenced by any remaining thread", async () => {
|
||||
@@ -234,9 +226,9 @@ describe("cli thread commands", () => {
|
||||
threads = await cmdThreads(storageRoot, []);
|
||||
}
|
||||
|
||||
const dataPath = join(storageRoot, "logs", added.value.hash, `${threadId}.data.jsonl`);
|
||||
const runningPath = join(dirname(dataPath), `${threadId}.running`);
|
||||
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
|
||||
await waitUntilRunningFileAbsent(runningPath, 120);
|
||||
expect((await resolveThreadRecord(storageRoot, threadId))?.source).toBe("history");
|
||||
|
||||
const put = await cmdCasPut(storageRoot, "keep-after-thread-rm");
|
||||
expect(put.ok).toBe(true);
|
||||
@@ -317,30 +309,31 @@ describe("cli thread commands", () => {
|
||||
}
|
||||
|
||||
const threadId = ran.value.threadId;
|
||||
const killBundleDir = getBundleDir(storageRoot, added.value.hash);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await waitUntilPredicate(async () => {
|
||||
const idx = await readThreadsIndex(killBundleDir);
|
||||
const ent = idx[threadId];
|
||||
return ent !== undefined && ent.head !== ent.start;
|
||||
}, 80);
|
||||
|
||||
const killed = await cmdKill(storageRoot, threadId);
|
||||
expect(killed.ok).toBe(true);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
await waitUntilPredicate(async () => {
|
||||
return (await resolveThreadRecord(storageRoot, threadId))?.source === "history";
|
||||
}, 120);
|
||||
|
||||
const dataPath = join(storageRoot, "logs", added.value.hash, `${threadId}.data.jsonl`);
|
||||
const text = await readFile(dataPath, "utf8");
|
||||
const lines = text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(lines.length).toBe(3);
|
||||
expect((await resolveThreadRecord(storageRoot, threadId))?.source).toBe("history");
|
||||
|
||||
const runningPath = join(dirname(dataPath), `${threadId}.running`);
|
||||
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
|
||||
expect(await pathExists(runningPath)).toBe(false);
|
||||
});
|
||||
|
||||
test("pause stops between yields and resume completes thread", async () => {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const srcDir = join(storageRoot, "src");
|
||||
await mkdir(srcDir, { recursive: true });
|
||||
const bundlePath = join(srcDir, "demo.esm.js");
|
||||
await writeFile(bundlePath, pauseResumeBundleSource, "utf8");
|
||||
|
||||
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
||||
@@ -356,24 +349,33 @@ describe("cli thread commands", () => {
|
||||
}
|
||||
|
||||
const threadId = ran.value.threadId;
|
||||
const dataPath = join(storageRoot, "logs", added.value.hash, `${threadId}.data.jsonl`);
|
||||
const bundleDir = getBundleDir(storageRoot, added.value.hash);
|
||||
|
||||
await waitUntilMinDataLines(dataPath, 2, 80);
|
||||
expect(await countDataJsonlLines(dataPath)).toBe(2);
|
||||
await waitUntilPredicate(async () => {
|
||||
const idx = await readThreadsIndex(bundleDir);
|
||||
const ent = idx[threadId];
|
||||
return ent !== undefined && ent.head !== ent.start;
|
||||
}, 80);
|
||||
|
||||
const idxBeforePause = await readThreadsIndex(bundleDir);
|
||||
const headAtPause = idxBeforePause[threadId]?.head;
|
||||
|
||||
const paused = await cmdPause(storageRoot, threadId);
|
||||
expect(paused.ok).toBe(true);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
expect(await countDataJsonlLines(dataPath)).toBe(2);
|
||||
const idxPaused = await readThreadsIndex(bundleDir);
|
||||
expect(idxPaused[threadId]?.head).toBe(headAtPause);
|
||||
|
||||
const resumed = await cmdResume(storageRoot, threadId);
|
||||
expect(resumed.ok).toBe(true);
|
||||
|
||||
await waitUntilMinDataLines(dataPath, 4, 120);
|
||||
expect(await countDataJsonlLines(dataPath)).toBe(4);
|
||||
await waitUntilPredicate(async () => {
|
||||
const row = await resolveThreadRecord(storageRoot, threadId);
|
||||
return row?.source === "history";
|
||||
}, 120);
|
||||
|
||||
const runningPath = join(dirname(dataPath), `${threadId}.running`);
|
||||
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
|
||||
await waitUntilRunningFileAbsent(runningPath, 100);
|
||||
expect(await pathExists(runningPath)).toBe(false);
|
||||
});
|
||||
@@ -397,8 +399,7 @@ describe("cli thread commands", () => {
|
||||
}
|
||||
|
||||
const threadId = ran.value.threadId;
|
||||
const dataPath = join(storageRoot, "logs", added.value.hash, `${threadId}.data.jsonl`);
|
||||
const runningPath = join(dirname(dataPath), `${threadId}.running`);
|
||||
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
|
||||
|
||||
await waitUntilRunningFileAbsent(runningPath, 100);
|
||||
expect(await pathExists(runningPath)).toBe(false);
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@uncaged/cli-workflow",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"uncaged-workflow": "src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-gateway": "workspace:^",
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"@uncaged/workflow-cas": "workspace:^",
|
||||
"@uncaged/workflow-execute": "workspace:^",
|
||||
"@uncaged/workflow-register": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"hono": "^4.12.18",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@uncaged/workflow-cas':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-cas
|
||||
'@uncaged/workflow-execute':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-execute
|
||||
'@uncaged/workflow-protocol':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-protocol
|
||||
'@uncaged/workflow-register':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-register
|
||||
'@uncaged/workflow-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-runtime
|
||||
'@uncaged/workflow-util':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-util
|
||||
hono:
|
||||
specifier: ^4.12.18
|
||||
version: 4.12.18
|
||||
yaml:
|
||||
specifier: ^2.8.4
|
||||
version: 2.8.4
|
||||
|
||||
packages:
|
||||
|
||||
hono@4.12.18:
|
||||
resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
yaml@2.8.4:
|
||||
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
hono@4.12.18: {}
|
||||
|
||||
yaml@2.8.4: {}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { pathExists } from "./fs-utils.js";
|
||||
|
||||
+4
-5
@@ -3,15 +3,13 @@ import { printCliError, printCliLine } from "./cli-output.js";
|
||||
import { getCommandRegistry } from "./cli-registry.js";
|
||||
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
||||
import { createCasDispatcher } from "./commands/cas/index.js";
|
||||
import { dispatchConnect } from "./commands/connect/index.js";
|
||||
import { createInitDispatcher } from "./commands/init/index.js";
|
||||
import { dispatchServe } from "./commands/serve/index.js";
|
||||
import { dispatchSetup } from "./commands/setup/index.js";
|
||||
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
||||
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
||||
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
|
||||
|
||||
export type { CommandEntry, CommandGroup, DispatchFn } from "./cli-command-types.js";
|
||||
export { getCommandRegistry } from "./cli-registry.js";
|
||||
|
||||
function dispatchGroup(
|
||||
tableName: string,
|
||||
table: Record<string, CommandEntry>,
|
||||
@@ -69,10 +67,11 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
|
||||
thread: dispatchThread,
|
||||
cas: dispatchCas,
|
||||
init: dispatchInit,
|
||||
setup: dispatchSetup,
|
||||
skill: dispatchSkill,
|
||||
run: dispatchRun,
|
||||
live: dispatchLive,
|
||||
serve: dispatchServe,
|
||||
connect: dispatchConnect,
|
||||
};
|
||||
|
||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||
+13
@@ -5,6 +5,15 @@ import { INIT_SUBCOMMAND_TABLE } from "./commands/init/index.js";
|
||||
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/index.js";
|
||||
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/index.js";
|
||||
|
||||
const SETUP_USAGE_COMMANDS = [
|
||||
{
|
||||
name: "",
|
||||
args: "[--provider <name>] [--base-url <url>] [--api-key <key>] [--default-model <provider/model>] [--init-workspace <name>]",
|
||||
description:
|
||||
"Configure workflow.yaml LLM providers and default model (interactive when no flags)",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
|
||||
return [
|
||||
{
|
||||
@@ -39,6 +48,10 @@ export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "setup",
|
||||
commands: [...SETUP_USAGE_COMMANDS],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
+6
-4
@@ -12,6 +12,7 @@ const USAGE_SECTION_BY_GROUP: Record<string, string> = {
|
||||
thread: "Thread execution:",
|
||||
cas: "Content-addressable storage:",
|
||||
init: "Development:",
|
||||
setup: "Configuration:",
|
||||
};
|
||||
|
||||
export function formatUsageCommandLines(
|
||||
@@ -38,9 +39,10 @@ export function formatCliUsage(
|
||||
}
|
||||
lines.push(sectionTitle);
|
||||
const rows = group.commands.map((cmd) => {
|
||||
const namePart = cmd.name === "" ? "" : ` ${cmd.name}`;
|
||||
const args = cmd.args ? ` ${cmd.args}` : "";
|
||||
return {
|
||||
prefix: `${group.name} ${cmd.name}${args}`,
|
||||
prefix: `${group.name}${namePart}${args}`,
|
||||
description: cmd.description,
|
||||
};
|
||||
});
|
||||
@@ -57,12 +59,12 @@ export function formatCliUsage(
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("Server:");
|
||||
lines.push("Gateway:");
|
||||
lines.push(
|
||||
...formatUsageCommandLines([
|
||||
{
|
||||
prefix: "serve [--port N] [--host ADDR]",
|
||||
description: "Start HTTP API server (default: 127.0.0.1:7860)",
|
||||
prefix: "connect [--name NAME] [--gateway URL]",
|
||||
description: "Connect to workflow gateway via WebSocket",
|
||||
},
|
||||
]),
|
||||
);
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { runCli } from "./cli-dispatch.js";
|
||||
import { resolveWorkflowStorageRoot } from "./storage-env.js";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const storageRoot = resolveWorkflowStorageRoot();
|
||||
const code = await runCli(storageRoot, argv);
|
||||
process.exit(code);
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type GcResult, garbageCollectCas } from "@uncaged/workflow-execute";
|
||||
import type { Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
export async function cmdGc(storageRoot: string): Promise<Result<GcResult, string>> {
|
||||
return garbageCollectCas(storageRoot);
|
||||
}
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
import { createCasStore, err, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
export async function cmdCasGet(
|
||||
storageRoot: string,
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
export async function cmdCasList(storageRoot: string): Promise<Result<string[], string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
export async function cmdCasPut(
|
||||
storageRoot: string,
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
export async function cmdCasRm(storageRoot: string, hash: string): Promise<Result<void, string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
+13
-1
@@ -8,7 +8,7 @@ import { createWorkflowRoutes } from "./routes-workflow.js";
|
||||
|
||||
const MAX_BODY_SIZE = 1_048_576; // 1 MB
|
||||
|
||||
export function createApp(storageRoot: string): Hono {
|
||||
export function createApp(storageRoot: string, clientToken: string | null): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.onError((_err, c) => {
|
||||
@@ -37,7 +37,19 @@ export function createApp(storageRoot: string): Hono {
|
||||
await next();
|
||||
});
|
||||
|
||||
// ── Client token auth (skip healthz) ───────────────────────────────
|
||||
if (clientToken !== null) {
|
||||
app.use("/api/*", async (c, next) => {
|
||||
const token = c.req.header("X-Client-Token");
|
||||
if (token !== clientToken) {
|
||||
return c.json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
}
|
||||
|
||||
app.get("/healthz", (c) => c.json({ ok: true }));
|
||||
app.get("/api/healthz", (c) => c.json({ ok: true }));
|
||||
|
||||
app.route("/api/workflows", createWorkflowRoutes(storageRoot));
|
||||
app.route("/api/threads", createThreadRoutes(storageRoot));
|
||||
@@ -0,0 +1,111 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { hostname as osHostname } from "node:os";
|
||||
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
import { createApp } from "./app.js";
|
||||
import { registerWithGateway, startHeartbeat, unregisterFromGateway } from "./gateway.js";
|
||||
import type { ConnectOptions } from "./types.js";
|
||||
import { startGatewayWsClient } from "./ws-client.js";
|
||||
|
||||
const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev";
|
||||
const HEARTBEAT_INTERVAL_MS = 60_000;
|
||||
|
||||
function requireNextArg(argv: string[], i: number, flag: string): Result<string, string> {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined) {
|
||||
return { ok: false, error: `${flag} requires a value` };
|
||||
}
|
||||
return ok(next);
|
||||
}
|
||||
|
||||
function parseConnectArgv(argv: string[]): Result<ConnectOptions, string> {
|
||||
let name = osHostname().split(".")[0].toLowerCase();
|
||||
let gatewayUrl = DEFAULT_GATEWAY_URL;
|
||||
const gatewaySecret = process.env.WORKFLOW_DASHBOARD_SECRET ?? "";
|
||||
const stringFlags: Record<string, (v: string) => void> = {
|
||||
"--name": (v) => {
|
||||
name = v;
|
||||
},
|
||||
"--gateway": (v) => {
|
||||
gatewayUrl = v;
|
||||
},
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg in stringFlags) {
|
||||
const r = requireNextArg(argv, i, arg);
|
||||
if (!r.ok) return r;
|
||||
stringFlags[arg](r.value);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ name, gatewayUrl, gatewaySecret });
|
||||
}
|
||||
|
||||
export async function dispatchConnect(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseConnectArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliLine(`error: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const options = parsed.value;
|
||||
|
||||
if (options.gatewaySecret === "") {
|
||||
printCliLine("error: WORKFLOW_DASHBOARD_SECRET is required");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const clientToken = randomUUID();
|
||||
const app = createApp(storageRoot, clientToken);
|
||||
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
const stopWsClient = startGatewayWsClient({
|
||||
gatewayUrl: options.gatewayUrl,
|
||||
name: options.name,
|
||||
secret: options.gatewaySecret,
|
||||
appFetch: app.fetch,
|
||||
log,
|
||||
});
|
||||
|
||||
printCliLine("connected to gateway via WebSocket");
|
||||
|
||||
// Register with gateway for discovery
|
||||
const registered = await registerWithGateway(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
`ws://${options.name}`,
|
||||
options.gatewaySecret,
|
||||
clientToken,
|
||||
);
|
||||
if (registered) {
|
||||
printCliLine(`registered with gateway as "${options.name}"`);
|
||||
}
|
||||
|
||||
const heartbeatTimer = startHeartbeat(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
`ws://${options.name}`,
|
||||
options.gatewaySecret,
|
||||
clientToken,
|
||||
HEARTBEAT_INTERVAL_MS,
|
||||
);
|
||||
|
||||
const cleanup = async () => {
|
||||
clearInterval(heartbeatTimer);
|
||||
stopWsClient();
|
||||
printCliLine("unregistering from gateway...");
|
||||
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
|
||||
export async function registerWithGateway(
|
||||
gatewayUrl: string,
|
||||
name: string,
|
||||
localUrl: string,
|
||||
secret: string,
|
||||
clientToken: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`${gatewayUrl}/api/gateway/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, url: localUrl, secret, clientToken }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text();
|
||||
printCliLine(`gateway registration failed: ${resp.status} ${body}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
printCliLine(`gateway registration error: ${e}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterFromGateway(
|
||||
gatewayUrl: string,
|
||||
name: string,
|
||||
secret: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fetch(`${gatewayUrl}/api/gateway/register/${name}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${secret}` },
|
||||
});
|
||||
} catch {
|
||||
// Best effort — process is exiting
|
||||
}
|
||||
}
|
||||
|
||||
export function startHeartbeat(
|
||||
gatewayUrl: string,
|
||||
name: string,
|
||||
localUrl: string,
|
||||
secret: string,
|
||||
clientToken: string,
|
||||
intervalMs: number,
|
||||
): ReturnType<typeof setInterval> {
|
||||
return setInterval(() => {
|
||||
registerWithGateway(gatewayUrl, name, localUrl, secret, clientToken).catch(() => {});
|
||||
}, intervalMs);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { dispatchConnect } from "./connect.js";
|
||||
export type { ConnectOptions } from "./types.js";
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
import { createCasStore, garbageCollectCas, getGlobalCasDir } from "@uncaged/workflow";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { garbageCollectCas } from "@uncaged/workflow-execute";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { Hono } from "hono";
|
||||
|
||||
export function createCasRoutes(storageRoot: string): Hono {
|
||||
@@ -0,0 +1,374 @@
|
||||
import { existsSync, statSync, watch } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import {
|
||||
FORK_BRANCH_ROLE,
|
||||
readThreadsIndex,
|
||||
type ThreadIndex,
|
||||
walkStateFramesNewestFirst,
|
||||
} from "@uncaged/workflow-execute";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
|
||||
import { resolveThreadRecord } from "../../thread-scan.js";
|
||||
|
||||
type PumpState = {
|
||||
contentOffset: number;
|
||||
carry: string;
|
||||
};
|
||||
|
||||
function fileSize(path: string): number {
|
||||
try {
|
||||
return statSync(path).size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function readNewBytes(path: string, state: PumpState): Promise<string | null> {
|
||||
const size = fileSize(path);
|
||||
if (size < state.contentOffset) {
|
||||
state.contentOffset = 0;
|
||||
state.carry = "";
|
||||
}
|
||||
if (size <= state.contentOffset) {
|
||||
return null;
|
||||
}
|
||||
const blob = Bun.file(path).slice(state.contentOffset, size);
|
||||
const chunk = await blob.text();
|
||||
state.contentOffset = size;
|
||||
return chunk;
|
||||
}
|
||||
|
||||
function parseJsonLine(line: string): unknown {
|
||||
try {
|
||||
return JSON.parse(line) as unknown;
|
||||
} catch {
|
||||
return { raw: line };
|
||||
}
|
||||
}
|
||||
|
||||
function parseNewLines(chunk: string, state: PumpState): string[] {
|
||||
state.carry += chunk;
|
||||
|
||||
const parts = state.carry.split("\n");
|
||||
state.carry = parts.pop() ?? "";
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const line of parts) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed !== "") {
|
||||
lines.push(trimmed);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
type CasSseState = {
|
||||
printedHashes: Set<string>;
|
||||
lastHead: string | null;
|
||||
completionEmitted: boolean;
|
||||
};
|
||||
|
||||
type LiveSseStream = {
|
||||
writeSSE: (opts: { event: string; data: string; id: string }) => Promise<void>;
|
||||
};
|
||||
|
||||
function completionFromEndMeta(meta: Record<string, unknown>): {
|
||||
returnCode: number;
|
||||
summary: string;
|
||||
} | null {
|
||||
const returnCode = meta.returnCode;
|
||||
const summary = meta.summary;
|
||||
if (typeof returnCode !== "number" || typeof summary !== "string") {
|
||||
return null;
|
||||
}
|
||||
return { returnCode, summary };
|
||||
}
|
||||
|
||||
async function emitRecordsForHead(params: {
|
||||
storageRoot: string;
|
||||
bundleDir: string;
|
||||
threadId: string;
|
||||
headHash: string;
|
||||
sseState: CasSseState;
|
||||
stream: LiveSseStream;
|
||||
eventId: { n: number };
|
||||
}): Promise<boolean> {
|
||||
const cas = createCasStore(getGlobalCasDir(params.storageRoot));
|
||||
const frames = await walkStateFramesNewestFirst(cas, params.headHash);
|
||||
const chronological = [...frames].reverse();
|
||||
|
||||
for (const fr of chronological) {
|
||||
if (params.sseState.printedHashes.has(fr.hash)) {
|
||||
continue;
|
||||
}
|
||||
params.sseState.printedHashes.add(fr.hash);
|
||||
|
||||
const role = fr.payload.role;
|
||||
if (role === FORK_BRANCH_ROLE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === END) {
|
||||
const wf = completionFromEndMeta(fr.payload.meta);
|
||||
if (wf !== null) {
|
||||
params.eventId.n++;
|
||||
await params.stream.writeSSE({
|
||||
event: "record",
|
||||
data: JSON.stringify({
|
||||
type: "workflow-result",
|
||||
returnCode: wf.returnCode,
|
||||
content: wf.summary,
|
||||
timestamp: null,
|
||||
}),
|
||||
id: String(params.eventId.n),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const payloadText = await getContentMerklePayload(cas, fr.payload.content);
|
||||
const content =
|
||||
payloadText !== null
|
||||
? payloadText
|
||||
: `(content not in CAS; contentHash=${fr.payload.content})`;
|
||||
|
||||
params.eventId.n++;
|
||||
await params.stream.writeSSE({
|
||||
event: "record",
|
||||
data: JSON.stringify({
|
||||
type: "role",
|
||||
role: fr.payload.role,
|
||||
contentHash: fr.payload.content,
|
||||
content,
|
||||
meta: fr.payload.meta,
|
||||
timestamp: fr.payload.timestamp,
|
||||
}),
|
||||
id: String(params.eventId.n),
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function pumpThreadsJsonSse(params: {
|
||||
storageRoot: string;
|
||||
bundleDir: string;
|
||||
threadId: string;
|
||||
sseState: CasSseState;
|
||||
stream: LiveSseStream;
|
||||
eventId: { n: number };
|
||||
}): Promise<boolean> {
|
||||
let idx: ThreadIndex;
|
||||
try {
|
||||
idx = await readThreadsIndex(params.bundleDir);
|
||||
} catch {
|
||||
idx = {};
|
||||
}
|
||||
|
||||
const active = idx[params.threadId];
|
||||
|
||||
if (active === undefined) {
|
||||
if (params.sseState.completionEmitted) {
|
||||
return false;
|
||||
}
|
||||
const hist = await resolveThreadRecord(params.storageRoot, params.threadId);
|
||||
if (hist === null || hist.source !== "history") {
|
||||
return false;
|
||||
}
|
||||
params.sseState.completionEmitted = true;
|
||||
return await emitRecordsForHead({
|
||||
storageRoot: params.storageRoot,
|
||||
bundleDir: params.bundleDir,
|
||||
threadId: params.threadId,
|
||||
headHash: hist.head,
|
||||
sseState: params.sseState,
|
||||
stream: params.stream,
|
||||
eventId: params.eventId,
|
||||
});
|
||||
}
|
||||
|
||||
const head = active.head;
|
||||
if (params.sseState.lastHead === null) {
|
||||
params.sseState.lastHead = head;
|
||||
return await emitRecordsForHead({
|
||||
storageRoot: params.storageRoot,
|
||||
bundleDir: params.bundleDir,
|
||||
threadId: params.threadId,
|
||||
headHash: head,
|
||||
sseState: params.sseState,
|
||||
stream: params.stream,
|
||||
eventId: params.eventId,
|
||||
});
|
||||
}
|
||||
|
||||
if (head !== params.sseState.lastHead) {
|
||||
params.sseState.lastHead = head;
|
||||
return await emitRecordsForHead({
|
||||
storageRoot: params.storageRoot,
|
||||
bundleDir: params.bundleDir,
|
||||
threadId: params.threadId,
|
||||
headHash: head,
|
||||
sseState: params.sseState,
|
||||
stream: params.stream,
|
||||
eventId: params.eventId,
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function createLiveRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/:threadId/live", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const resolved = await resolveThreadRecord(storageRoot, threadId);
|
||||
if (resolved === null) {
|
||||
return c.json({ error: `thread not found: ${threadId}` }, 404);
|
||||
}
|
||||
|
||||
const threadTarget = resolved;
|
||||
const threadsJsonPath = join(threadTarget.bundleDir, "threads.json");
|
||||
const infoPath = join(storageRoot, "logs", threadTarget.bundleHash, `${threadId}.info.jsonl`);
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
const infoState: PumpState = { contentOffset: 0, carry: "" };
|
||||
const sseThreadState: CasSseState = {
|
||||
printedHashes: new Set<string>(),
|
||||
lastHead: null,
|
||||
completionEmitted: false,
|
||||
};
|
||||
const eventId = { n: 0 };
|
||||
|
||||
async function pumpData(): Promise<boolean> {
|
||||
const finished = await pumpThreadsJsonSse({
|
||||
storageRoot,
|
||||
bundleDir: threadTarget.bundleDir,
|
||||
threadId,
|
||||
sseState: sseThreadState,
|
||||
stream,
|
||||
eventId,
|
||||
});
|
||||
return finished;
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: SSE newline framing mirrors legacy pump
|
||||
async function pumpInfo(): Promise<void> {
|
||||
let chunk: string | null;
|
||||
try {
|
||||
chunk = await readNewBytes(infoPath, infoState);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (chunk === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = parseNewLines(chunk, infoState);
|
||||
for (const line of lines) {
|
||||
const record = parseJsonLine(line);
|
||||
if (
|
||||
typeof record === "object" &&
|
||||
record !== null &&
|
||||
"raw" in (record as Record<string, unknown>)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
eventId.n++;
|
||||
await stream.writeSSE({
|
||||
event: "info",
|
||||
data: JSON.stringify(record),
|
||||
id: String(eventId.n),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
eventId.n++;
|
||||
await stream.writeSSE({
|
||||
event: "record",
|
||||
data: JSON.stringify({
|
||||
type: "thread-start",
|
||||
threadId: threadTarget.threadId,
|
||||
bundleHash: threadTarget.bundleHash,
|
||||
head: threadTarget.head,
|
||||
start: threadTarget.start,
|
||||
source: threadTarget.source,
|
||||
}),
|
||||
id: String(eventId.n),
|
||||
});
|
||||
|
||||
const done = await pumpData();
|
||||
try {
|
||||
await pumpInfo();
|
||||
} catch {
|
||||
// optional info file
|
||||
}
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If thread is not actively running, emit all records and close — don't keep SSE open
|
||||
const runningPath = join(storageRoot, "logs", threadTarget.bundleHash, `${threadId}.running`);
|
||||
if (!existsSync(runningPath)) {
|
||||
eventId.n++;
|
||||
await stream.writeSSE({
|
||||
event: "done",
|
||||
data: JSON.stringify({ reason: "not-running" }),
|
||||
id: String(eventId.n),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let completed = false;
|
||||
|
||||
const threadsJsonWatcher = watch(threadsJsonPath, async () => {
|
||||
if (completed) {
|
||||
return;
|
||||
}
|
||||
const finished = await pumpData();
|
||||
if (finished) {
|
||||
completed = true;
|
||||
controller.abort();
|
||||
}
|
||||
});
|
||||
|
||||
let infoWatcher: ReturnType<typeof watch> | null = null;
|
||||
try {
|
||||
infoWatcher = watch(infoPath, async () => {
|
||||
if (completed) {
|
||||
return;
|
||||
}
|
||||
await pumpInfo();
|
||||
});
|
||||
} catch {
|
||||
// info file may not exist
|
||||
}
|
||||
|
||||
stream.onAbort(() => {
|
||||
completed = true;
|
||||
threadsJsonWatcher.close();
|
||||
infoWatcher?.close();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
if (completed) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
controller.signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
stream.onAbort(() => resolve());
|
||||
});
|
||||
|
||||
threadsJsonWatcher.close();
|
||||
infoWatcher?.close();
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, getContentMerklePayload, parseCasThreadNode } from "@uncaged/workflow-cas";
|
||||
import { FORK_BRANCH_ROLE, walkStateFramesNewestFirst } from "@uncaged/workflow-execute";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
import type { HistoricalThreadRow, ResolvedThreadRecord } from "../../thread-scan.js";
|
||||
import {
|
||||
listHistoricalThreads,
|
||||
listRunningThreads,
|
||||
resolveThreadListStatus,
|
||||
resolveThreadRecord,
|
||||
} from "../../thread-scan.js";
|
||||
import { cmdKill, cmdPause, cmdResume } from "../thread/control.js";
|
||||
import { cmdRun } from "../thread/run.js";
|
||||
|
||||
async function readStartInfo(
|
||||
cas: ReturnType<typeof createCasStore>,
|
||||
startHash: string,
|
||||
): Promise<{ name: string | null; prompt: string | null }> {
|
||||
const raw = await cas.get(startHash);
|
||||
if (raw === null) return { name: null, prompt: null };
|
||||
const parsed = parseCasThreadNode(raw);
|
||||
if (parsed === null || parsed.kind !== "start") return { name: null, prompt: null };
|
||||
const name = parsed.node.payload.name;
|
||||
const promptHash = parsed.node.refs[0] ?? null;
|
||||
let prompt: string | null = null;
|
||||
if (promptHash !== null) {
|
||||
prompt = await getContentMerklePayload(cas, promptHash);
|
||||
}
|
||||
return { name, prompt };
|
||||
}
|
||||
|
||||
async function buildThreadDetailRecords(
|
||||
storageRoot: string,
|
||||
resolved: ResolvedThreadRecord,
|
||||
runningMarkerPresent: boolean,
|
||||
statusRow: HistoricalThreadRow,
|
||||
): Promise<unknown[]> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const frames = await walkStateFramesNewestFirst(cas, resolved.head);
|
||||
const chronological = [...frames].reverse();
|
||||
|
||||
const { name: workflowName, prompt } = await readStartInfo(cas, resolved.start);
|
||||
|
||||
const status = await resolveThreadListStatus(storageRoot, statusRow, runningMarkerPresent);
|
||||
|
||||
const records: unknown[] = [
|
||||
{
|
||||
type: "thread-start",
|
||||
workflow: workflowName ?? "unknown",
|
||||
prompt: prompt ?? null,
|
||||
threadId: resolved.threadId,
|
||||
status,
|
||||
timestamp: null,
|
||||
},
|
||||
];
|
||||
|
||||
for (const fr of chronological) {
|
||||
if (fr.payload.role === FORK_BRANCH_ROLE) {
|
||||
continue;
|
||||
}
|
||||
if (fr.payload.role === END) {
|
||||
const returnCode = fr.payload.meta.returnCode;
|
||||
const summary = fr.payload.meta.summary;
|
||||
if (typeof returnCode === "number" && typeof summary === "string") {
|
||||
records.push({
|
||||
type: "workflow-result",
|
||||
returnCode,
|
||||
content: summary,
|
||||
timestamp: fr.payload.timestamp,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const payloadText = await getContentMerklePayload(cas, fr.payload.content);
|
||||
const content =
|
||||
payloadText !== null
|
||||
? payloadText
|
||||
: `(content not in CAS; contentHash=${fr.payload.content})`;
|
||||
records.push({
|
||||
type: "role",
|
||||
role: fr.payload.role,
|
||||
contentHash: fr.payload.content,
|
||||
content,
|
||||
meta: fr.payload.meta,
|
||||
timestamp: fr.payload.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
export function createThreadRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
const nameFilter = c.req.query("workflow") ?? null;
|
||||
const rows = await listHistoricalThreads(storageRoot, nameFilter);
|
||||
const threads = await Promise.all(
|
||||
rows.map(async (r) => {
|
||||
const runningPath = join(storageRoot, "logs", r.hash, `${r.threadId}.running`);
|
||||
const runningMarkerPresent = await pathExists(runningPath);
|
||||
const status = await resolveThreadListStatus(storageRoot, r, runningMarkerPresent);
|
||||
return {
|
||||
threadId: r.threadId,
|
||||
workflow: r.workflowName,
|
||||
hash: r.hash,
|
||||
startedAt: new Date(r.activityTs).toISOString(),
|
||||
status,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return c.json({ threads });
|
||||
});
|
||||
|
||||
app.get("/running", async (c) => {
|
||||
const rows = await listRunningThreads(storageRoot);
|
||||
return c.json({ threads: rows });
|
||||
});
|
||||
|
||||
app.get("/:threadId", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const resolved = await resolveThreadRecord(storageRoot, threadId);
|
||||
if (resolved === null) {
|
||||
return c.json({ error: `thread not found: ${threadId}` }, 404);
|
||||
}
|
||||
const runningPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.running`);
|
||||
const runningMarkerPresent = await pathExists(runningPath);
|
||||
const statusRow = {
|
||||
threadId: resolved.threadId,
|
||||
hash: resolved.bundleHash,
|
||||
workflowName: null,
|
||||
source: resolved.source,
|
||||
activityTs: 0,
|
||||
head: resolved.head,
|
||||
};
|
||||
const records = await buildThreadDetailRecords(
|
||||
storageRoot,
|
||||
resolved,
|
||||
runningMarkerPresent,
|
||||
statusRow,
|
||||
);
|
||||
return c.json({ threadId, records });
|
||||
});
|
||||
|
||||
app.post("/", async (c) => {
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await c.req.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return c.json({ error: "invalid JSON body" }, 400);
|
||||
}
|
||||
|
||||
const name = body.workflow;
|
||||
const prompt = body.prompt;
|
||||
|
||||
if (typeof name !== "string" || typeof prompt !== "string") {
|
||||
return c.json({ error: "workflow (string) and prompt (string) are required" }, 400);
|
||||
}
|
||||
|
||||
const result = await cmdRun(storageRoot, name, prompt);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ threadId: result.value.threadId }, 201);
|
||||
});
|
||||
|
||||
app.post("/:threadId/kill", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const result = await cmdKill(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/:threadId/pause", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const result = await cmdPause(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/:threadId/resume", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const result = await cmdResume(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
+17
-2
@@ -1,9 +1,14 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { WorkflowDescriptor } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
getRegisteredWorkflow,
|
||||
listRegisteredWorkflowNames,
|
||||
readWorkflowRegistry,
|
||||
} from "@uncaged/workflow";
|
||||
validateWorkflowDescriptor,
|
||||
} from "@uncaged/workflow-register";
|
||||
import { Hono } from "hono";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
export function createWorkflowRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
@@ -35,7 +40,17 @@ export function createWorkflowRoutes(storageRoot: string): Hono {
|
||||
if (entry === null) {
|
||||
return c.json({ error: `workflow not found: ${name}` }, 404);
|
||||
}
|
||||
return c.json({ name, ...entry });
|
||||
let descriptor: WorkflowDescriptor | null = null;
|
||||
try {
|
||||
const yamlPath = join(storageRoot, "bundles", `${entry.hash}.yaml`);
|
||||
const yamlText = await readFile(yamlPath, "utf8");
|
||||
const parsed: unknown = parseYaml(yamlText);
|
||||
const validated = validateWorkflowDescriptor(parsed);
|
||||
descriptor = validated.ok ? validated.value : null;
|
||||
} catch {
|
||||
descriptor = null;
|
||||
}
|
||||
return c.json({ name, ...entry, descriptor });
|
||||
});
|
||||
|
||||
app.get("/:name/history", async (c) => {
|
||||
@@ -0,0 +1,5 @@
|
||||
export type ConnectOptions = {
|
||||
name: string;
|
||||
gatewayUrl: string;
|
||||
gatewaySecret: string;
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import { parseWsRequestJson, type WsResponse } from "@uncaged/workflow-gateway/ws-protocol";
|
||||
import type { LogFn } from "@uncaged/workflow-util";
|
||||
|
||||
export type GatewayWsClientParams = {
|
||||
gatewayUrl: string;
|
||||
name: string;
|
||||
secret: string;
|
||||
appFetch: (request: Request) => Response | Promise<Response>;
|
||||
log: LogFn;
|
||||
};
|
||||
|
||||
const INITIAL_BACKOFF_MS = 1000;
|
||||
const MAX_BACKOFF_MS = 30_000;
|
||||
|
||||
export function buildGatewayWsConnectUrl(gatewayUrl: string, name: string, secret: string): string {
|
||||
const u = new URL(gatewayUrl);
|
||||
if (u.protocol === "https:") {
|
||||
u.protocol = "wss:";
|
||||
} else if (u.protocol === "http:") {
|
||||
u.protocol = "ws:";
|
||||
}
|
||||
u.pathname = "/ws/connect";
|
||||
u.search = "";
|
||||
u.searchParams.set("name", name);
|
||||
u.searchParams.set("secret", secret);
|
||||
return u.href;
|
||||
}
|
||||
|
||||
function headersToRecord(h: Headers): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of h) {
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function handleGatewayMessage(
|
||||
ws: WebSocket,
|
||||
raw: string,
|
||||
params: GatewayWsClientParams,
|
||||
): Promise<void> {
|
||||
const req = parseWsRequestJson(raw);
|
||||
if (req === null) {
|
||||
params.log("ZM8K2PQ1", "gateway WebSocket dropped non-request message");
|
||||
return;
|
||||
}
|
||||
const localUrl = `http://localhost${req.path}`;
|
||||
const headers = new Headers(req.headers);
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await params.appFetch(
|
||||
new Request(localUrl, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: req.body === null ? undefined : req.body,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
params.log("R4N7BQ3C", `app.fetch failed: ${String(e)}`);
|
||||
const errBody: WsResponse = {
|
||||
id: req.id,
|
||||
status: 502,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ error: "local fetch failed", detail: String(e) }),
|
||||
};
|
||||
ws.send(JSON.stringify(errBody));
|
||||
return;
|
||||
}
|
||||
const bodyText = await resp.text();
|
||||
const headerRecord = headersToRecord(resp.headers);
|
||||
const out: WsResponse = {
|
||||
id: req.id,
|
||||
status: resp.status,
|
||||
headers: headerRecord,
|
||||
body: bodyText,
|
||||
};
|
||||
ws.send(JSON.stringify(out));
|
||||
}
|
||||
|
||||
/** Maintains a reverse WebSocket to the workflow gateway; reconnects with exponential backoff. */
|
||||
export function startGatewayWsClient(params: GatewayWsClientParams): () => void {
|
||||
const wsUrl = buildGatewayWsConnectUrl(params.gatewayUrl, params.name, params.secret);
|
||||
let socket: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let stopped = false;
|
||||
let attempt = 0;
|
||||
|
||||
const clearReconnectTimer = (): void => {
|
||||
if (reconnectTimer !== null) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = (): void => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
clearReconnectTimer();
|
||||
const delayMs = Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
||||
attempt++;
|
||||
params.log("6CJX2R8P", `gateway WebSocket reconnect in ${delayMs}ms (attempt ${attempt})`);
|
||||
reconnectTimer = setTimeout(connect, delayMs);
|
||||
};
|
||||
|
||||
const connect = (): void => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
clearReconnectTimer();
|
||||
params.log("2XK7HM9Q", "gateway WebSocket connecting...");
|
||||
try {
|
||||
socket = new WebSocket(wsUrl);
|
||||
} catch (e) {
|
||||
params.log("7NQW4HBT", `gateway WebSocket create failed: ${String(e)}`);
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = socket;
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
attempt = 0;
|
||||
params.log("4PWN3V82", "gateway WebSocket connected");
|
||||
});
|
||||
|
||||
ws.addEventListener("close", (ev) => {
|
||||
socket = null;
|
||||
params.log(
|
||||
"8QTR6ZKC",
|
||||
`gateway WebSocket closed code=${String(ev.code)} reason=${ev.reason} wasClean=${String(ev.wasClean)}`,
|
||||
);
|
||||
if (!stopped) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
params.log("9BWS1M7F", "gateway WebSocket error");
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (ev) => {
|
||||
const data = ev.data;
|
||||
if (typeof data !== "string") {
|
||||
params.log("T9W2K35H", "gateway WebSocket non-text frame ignored");
|
||||
return;
|
||||
}
|
||||
void handleGatewayMessage(ws, data, params).catch((e: unknown) => {
|
||||
params.log("V7KX2M9P", `gateway WebSocket handler error: ${String(e)}`);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return (): void => {
|
||||
stopped = true;
|
||||
clearReconnectTimer();
|
||||
if (socket !== null && socket.readyState === WebSocket.OPEN) {
|
||||
socket.close(1000, "shutdown");
|
||||
}
|
||||
socket = null;
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
|
||||
+8
-15
@@ -6,8 +6,7 @@ export function templatePackageJson(templateName: string): string {
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@uncaged/workflow": "^0.1.0",
|
||||
"@uncaged/workflow-runtime": "^0.1.0",
|
||||
"@uncaged/workflow-runtime": "^0.3.1",
|
||||
zod: "^4.0.0",
|
||||
},
|
||||
},
|
||||
@@ -51,25 +50,19 @@ const greeterMetaSchema = z.object({
|
||||
export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
|
||||
description: "Says hello — replace with your first role.",
|
||||
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
|
||||
extractPrompt: "Extract the assistant's greeting as message.",
|
||||
schema: greeterMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
export function templateModeratorTs(): string {
|
||||
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow-runtime";
|
||||
return `import { END, START, type ModeratorTable } from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { HelloTemplateMeta } from "./roles.js";
|
||||
|
||||
export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
|
||||
ctx: ModeratorContext<HelloTemplateMeta>,
|
||||
) => {
|
||||
if (ctx.steps.length === 0) {
|
||||
return "greeter";
|
||||
}
|
||||
return END;
|
||||
export const helloTemplateTable: ModeratorTable<HelloTemplateMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "greeter" }],
|
||||
greeter: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
`;
|
||||
}
|
||||
@@ -77,7 +70,7 @@ export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
|
||||
export function templateIndexTs(): string {
|
||||
return `import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { helloTemplateModerator } from "./moderator.js";
|
||||
import { helloTemplateTable } from "./moderator.js";
|
||||
import {
|
||||
HELLO_TEMPLATE_DESCRIPTION,
|
||||
type HelloTemplateMeta,
|
||||
@@ -89,14 +82,14 @@ export {
|
||||
type HelloTemplateMeta,
|
||||
greeterRole,
|
||||
} from "./roles.js";
|
||||
export { helloTemplateModerator } from "./moderator.js";
|
||||
export { helloTemplateTable } from "./moderator.js";
|
||||
|
||||
export const helloTemplateWorkflowDefinition: WorkflowDefinition<HelloTemplateMeta> = {
|
||||
description: HELLO_TEMPLATE_DESCRIPTION,
|
||||
roles: {
|
||||
greeter: greeterRole,
|
||||
},
|
||||
moderator: helloTemplateModerator,
|
||||
table: helloTemplateTable,
|
||||
};
|
||||
`;
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
/** Validates a single path segment for workspace / template names (no separators, not `.` / `..`). */
|
||||
export function validateWorkspaceSegment(name: string): Result<void, string> {
|
||||
+106
-28
@@ -1,11 +1,10 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
import type { CmdInitWorkspaceSuccess } from "./types.js";
|
||||
import { validateWorkspaceSegment } from "./validate.js";
|
||||
|
||||
function rootPackageJson(workspaceName: string): string {
|
||||
return `${JSON.stringify(
|
||||
@@ -14,6 +13,9 @@ function rootPackageJson(workspaceName: string): string {
|
||||
private: true,
|
||||
type: "module",
|
||||
workspaces: ["templates/*", "workflows"],
|
||||
scripts: {
|
||||
bundle: "bun run scripts/bundle.ts",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -28,7 +30,7 @@ function workflowsPackageJson(): string {
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@uncaged/workflow": "^0.1.0",
|
||||
"@uncaged/workflow-runtime": "^0.3.1",
|
||||
zod: "^4.0.0",
|
||||
},
|
||||
},
|
||||
@@ -42,7 +44,9 @@ function biomeJson(): string {
|
||||
{
|
||||
$schema: "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
files: {
|
||||
includes: ["**", "!**/node_modules", "!**/dist"],
|
||||
// Exclude generated bundle script — it uses Bun globals and console that
|
||||
// conflict with the workspace's Biome rules (noConsole, etc.).
|
||||
includes: ["**", "!**/node_modules", "!**/dist", "!scripts/bundle.ts"],
|
||||
},
|
||||
formatter: {
|
||||
indentWidth: 2,
|
||||
@@ -85,29 +89,29 @@ function agentsMd(): string {
|
||||
| 层级 | 目录 / 产物 | 职责 |
|
||||
|------|----------------|------|
|
||||
| **Workspace** | 仓库根(\`package.json\` 含 \`workspaces: ["templates/*", "workflows"]\`) | Bun monorepo:统一管理本地模板包与 workflow 实例 |
|
||||
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`、\`src/moderator.ts\`、\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **Moderator**),**不绑定**具体 Agent |
|
||||
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AgentFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
|
||||
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`、\`src/moderator.ts\`、\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **ModeratorTable**),**不绑定**具体 Agent |
|
||||
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AdapterFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
|
||||
|
||||
Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下放绑定与打包入口。
|
||||
|
||||
## 2. 核心概念
|
||||
|
||||
- **RoleMeta**:\`Record<string, Record<string, unknown>>\`,角色名 → 该角色结构化 meta 的形状约定。
|
||||
- **RoleDefinition<Meta>**:纯数据——\`description\`、\`systemPrompt\`、\`extractPrompt\`、\`schema\`(Zod v4)。不含执行逻辑。
|
||||
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **Moderator**。
|
||||
- **Moderator**:\`(ctx: ModeratorContext<M>) => (角色名) | END\`。同步、纯函数,只做路由。
|
||||
- **AgentFn**:\`(ctx: AgentContext) => Promise<string>\`,原始文本输出;从上下文读取当前角色的 \`systemPrompt\`。
|
||||
- **ExtractFn**:从上下文与 prompt 解析结构化数据(引擎与 Agent 都可使用)。
|
||||
- **RoleDefinition<Meta>**:纯数据——\`description\`、\`systemPrompt\`、\`schema\`(Zod v4)。不含执行逻辑。
|
||||
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **ModeratorTable**(声明式路由表)。
|
||||
- **ModeratorTable**:从 \`START\` 与各角色名映射到有序 transition 列表(条件 + 下一角色或 \`END\`);可序列化,供描述符提取 **graph**。
|
||||
- **AdapterFn**:接收系统提示词与 Zod schema,返回角色执行函数(RoleFn)。
|
||||
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Adapter 都可使用)。
|
||||
|
||||
引擎循环简述:**Moderator** → 选角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
|
||||
引擎循环简述:按 **ModeratorTable** 选下一角色 → **Adapter** 产出 typed meta → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
|
||||
|
||||
## 3. 开发流程
|
||||
|
||||
1. **定义 RoleMeta**:为每个角色约定 meta 的 TypeScript 类型(与 Zod schema 对齐)。
|
||||
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`。
|
||||
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`。
|
||||
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
|
||||
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`。
|
||||
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`description\`。
|
||||
3. **编写 ModeratorTable**:为 \`START\` 与各角色声明 transition(\`FALLBACK\` 或命名条件 + \`check\`)。
|
||||
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / table 导出)。
|
||||
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AdapterFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`。
|
||||
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
|
||||
|
||||
## 4. 编码规范
|
||||
@@ -153,7 +157,13 @@ uncaged-workflow add <name> <path/to/bundle.esm.js>
|
||||
|
||||
---
|
||||
|
||||
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ Moderator → 绑定 → 单文件 bundle**,再对照本节规范自检。
|
||||
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ ModeratorTable → 绑定 → 单文件 bundle**,再对照本节规范自检。
|
||||
`;
|
||||
}
|
||||
|
||||
function bunfigToml(): string {
|
||||
return `[install.scopes]
|
||||
"@uncaged" = "https://git.shazhou.work/api/packages/shazhou/npm/"
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -164,7 +174,7 @@ Local workflow development workspace (Bun monorepo).
|
||||
|
||||
## Layout
|
||||
|
||||
- \`templates/\` — reusable workflow definition packages (roles + moderator), no agent binding
|
||||
- \`templates/\` — reusable workflow definition packages (roles + ModeratorTable), no agent binding
|
||||
- \`workflows/\` — workflow instances that bind templates to agents and export \`run\` + \`descriptor\`
|
||||
|
||||
## Commands
|
||||
@@ -184,32 +194,100 @@ uncaged-workflow init workspace ${workspaceName}
|
||||
`;
|
||||
}
|
||||
|
||||
function bundleTs(): string {
|
||||
return [
|
||||
'import { mkdir, readdir, writeFile } from "node:fs/promises";',
|
||||
'import { join } from "node:path";',
|
||||
"",
|
||||
'const rootDir = join(import.meta.dir, "..");',
|
||||
'const workflowsDir = join(rootDir, "workflows");',
|
||||
'const distDir = join(rootDir, "dist");',
|
||||
"",
|
||||
"function isEntryFile(name: string): boolean {",
|
||||
' return name.endsWith("-entry.ts");',
|
||||
"}",
|
||||
"",
|
||||
"function entryStem(name: string): string {",
|
||||
' return name.slice(0, -".ts".length);',
|
||||
"}",
|
||||
"",
|
||||
"async function main(): Promise<void> {",
|
||||
" await mkdir(distDir, { recursive: true });",
|
||||
" let files: string[];",
|
||||
" try {",
|
||||
" files = await readdir(workflowsDir);",
|
||||
" } catch {",
|
||||
' console.error("bundle: missing workflows/ directory");',
|
||||
" process.exitCode = 1;",
|
||||
" return;",
|
||||
" }",
|
||||
" const entries = files.filter(isEntryFile);",
|
||||
" if (entries.length === 0) {",
|
||||
' console.warn("bundle: no *-entry.ts files under workflows/");',
|
||||
" return;",
|
||||
" }",
|
||||
" for (const file of entries) {",
|
||||
" const stem = entryStem(file);",
|
||||
" const entryPath = join(workflowsDir, file);",
|
||||
" const result = await Bun.build({",
|
||||
" entrypoints: [entryPath],",
|
||||
" outdir: distDir,",
|
||||
' format: "esm",',
|
||||
' target: "node",',
|
||||
" splitting: false,",
|
||||
' naming: { entry: "[name].esm.js" },',
|
||||
" });",
|
||||
" if (!result.success) {",
|
||||
" for (const log of result.logs) {",
|
||||
" console.error(log);",
|
||||
" }",
|
||||
` throw new Error(\`bundle failed for \${file}\`);`,
|
||||
" }",
|
||||
" const dts =",
|
||||
` 'export { run, descriptor } from "../workflows/' + stem + '.js";\\n';`,
|
||||
` await writeFile(join(distDir, \`\${stem}.d.ts\`), dts, "utf8");`,
|
||||
` console.log(\`bundle: \${stem} -> dist/\${stem}.esm.js\`);`,
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
"await main();",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function cmdInitWorkspace(
|
||||
parentDir: string,
|
||||
workspaceName: string,
|
||||
): Promise<Result<CmdInitWorkspaceSuccess, string>> {
|
||||
const validated = validateWorkspaceSegment(workspaceName);
|
||||
if (!validated.ok) {
|
||||
return validated;
|
||||
// Accept a relative/absolute path: resolve it and derive the dir name for package.json.
|
||||
const resolved = resolve(parentDir, workspaceName);
|
||||
const rootPath = resolved;
|
||||
const dirName = basename(resolved);
|
||||
|
||||
if (dirName === "" || dirName === "." || dirName === "..") {
|
||||
return err(`invalid workspace path: ${workspaceName}`);
|
||||
}
|
||||
|
||||
const rootPath = join(parentDir, workspaceName);
|
||||
if (await pathExists(rootPath)) {
|
||||
return err(`directory already exists: ${rootPath}`);
|
||||
}
|
||||
|
||||
await mkdir(rootPath, { recursive: false });
|
||||
await mkdir(join(rootPath, "templates"), { recursive: false });
|
||||
await mkdir(join(rootPath, "workflows"), { recursive: false });
|
||||
await mkdir(rootPath, { recursive: true });
|
||||
await mkdir(join(rootPath, "templates"), { recursive: true });
|
||||
await mkdir(join(rootPath, "workflows"), { recursive: true });
|
||||
await mkdir(join(rootPath, "scripts"), { recursive: true });
|
||||
|
||||
await Promise.all([
|
||||
writeFile(join(rootPath, "package.json"), rootPackageJson(workspaceName), "utf8"),
|
||||
writeFile(join(rootPath, "package.json"), rootPackageJson(dirName), "utf8"),
|
||||
writeFile(join(rootPath, "biome.json"), biomeJson(), "utf8"),
|
||||
writeFile(join(rootPath, "tsconfig.json"), tsconfigJson(), "utf8"),
|
||||
writeFile(join(rootPath, "AGENTS.md"), agentsMd(), "utf8"),
|
||||
writeFile(join(rootPath, "README.md"), readmeMd(workspaceName), "utf8"),
|
||||
writeFile(join(rootPath, "README.md"), readmeMd(dirName), "utf8"),
|
||||
writeFile(join(rootPath, "templates", ".gitkeep"), "", "utf8"),
|
||||
writeFile(join(rootPath, "workflows", "package.json"), workflowsPackageJson(), "utf8"),
|
||||
writeFile(join(rootPath, "workflows", ".gitkeep"), "", "utf8"),
|
||||
writeFile(join(rootPath, "bunfig.toml"), bunfigToml(), "utf8"),
|
||||
writeFile(join(rootPath, "scripts", "bundle.ts"), bundleTs(), "utf8"),
|
||||
]);
|
||||
|
||||
return ok({ rootPath });
|
||||
@@ -0,0 +1,451 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve as resolvePath } from "node:path";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import { printCliError, printCliLine, printCliWarn } from "../../cli-output.js";
|
||||
|
||||
const setupDispatchLog = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
import { loadPresetProviders } from "./preset-providers.js";
|
||||
import { cmdSetup, printSetupSummary } from "./setup.js";
|
||||
import type { SetupCliArgs } from "./types.js";
|
||||
|
||||
type OpenAiModelEntry = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
type OpenAiModelsResponse = {
|
||||
data: OpenAiModelEntry[];
|
||||
};
|
||||
|
||||
function usageSetup(): string {
|
||||
return [
|
||||
"uncaged-workflow setup — configure workflow.yaml providers and default model",
|
||||
"",
|
||||
"Non-interactive (agent mode):",
|
||||
" uncaged-workflow setup \\",
|
||||
" --provider <name> \\",
|
||||
" --base-url <url> \\",
|
||||
" --api-key <key> \\",
|
||||
" --default-model <provider/model> \\",
|
||||
" [--init-workspace <name>]",
|
||||
"",
|
||||
"Interactive: run with no flags (prompts for each value).",
|
||||
"",
|
||||
"Storage: uses the same root as other commands (see UNCAGED_WORKFLOW_STORAGE_ROOT).",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function requireNext(argv: string[], i: number, flag: string): Result<string, string> {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined || next.startsWith("--")) {
|
||||
return err(`${flag} requires a value`);
|
||||
}
|
||||
return ok(next);
|
||||
}
|
||||
|
||||
type ParsedSetup = SetupCliArgs | "interactive" | "help";
|
||||
|
||||
type SetupFlagField = "provider" | "baseUrl" | "apiKey" | "defaultModel" | "initWorkspaceName";
|
||||
|
||||
const SETUP_FLAG_TO_FIELD: Record<string, SetupFlagField> = {
|
||||
"--provider": "provider",
|
||||
"--base-url": "baseUrl",
|
||||
"--api-key": "apiKey",
|
||||
"--default-model": "defaultModel",
|
||||
"--init-workspace": "initWorkspaceName",
|
||||
};
|
||||
|
||||
function emptyFlagState(): Record<SetupFlagField, string | null> {
|
||||
return {
|
||||
provider: null,
|
||||
baseUrl: null,
|
||||
apiKey: null,
|
||||
defaultModel: null,
|
||||
initWorkspaceName: null,
|
||||
};
|
||||
}
|
||||
|
||||
function finalizeParsedSetup(
|
||||
state: Record<SetupFlagField, string | null>,
|
||||
): Result<ParsedSetup, string> {
|
||||
const hasAnyFlag =
|
||||
state.provider !== null ||
|
||||
state.baseUrl !== null ||
|
||||
state.apiKey !== null ||
|
||||
state.defaultModel !== null ||
|
||||
state.initWorkspaceName !== null;
|
||||
|
||||
if (!hasAnyFlag) {
|
||||
return ok("interactive");
|
||||
}
|
||||
|
||||
if (state.provider === null) {
|
||||
return err(
|
||||
"non-interactive setup requires --provider (or omit all flags for interactive mode)",
|
||||
);
|
||||
}
|
||||
|
||||
const missing: string[] = [];
|
||||
if (state.baseUrl === null) {
|
||||
missing.push("--base-url");
|
||||
}
|
||||
if (state.apiKey === null) {
|
||||
missing.push("--api-key");
|
||||
}
|
||||
if (state.defaultModel === null) {
|
||||
missing.push("--default-model");
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
return err(`missing required flag(s): ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
const b = state.baseUrl;
|
||||
const k = state.apiKey;
|
||||
const m = state.defaultModel;
|
||||
if (b === null || k === null || m === null) {
|
||||
return err("internal: missing required flags after validation");
|
||||
}
|
||||
|
||||
return ok({
|
||||
provider: state.provider,
|
||||
baseUrl: b,
|
||||
apiKey: k,
|
||||
defaultModel: m,
|
||||
initWorkspaceName: state.initWorkspaceName,
|
||||
});
|
||||
}
|
||||
|
||||
function parseSetupArgv(argv: string[]): Result<ParsedSetup, string> {
|
||||
const state = emptyFlagState();
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const tok = argv[i];
|
||||
if (tok === undefined) {
|
||||
break;
|
||||
}
|
||||
if (tok === "--help" || tok === "-h") {
|
||||
return ok("help");
|
||||
}
|
||||
const field = SETUP_FLAG_TO_FIELD[tok];
|
||||
if (field === undefined) {
|
||||
return err(`unknown argument: ${tok}`);
|
||||
}
|
||||
const v = requireNext(argv, i, tok);
|
||||
if (!v.ok) {
|
||||
return v;
|
||||
}
|
||||
state[field] = v.value;
|
||||
i++;
|
||||
}
|
||||
|
||||
return finalizeParsedSetup(state);
|
||||
}
|
||||
|
||||
async function promptLine(
|
||||
rl: { question: (q: string) => Promise<string> },
|
||||
label: string,
|
||||
): Promise<string> {
|
||||
const raw = await rl.question(label);
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
type SecretInputState = {
|
||||
buf: string;
|
||||
rawWasSet: boolean;
|
||||
onData: (chunk: string) => void;
|
||||
fulfill: (value: string) => void;
|
||||
};
|
||||
|
||||
function isLineTerminator(c: string): boolean {
|
||||
return c === "\n" || c === "\r" || c === "\u0004";
|
||||
}
|
||||
|
||||
function handleLineTerminator(state: SecretInputState): void {
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(state.rawWasSet);
|
||||
}
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", state.onData);
|
||||
process.stdout.write("\n");
|
||||
state.fulfill(state.buf.trim());
|
||||
}
|
||||
|
||||
function handleBackspace(state: SecretInputState): void {
|
||||
if (state.buf.length > 0) {
|
||||
state.buf = state.buf.slice(0, -1);
|
||||
process.stdout.write("\b \b");
|
||||
}
|
||||
}
|
||||
|
||||
function handleInterrupt(rawWasSet: boolean): void {
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(rawWasSet);
|
||||
}
|
||||
process.exit(130);
|
||||
}
|
||||
|
||||
function isBackspace(c: string): boolean {
|
||||
return c === "\u007F" || c === "\b";
|
||||
}
|
||||
|
||||
/** Process a single character in secret input. Returns "done" to stop reading. */
|
||||
function processSecretChar(c: string, state: SecretInputState): "done" | "skip" | "append" {
|
||||
if (isLineTerminator(c)) {
|
||||
handleLineTerminator(state);
|
||||
return "done";
|
||||
}
|
||||
if (isBackspace(c)) {
|
||||
handleBackspace(state);
|
||||
return "skip";
|
||||
}
|
||||
if (c === "\u0003") {
|
||||
handleInterrupt(state.rawWasSet);
|
||||
}
|
||||
state.buf += c;
|
||||
process.stdout.write("*");
|
||||
return "append";
|
||||
}
|
||||
|
||||
/** Read a line with terminal echo disabled (for secrets). */
|
||||
async function promptSecret(label: string): Promise<string> {
|
||||
process.stdout.write(label);
|
||||
return new Promise((fulfill) => {
|
||||
const rawWasSet = process.stdin.isRaw;
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding("utf8");
|
||||
|
||||
const state: SecretInputState = { buf: "", rawWasSet, fulfill, onData: () => {} };
|
||||
|
||||
const onData = (chunk: string) => {
|
||||
for (const c of chunk.toString()) {
|
||||
if (processSecretChar(c, state) === "done") return;
|
||||
}
|
||||
};
|
||||
|
||||
state.onData = onData;
|
||||
process.stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch available models from an OpenAI-compatible /models endpoint. */
|
||||
async function fetchAvailableModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}/models`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setupDispatchLog("R5KH7WM3", `GET ${url} returned ${res.status}`);
|
||||
return [];
|
||||
}
|
||||
const body = (await res.json()) as OpenAiModelsResponse;
|
||||
if (!Array.isArray(body.data)) {
|
||||
return [];
|
||||
}
|
||||
// Filter out non-chat models. Some patterns are DashScope-specific (sambert, cosyvoice,
|
||||
// wordart, wanx, wan2, paraformer) but harmless for other providers.
|
||||
const NON_CHAT_RE =
|
||||
/speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|z-image|s2s|livetranslate|realtime|gui-/i;
|
||||
return body.data
|
||||
.map((m) => m.id)
|
||||
.filter((id) => !NON_CHAT_RE.test(id))
|
||||
.sort();
|
||||
} catch (e) {
|
||||
setupDispatchLog(
|
||||
"V8NQ4JT6",
|
||||
`fetch models failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
type PresetProvider = ReturnType<typeof loadPresetProviders>[number];
|
||||
|
||||
function printProviderMenu(presets: readonly PresetProvider[]): void {
|
||||
const numWidth = String(presets.length + 1).length;
|
||||
printCliLine("Select a provider:\n");
|
||||
for (let i = 0; i < presets.length; i++) {
|
||||
const p = presets.at(i);
|
||||
if (!p) continue;
|
||||
const num = String(i + 1).padStart(numWidth);
|
||||
printCliLine(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
|
||||
}
|
||||
const customNum = String(presets.length + 1).padStart(numWidth);
|
||||
printCliLine(` ${customNum}) Custom (enter name and URL manually)`);
|
||||
printCliLine("");
|
||||
}
|
||||
|
||||
async function selectProvider(
|
||||
rl: { question: (q: string) => Promise<string> },
|
||||
presets: readonly PresetProvider[],
|
||||
): Promise<Result<{ provider: string; baseUrl: string }, string>> {
|
||||
const choice = await promptLine(rl, `Choose [1-${presets.length + 1}]: `);
|
||||
const choiceNum = Number.parseInt(choice, 10);
|
||||
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > presets.length + 1) {
|
||||
return err(`invalid choice: ${choice}`);
|
||||
}
|
||||
|
||||
if (choiceNum <= presets.length) {
|
||||
const selected = presets.at(choiceNum - 1);
|
||||
if (!selected) return err(`invalid choice: ${choice}`);
|
||||
printCliLine(`\n → ${selected.label} (${selected.baseUrl})\n`);
|
||||
return ok({ provider: selected.name, baseUrl: selected.baseUrl });
|
||||
}
|
||||
|
||||
const provider = await promptLine(rl, "Provider name (e.g. my-proxy): ");
|
||||
if (provider === "") return err("provider name must not be empty");
|
||||
const baseUrl = await promptLine(rl, "OpenAI-compatible API base URL: ");
|
||||
if (baseUrl === "") return err("base URL must not be empty");
|
||||
return ok({ provider, baseUrl });
|
||||
}
|
||||
|
||||
function printModelList(models: string[]): void {
|
||||
const cols = process.stdout.columns || 80;
|
||||
const nw = String(models.length).length;
|
||||
const prefixLen = nw + 4;
|
||||
const maxModelLen = Math.max(...models.map((m) => m.length));
|
||||
const cellWidth = prefixLen + maxModelLen + 2;
|
||||
const numCols = Math.max(1, Math.floor(cols / cellWidth));
|
||||
for (let i = 0; i < models.length; i += numCols) {
|
||||
const cells: string[] = [];
|
||||
for (let j = i; j < Math.min(i + numCols, models.length); j++) {
|
||||
const num = String(j + 1).padStart(nw);
|
||||
const model = models.at(j) ?? "";
|
||||
cells.push(` ${num}) ${model.padEnd(maxModelLen + 2)}`);
|
||||
}
|
||||
printCliLine(cells.join(""));
|
||||
}
|
||||
}
|
||||
|
||||
async function selectModel(
|
||||
rl: { question: (q: string) => Promise<string> },
|
||||
models: string[],
|
||||
): Promise<Result<string, string>> {
|
||||
if (models.length > 0) {
|
||||
printCliLine(`\nAvailable models (${models.length}):\n`);
|
||||
printModelList(models);
|
||||
printCliLine(`\nChoose a number, or type a model name directly.`);
|
||||
const modelInput = await promptLine(rl, `Default model [1-${models.length}]: `);
|
||||
if (modelInput === "") return err("default model must not be empty");
|
||||
const modelNum = Number.parseInt(modelInput, 10);
|
||||
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
|
||||
return ok(models.at(modelNum - 1) ?? modelInput);
|
||||
}
|
||||
return ok(modelInput);
|
||||
}
|
||||
|
||||
printCliWarn("Could not fetch models (API may not support /models endpoint).");
|
||||
const modelInput = await promptLine(rl, `Default model (e.g. qwen-plus, gpt-4o): `);
|
||||
if (modelInput === "") return err("default model must not be empty");
|
||||
return ok(modelInput);
|
||||
}
|
||||
|
||||
async function selectWorkspace(rl: {
|
||||
question: (q: string) => Promise<string>;
|
||||
}): Promise<string | null> {
|
||||
while (true) {
|
||||
const wsPath = await promptLine(
|
||||
rl,
|
||||
"\nWorkflow workspace path (default: ./workflows, type 'skip' to skip): ",
|
||||
);
|
||||
if (wsPath.toLowerCase() === "skip") return null;
|
||||
const candidate = wsPath === "" ? "./workflows" : wsPath;
|
||||
const resolved = resolvePath(process.cwd(), candidate);
|
||||
if (existsSync(resolved)) {
|
||||
printCliWarn(`directory already exists: ${resolved}`);
|
||||
printCliLine("Please enter a different path, or type 'skip' to skip.");
|
||||
continue;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
function stripProviderPrefix(model: string): string {
|
||||
if (model.includes("/")) {
|
||||
return model.split("/").pop() ?? model;
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
async function collectInteractiveSetup(): Promise<Result<SetupCliArgs, string>> {
|
||||
const rl = createInterface({ input, output });
|
||||
try {
|
||||
printCliLine("Configure the LLM provider that workflow agents will use.\n");
|
||||
|
||||
const presets = loadPresetProviders();
|
||||
printProviderMenu(presets);
|
||||
|
||||
const providerResult = await selectProvider(rl, presets);
|
||||
if (!providerResult.ok) {
|
||||
rl.close();
|
||||
return providerResult;
|
||||
}
|
||||
const { provider, baseUrl } = providerResult.value;
|
||||
|
||||
rl.close();
|
||||
const apiKey = await promptSecret("API key for this provider: ");
|
||||
if (apiKey === "") return err("API key must not be empty");
|
||||
const rl2 = createInterface({ input, output });
|
||||
|
||||
printCliLine("\nFetching available models...");
|
||||
const models = await fetchAvailableModels(baseUrl, apiKey);
|
||||
const modelResult = await selectModel(rl2, models);
|
||||
if (!modelResult.ok) {
|
||||
rl2.close();
|
||||
return modelResult;
|
||||
}
|
||||
|
||||
const bare = stripProviderPrefix(modelResult.value);
|
||||
const defaultModel = `${provider}/${bare}`;
|
||||
printCliLine(` → ${defaultModel}`);
|
||||
|
||||
const initWorkspaceName = await selectWorkspace(rl2);
|
||||
rl2.close();
|
||||
|
||||
return ok({ provider, baseUrl, apiKey, defaultModel, initWorkspaceName });
|
||||
} catch (e) {
|
||||
return err(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
export async function dispatchSetup(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseSetupArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${parsed.error}\n\n${usageSetup()}`);
|
||||
return 1;
|
||||
}
|
||||
if (parsed.value === "help") {
|
||||
printCliLine(usageSetup());
|
||||
return 0;
|
||||
}
|
||||
|
||||
let args: SetupCliArgs;
|
||||
if (parsed.value === "interactive") {
|
||||
const collected = await collectInteractiveSetup();
|
||||
if (!collected.ok) {
|
||||
printCliError(collected.error);
|
||||
return 1;
|
||||
}
|
||||
args = collected.value;
|
||||
} else {
|
||||
args = parsed.value;
|
||||
}
|
||||
|
||||
const result = await cmdSetup(storageRoot, args);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printSetupSummary(result.value);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { dispatchSetup } from "./dispatch.js";
|
||||
export { loadPresetProviders } from "./preset-providers.js";
|
||||
export { cmdSetup, printSetupSummary } from "./setup.js";
|
||||
export type { CmdSetupSuccess, PresetProvider, SetupCliArgs } from "./types.js";
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
import type { PresetProvider } from "./types.js";
|
||||
|
||||
type RawPresetEntry = {
|
||||
name: unknown;
|
||||
label: unknown;
|
||||
baseUrl: unknown;
|
||||
};
|
||||
|
||||
function isRawEntry(v: unknown): v is RawPresetEntry {
|
||||
if (typeof v !== "object" || v === null) return false;
|
||||
const o = v as Record<string, unknown>;
|
||||
return typeof o.name === "string" && typeof o.label === "string" && typeof o.baseUrl === "string";
|
||||
}
|
||||
|
||||
let cached: ReadonlyArray<PresetProvider> | null = null;
|
||||
|
||||
export function loadPresetProviders(): ReadonlyArray<PresetProvider> {
|
||||
if (cached !== null) return cached;
|
||||
|
||||
const yamlPath = join(import.meta.dirname, "providers.yaml");
|
||||
const raw = readFileSync(yamlPath, "utf8");
|
||||
const parsed: unknown = parseYaml(raw);
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`providers.yaml: expected array, got ${typeof parsed}`);
|
||||
}
|
||||
|
||||
const result: PresetProvider[] = [];
|
||||
for (const entry of parsed) {
|
||||
if (!isRawEntry(entry)) {
|
||||
throw new Error(`providers.yaml: invalid entry: ${JSON.stringify(entry)}`);
|
||||
}
|
||||
result.push({
|
||||
name: entry.name as string,
|
||||
label: entry.label as string,
|
||||
baseUrl: entry.baseUrl as string,
|
||||
});
|
||||
}
|
||||
|
||||
cached = result;
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# Preset LLM providers for `uncaged-workflow setup`.
|
||||
# Each entry needs a provider name (used in workflow.yaml) and an OpenAI-compatible base URL.
|
||||
# Add new providers here — no code changes required.
|
||||
|
||||
# ── International ──────────────────────────────────────────
|
||||
|
||||
- name: openai
|
||||
label: OpenAI
|
||||
baseUrl: https://api.openai.com/v1
|
||||
|
||||
- name: xai
|
||||
label: xAI
|
||||
baseUrl: https://api.x.ai/v1
|
||||
|
||||
- name: openrouter
|
||||
label: OpenRouter
|
||||
baseUrl: https://openrouter.ai/api/v1
|
||||
|
||||
- name: venice
|
||||
label: Venice
|
||||
baseUrl: https://api.venice.ai/api/v1
|
||||
|
||||
# ── China ──────────────────────────────────────────────────
|
||||
|
||||
- name: dashscope
|
||||
label: DashScope (Alibaba)
|
||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
|
||||
- name: deepseek
|
||||
label: DeepSeek
|
||||
baseUrl: https://api.deepseek.com/v1
|
||||
|
||||
- name: siliconflow
|
||||
label: SiliconFlow
|
||||
baseUrl: https://api.siliconflow.cn/v1
|
||||
|
||||
- name: volcengine
|
||||
label: Volcengine (ByteDance)
|
||||
baseUrl: https://ark.cn-beijing.volces.com/api/v3
|
||||
|
||||
- name: kimi
|
||||
label: Kimi (Moonshot)
|
||||
baseUrl: https://api.moonshot.cn/v1
|
||||
|
||||
- name: glm
|
||||
label: GLM (Zhipu AI)
|
||||
baseUrl: https://open.bigmodel.cn/api/paas/v4
|
||||
|
||||
- name: glm-intl
|
||||
label: GLM (Zhipu AI Intl)
|
||||
baseUrl: https://api.z.ai/api/paas/v4
|
||||
|
||||
- name: stepfun
|
||||
label: StepFun
|
||||
baseUrl: https://api.stepfun.com/v1
|
||||
|
||||
- name: minimax
|
||||
label: MiniMax
|
||||
baseUrl: https://api.minimax.io/v1
|
||||
|
||||
- name: tencent
|
||||
label: Tencent TokenHub
|
||||
baseUrl: https://tokenhub.tencentmaas.com/v1
|
||||
|
||||
- name: xiaomi
|
||||
label: Xiaomi MiMo
|
||||
baseUrl: https://api.xiaomimimo.com/v1
|
||||
|
||||
# ── Local ──────────────────────────────────────────────────
|
||||
|
||||
- name: ollama
|
||||
label: Ollama (local)
|
||||
baseUrl: http://localhost:11434/v1
|
||||
@@ -0,0 +1,103 @@
|
||||
import { err, ok, type Result, type WorkflowConfig } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
readWorkflowRegistry,
|
||||
splitProviderModelRef,
|
||||
workflowRegistryPath,
|
||||
writeWorkflowRegistry,
|
||||
} from "@uncaged/workflow-register";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
import { cmdInitWorkspace } from "../init/index.js";
|
||||
import type { CmdSetupSuccess, SetupCliArgs } from "./types.js";
|
||||
|
||||
const setupLog = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
function mergeWorkflowConfig(
|
||||
prev: WorkflowConfig | null,
|
||||
input: SetupCliArgs,
|
||||
): Result<WorkflowConfig, string> {
|
||||
const modelSplit = splitProviderModelRef(input.defaultModel);
|
||||
if (!modelSplit.ok) {
|
||||
return err(modelSplit.error);
|
||||
}
|
||||
if (modelSplit.value.providerName !== input.provider) {
|
||||
return err(
|
||||
`default model provider "${modelSplit.value.providerName}" must match --provider "${input.provider}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const maxDepth = prev === null ? 3 : prev.maxDepth;
|
||||
const supervisorInterval = prev === null ? 3 : prev.supervisorInterval;
|
||||
const providers = {
|
||||
...(prev === null ? {} : prev.providers),
|
||||
[input.provider]: { baseUrl: input.baseUrl, apiKey: input.apiKey },
|
||||
};
|
||||
const models = { ...(prev === null ? {} : prev.models), default: input.defaultModel };
|
||||
|
||||
return ok({
|
||||
maxDepth,
|
||||
supervisorInterval,
|
||||
providers,
|
||||
models,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cmdSetup(
|
||||
storageRoot: string,
|
||||
input: SetupCliArgs,
|
||||
): Promise<Result<CmdSetupSuccess, string>> {
|
||||
const readResult = await readWorkflowRegistry(storageRoot);
|
||||
if (!readResult.ok) {
|
||||
setupLog("W8JH4Q2K", `read workflow registry failed: ${readResult.error.message}`);
|
||||
return err(readResult.error.message);
|
||||
}
|
||||
|
||||
const current = readResult.value;
|
||||
const merged = mergeWorkflowConfig(current.config, input);
|
||||
if (!merged.ok) {
|
||||
return merged;
|
||||
}
|
||||
const nextConfig = merged.value;
|
||||
const nextRegistry = {
|
||||
config: nextConfig,
|
||||
workflows: current.workflows,
|
||||
};
|
||||
|
||||
const written = await writeWorkflowRegistry(storageRoot, nextRegistry);
|
||||
if (!written.ok) {
|
||||
setupLog("M2NB5VX9", `write workflow registry failed: ${written.error.message}`);
|
||||
return err(written.error.message);
|
||||
}
|
||||
|
||||
const registryPath = workflowRegistryPath(storageRoot);
|
||||
|
||||
let initWorkspaceRootPath: string | null = null;
|
||||
if (input.initWorkspaceName !== null) {
|
||||
const initResult = await cmdInitWorkspace(process.cwd(), input.initWorkspaceName);
|
||||
if (!initResult.ok) {
|
||||
setupLog("T7QC4HWP", `init workspace failed: ${initResult.error}`);
|
||||
return err(initResult.error);
|
||||
}
|
||||
initWorkspaceRootPath = initResult.value.rootPath;
|
||||
}
|
||||
|
||||
return ok({
|
||||
registryPath,
|
||||
provider: input.provider,
|
||||
defaultModel: input.defaultModel,
|
||||
maxDepth: nextConfig.maxDepth,
|
||||
supervisorInterval: nextConfig.supervisorInterval,
|
||||
initWorkspaceRootPath,
|
||||
});
|
||||
}
|
||||
|
||||
export function printSetupSummary(result: CmdSetupSuccess): void {
|
||||
printCliLine(`wrote registry: ${result.registryPath}`);
|
||||
printCliLine(`provider "${result.provider}" (baseUrl + apiKey updated)`);
|
||||
printCliLine(`config.models.default = "${result.defaultModel}"`);
|
||||
printCliLine(`maxDepth=${result.maxDepth}, supervisorInterval=${result.supervisorInterval}`);
|
||||
if (result.initWorkspaceRootPath !== null) {
|
||||
printCliLine(`initialized workflow workspace at ${result.initWorkspaceRootPath}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Parsed non-interactive `setup` CLI arguments (all fields required for agent mode). */
|
||||
export type SetupCliArgs = {
|
||||
provider: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
defaultModel: string;
|
||||
initWorkspaceName: string | null;
|
||||
};
|
||||
|
||||
export type PresetProvider = {
|
||||
name: string;
|
||||
label: string;
|
||||
baseUrl: string;
|
||||
};
|
||||
|
||||
export type CmdSetupSuccess = {
|
||||
registryPath: string;
|
||||
provider: string;
|
||||
defaultModel: string;
|
||||
maxDepth: number;
|
||||
supervisorInterval: number;
|
||||
initWorkspaceRootPath: string | null;
|
||||
};
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { Result } from "@uncaged/workflow";
|
||||
import type { Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import {
|
||||
readWorkerCtl,
|
||||
+2
-7
@@ -26,12 +26,7 @@ export async function dispatchRun(storageRoot: string, argv: string[]): Promise<
|
||||
return 1;
|
||||
}
|
||||
|
||||
const result = await cmdRun(
|
||||
storageRoot,
|
||||
parsed.value.name,
|
||||
parsed.value.prompt,
|
||||
parsed.value.maxRounds,
|
||||
);
|
||||
const result = await cmdRun(storageRoot, parsed.value.name, parsed.value.prompt);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
@@ -166,7 +161,7 @@ export async function dispatchFork(storageRoot: string, argv: string[]): Promise
|
||||
export const THREAD_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
run: {
|
||||
handler: dispatchRun,
|
||||
args: "<name> [--prompt <text>] [--max-rounds N]",
|
||||
args: "<name> [--prompt <text>]",
|
||||
description: "Start a new thread executing a workflow",
|
||||
},
|
||||
list: {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import type { ParsedForkArgv } from "./types.js";
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { join } from "node:path";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { prepareCasFork } from "@uncaged/workflow-execute";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { generateUlid, getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
import { resolveThreadRecord } from "../../thread-scan.js";
|
||||
import { ensureWorkerForHash, sendWorkerTcpCommand } from "../../worker-spawn.js";
|
||||
|
||||
export async function cmdFork(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
fromRole: string | null,
|
||||
): Promise<Result<{ threadId: string }, string>> {
|
||||
const resolved = await resolveThreadRecord(storageRoot, threadId);
|
||||
if (resolved === null) {
|
||||
return err(`thread not found: ${threadId}`);
|
||||
}
|
||||
|
||||
const bundlePath = join(storageRoot, "bundles", `${resolved.bundleHash}.esm.js`);
|
||||
if (!(await pathExists(bundlePath))) {
|
||||
return err(`bundle file missing for thread hash ${resolved.bundleHash}`);
|
||||
}
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const newThreadId = generateUlid(Date.now());
|
||||
|
||||
const plan = await prepareCasFork({
|
||||
cas,
|
||||
bundleDir: resolved.bundleDir,
|
||||
bundleHash: resolved.bundleHash,
|
||||
sourceThreadId: threadId,
|
||||
headHash: resolved.head,
|
||||
startHash: resolved.start,
|
||||
newThreadId,
|
||||
fromRole,
|
||||
});
|
||||
if (!plan.ok) {
|
||||
return plan;
|
||||
}
|
||||
|
||||
const worker = await ensureWorkerForHash(storageRoot, plan.value.hash, bundlePath);
|
||||
if (!worker.ok) {
|
||||
return worker;
|
||||
}
|
||||
|
||||
const p = plan.value;
|
||||
const sent = await sendWorkerTcpCommand(
|
||||
worker.value.port,
|
||||
{
|
||||
type: "run",
|
||||
threadId: newThreadId,
|
||||
workflowName: p.workflowName,
|
||||
prompt: p.prompt,
|
||||
options: p.runOptions,
|
||||
steps: p.steps,
|
||||
stepTimestamps: p.stepTimestamps.length > 0 ? p.stepTimestamps : null,
|
||||
forkSourceThreadId: threadId,
|
||||
forkContinuation: p.forkContinuation,
|
||||
},
|
||||
{ awaitResponseLine: false },
|
||||
);
|
||||
if (!sent.ok) {
|
||||
return sent;
|
||||
}
|
||||
|
||||
return ok({ threadId: newThreadId });
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user