diff --git a/Cargo.lock b/Cargo.lock index e1b1bca..3b60a39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,27 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 + +[[package]] +name = "agent-teams" +version = "0.1.0" +dependencies = [ + "async-trait", + "cc-sdk", + "chrono", + "dirs 6.0.0", + "fs2", + "futures", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "uuid", + "which 7.0.3", +] [[package]] name = "aho-corasick" @@ -76,12 +97,57 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.10.0" @@ -110,6 +176,32 @@ dependencies = [ "shlex", ] +[[package]] +name = "cc-sdk" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9744c467f51ff30fa405be7543ccfe49627a29d468d6946061f77745abcc6f03" +dependencies = [ + "async-stream", + "async-trait", + "bytes", + "crossbeam-channel", + "dirs 5.0.1", + "futures", + "libc", + "pin-project-lite", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", + "uuid", + "which 6.0.3", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -197,7 +289,7 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", - "which", + "which 7.0.3", ] [[package]] @@ -206,6 +298,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -257,13 +359,14 @@ dependencies = [ "serde", "tempfile", "toml", - "which", + "which 7.0.3", ] [[package]] name = "csa-core" version = "0.1.0" dependencies = [ + "agent-teams", "chrono", "clap", "serde", @@ -275,7 +378,9 @@ dependencies = [ name = "csa-executor" version = "0.1.0" dependencies = [ + "agent-teams", "anyhow", + "async-trait", "chrono", "csa-core", "csa-process", @@ -403,7 +508,25 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", ] [[package]] @@ -414,16 +537,48 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.4.6", "windows-sys 0.48.0", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "env_home" version = "0.1.0" @@ -459,7 +614,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", - "rustix", + "rustix 1.1.3", "windows-sys 0.59.0", ] @@ -469,6 +624,120 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -492,6 +761,25 @@ dependencies = [ "wasip2", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -504,6 +792,87 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http", + "hyper", + "rustls", + "tokio", + "tokio-rustls", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -528,6 +897,108 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -538,6 +1009,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -578,16 +1055,28 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags", + "bitflags 2.10.0", "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "lock_api" version = "0.4.14" @@ -618,6 +1107,12 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "mio" version = "1.1.1" @@ -703,12 +1198,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -748,14 +1264,35 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -765,7 +1302,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -803,7 +1349,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.10.0", ] [[package]] @@ -814,7 +1360,18 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 1.0.69", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", ] [[package]] @@ -846,31 +1403,146 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-rustls", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-rustls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "serde" version = "1.0.228" @@ -923,6 +1595,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -948,12 +1632,28 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.2" @@ -964,6 +1664,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -981,6 +1687,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sysinfo" version = "0.32.1" @@ -995,6 +1718,27 @@ dependencies = [ "windows", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tempfile" version = "3.24.0" @@ -1004,7 +1748,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -1088,6 +1832,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.49.0" @@ -1100,7 +1854,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.2", "tokio-macros", "windows-sys 0.61.2", ] @@ -1116,6 +1870,41 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -1157,6 +1946,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -1230,13 +2025,19 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ulid" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" dependencies = [ - "rand", + "rand 0.9.2", "serde", "web-time", ] @@ -1247,18 +2048,63 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1287,6 +2133,20 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.108" @@ -1319,6 +2179,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web-time" version = "1.1.0" @@ -1329,6 +2199,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "which" +version = "6.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix 0.38.44", + "winsafe", +] + [[package]] name = "which" version = "7.0.3" @@ -1337,7 +2225,7 @@ checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" dependencies = [ "either", "env_home", - "rustix", + "rustix 1.1.3", "winsafe", ] @@ -1484,6 +2372,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -1706,6 +2603,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winsafe" version = "0.0.19" @@ -1718,6 +2625,35 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.39" @@ -1738,6 +2674,60 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.19" diff --git a/Cargo.toml b/Cargo.toml index 38b203a..ab076e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,8 +4,8 @@ resolver = "2" [workspace.package] version = "0.1.0" -edition = "2021" -rust-version = "1.80" +edition = "2024" +rust-version = "1.85" license = "Apache-2.0" [workspace.dependencies] @@ -20,6 +20,7 @@ csa-resource = { path = "crates/csa-resource" } csa-scheduler = { path = "crates/csa-scheduler" } csa-todo = { path = "crates/csa-todo" } csa-hooks = { path = "crates/csa-hooks" } +agent-teams = { path = "../agent-teams-rs" } # CLI & Async clap = { version = "4.5", features = ["derive"] } @@ -35,6 +36,7 @@ ulid = { version = "1.1", features = ["serde"] } # Error handling & logging anyhow = "1.0" thiserror = "2.0" +async-trait = "0.1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2" diff --git a/README.md b/README.md index c0c627c..531971b 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,22 @@ If you only use a single AI tool for simple tasks, the tool's native CLI may suf ## Installation +## Prerequisites + +Requires [Rust toolchain](https://rustup.rs/) and a sibling checkout for the local path crate dependency: + +```toml +agent-teams = { path = "../agent-teams-rs" } +``` + +Expected directory layout: + +```text +/ + cli-sub-agent/ + agent-teams-rs/ +``` + ### Quick Install (macOS / Linux) ```bash @@ -50,17 +66,18 @@ curl -sSf https://raw.githubusercontent.com/RyderFreeman4Logos/cli-sub-agent/mai ### Manual Install -Requires [Rust toolchain](https://rustup.rs/): +Clone both repositories into the same parent directory, then install from source: ```bash -cargo install --git https://github.com/RyderFreeman4Logos/cli-sub-agent \ - -p cli-sub-agent --all-features --locked +git clone https://github.com/RyderFreeman4Logos/cli-sub-agent.git +git clone https://github.com/RyderFreeman4Logos/agent-teams-rs.git +cd cli-sub-agent +cargo install --all-features --path crates/cli-sub-agent ``` ### From Source ```bash -git clone https://github.com/RyderFreeman4Logos/cli-sub-agent cd cli-sub-agent cargo install --all-features --path crates/cli-sub-agent ``` diff --git a/crates/cli-sub-agent/src/batch.rs b/crates/cli-sub-agent/src/batch.rs index ede3e95..8971a98 100644 --- a/crates/cli-sub-agent/src/batch.rs +++ b/crates/cli-sub-agent/src/batch.rs @@ -387,7 +387,7 @@ async fn execute_parallel_tasks( let config = config.cloned(); // Check resource availability before spawning (best effort) - if let Some(ref mut guard) = resource_guard { + if let Some(guard) = resource_guard { let tool_name = parse_tool_name(&task.tool)?; if let Err(e) = guard.check_availability(tool_name.as_str()) { warn!( @@ -497,7 +497,7 @@ async fn execute_task( }; // Check resource availability - if let Some(ref mut guard) = resource_guard { + if let Some(guard) = resource_guard { if let Err(e) = guard.check_availability(executor.tool_name()) { error!("{} - Resource check failed: {}", task_label, e); return TaskResult { diff --git a/crates/cli-sub-agent/src/cli.rs b/crates/cli-sub-agent/src/cli.rs index 454d783..0f2f059 100644 --- a/crates/cli-sub-agent/src/cli.rs +++ b/crates/cli-sub-agent/src/cli.rs @@ -1,4 +1,4 @@ -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; use csa_core::types::{OutputFormat, ToolArg, ToolName}; #[derive(Parser)] @@ -215,6 +215,18 @@ pub struct ReviewArgs { #[arg(long)] pub context: Option, + /// Number of reviewers to run in parallel (default: 1) + #[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u32).range(1..))] + pub reviewers: u32, + + /// Consensus strategy for multi-reviewer mode + #[arg( + long, + default_value = "majority", + value_parser = ["majority", "weighted", "unanimous"] + )] + pub consensus: String, + /// Working directory #[arg(long)] pub cd: Option, @@ -578,4 +590,26 @@ pub enum TodoCommands { #[arg(long)] cd: Option, }, + + /// Visualize TODO task dependency DAG + Dag { + /// Timestamp of the TODO plan (default: latest) + #[arg(short, long)] + timestamp: Option, + + /// DAG output format + #[arg(long, default_value = "mermaid")] + format: TodoDagFormat, + + /// Working directory + #[arg(long)] + cd: Option, + }, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum TodoDagFormat { + Mermaid, + Terminal, + Dot, } diff --git a/crates/cli-sub-agent/src/config_cmds.rs b/crates/cli-sub-agent/src/config_cmds.rs index a8a00f0..d0991c7 100644 --- a/crates/cli-sub-agent/src/config_cmds.rs +++ b/crates/cli-sub-agent/src/config_cmds.rs @@ -2,7 +2,7 @@ use anyhow::Result; use tracing::{error, warn}; use csa_config::init::init_project; -use csa_config::{validate_config, GlobalConfig, ProjectConfig}; +use csa_config::{GlobalConfig, ProjectConfig, validate_config}; use csa_core::types::OutputFormat; pub(crate) fn handle_config_show(cd: Option, format: OutputFormat) -> Result<()> { diff --git a/crates/cli-sub-agent/src/debate_cmd.rs b/crates/cli-sub-agent/src/debate_cmd.rs index 693c5f4..d07e180 100644 --- a/crates/cli-sub-agent/src/debate_cmd.rs +++ b/crates/cli-sub-agent/src/debate_cmd.rs @@ -6,6 +6,7 @@ use crate::run_helpers::read_prompt; use csa_config::global::{heterogeneous_counterpart, select_heterogeneous_tool}; use csa_config::{GlobalConfig, ProjectConfig}; use csa_core::types::ToolName; +use csa_executor::extract_session_id; pub(crate) async fn handle_debate(args: DebateArgs, current_depth: u32) -> Result { // 1. Determine project root @@ -56,7 +57,7 @@ pub(crate) async fn handle_debate(args: DebateArgs, current_depth: u32) -> Resul "debate: {}", crate::run_helpers::truncate_prompt(&question, 80) ); - let result = crate::pipeline::execute_with_session( + let execution = crate::pipeline::execute_with_session_and_meta( &executor, &tool, &prompt, @@ -71,10 +72,17 @@ pub(crate) async fn handle_debate(args: DebateArgs, current_depth: u32) -> Resul ) .await?; + let provider_session_id = extract_session_id(&tool, &execution.execution.output); + let output = render_debate_output( + &execution.execution.output, + &execution.meta_session_id, + provider_session_id.as_deref(), + ); + // 10. Print result - print!("{}", result.output); + print!("{output}"); - Ok(result.exit_code) + Ok(execution.execution.exit_code) } fn resolve_debate_tool( @@ -203,6 +211,24 @@ fn build_debate_instruction(question: &str, is_continuation: bool) -> String { } } +fn render_debate_output( + tool_output: &str, + meta_session_id: &str, + provider_session_id: Option<&str>, +) -> String { + let mut output = match provider_session_id { + Some(provider_id) => tool_output.replace(provider_id, meta_session_id), + None => tool_output.to_string(), + }; + + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + + output.push_str(&format!("CSA Meta Session ID: {meta_session_id}\n")); + output +} + #[cfg(test)] mod tests { use super::*; @@ -293,9 +319,10 @@ mod tests { std::path::Path::new("/tmp/test-project"), ) .unwrap_err(); - assert!(err - .to_string() - .contains("AUTO debate tool selection failed")); + assert!( + err.to_string() + .contains("AUTO debate tool selection failed") + ); } #[test] @@ -310,9 +337,10 @@ mod tests { std::path::Path::new("/tmp/test-project"), ) .unwrap_err(); - assert!(err - .to_string() - .contains("AUTO debate tool selection failed")); + assert!( + err.to_string() + .contains("AUTO debate tool selection failed") + ); } #[test] @@ -368,4 +396,22 @@ mod tests { assert!(prompt.contains("continuation=true")); assert!(prompt.contains("I disagree because X")); } + + #[test] + fn render_debate_output_appends_meta_session_id() { + let output = render_debate_output("debate answer", "01ARZ3NDEKTSV4RRFFQ69G5FAV", None); + assert!(output.contains("debate answer")); + assert!(output.contains("CSA Meta Session ID: 01ARZ3NDEKTSV4RRFFQ69G5FAV")); + } + + #[test] + fn render_debate_output_replaces_provider_id_with_meta_id() { + let provider = "019c5589-3c84-7f03-b9c4-9f0a164c4eb2"; + let meta = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; + let tool_output = format!("session_id={provider}\nresult=ok"); + + let output = render_debate_output(&tool_output, meta, Some(provider)); + assert!(!output.contains(provider)); + assert!(output.contains(meta)); + } } diff --git a/crates/cli-sub-agent/src/gc.rs b/crates/cli-sub-agent/src/gc.rs index c18af2b..3906e5a 100644 --- a/crates/cli-sub-agent/src/gc.rs +++ b/crates/cli-sub-agent/src/gc.rs @@ -5,7 +5,7 @@ use tracing::{info, warn}; use csa_config::GlobalConfig; use csa_core::types::OutputFormat; use csa_session::{ - delete_session, get_session_dir, get_session_root, list_sessions, save_session_in, PhaseEvent, + PhaseEvent, delete_session, get_session_dir, get_session_root, list_sessions, save_session_in, }; /// Default age threshold (in days) for retiring stale Active sessions. @@ -198,7 +198,8 @@ pub(crate) fn handle_gc( if dry_run { eprintln!( "[dry-run] Would clean stale slot: {:?} (dead PID {})", - path.file_name(), pid + path.file_name(), + pid ); stale_slots_cleaned += 1; } else if fs::remove_file(&path).is_ok() { @@ -522,7 +523,8 @@ pub(crate) fn handle_gc_global( if dry_run { eprintln!( "[dry-run] Would clean stale slot: {:?} (dead PID {})", - path.file_name(), pid + path.file_name(), + pid ); stale_slots_cleaned += 1; } else if fs::remove_file(&path).is_ok() { diff --git a/crates/cli-sub-agent/src/main.rs b/crates/cli-sub-agent/src/main.rs index 17786ad..498693a 100644 --- a/crates/cli-sub-agent/src/main.rs +++ b/crates/cli-sub-agent/src/main.rs @@ -14,6 +14,7 @@ mod mcp_server; mod pipeline; mod process_tree; mod review_cmd; +mod review_consensus; mod run_helpers; mod self_update; mod session_cmds; @@ -29,7 +30,7 @@ use cli::{ use csa_config::GlobalConfig; use csa_core::types::{OutputFormat, ToolArg, ToolSelectionStrategy}; use csa_lock::slot::{ - format_slot_diagnostic, slot_usage, try_acquire_slot, SlotAcquireResult, ToolSlot, + SlotAcquireResult, ToolSlot, format_slot_diagnostic, slot_usage, try_acquire_slot, }; use csa_session::{load_session, resolve_session_prefix}; use run_helpers::{ @@ -244,6 +245,13 @@ async fn main() -> Result<()> { } => { todo_cmd::handle_status(timestamp, status, cd)?; } + TodoCommands::Dag { + timestamp, + format, + cd, + } => { + todo_cmd::handle_dag(timestamp, format, cd)?; + } }, Commands::SelfUpdate { check } => { self_update::handle_self_update(check)?; @@ -369,7 +377,9 @@ async fn handle_run( } } else { // No parent context/default fallback, fall back to AnyAvailable with warning - warn!("HeterogeneousStrict requested but no parent tool context/defaults.tool found. Falling back to AnyAvailable."); + warn!( + "HeterogeneousStrict requested but no parent tool context/defaults.tool found. Falling back to AnyAvailable." + ); resolve_tool_and_model( None, model_spec.as_deref(), diff --git a/crates/cli-sub-agent/src/pipeline.rs b/crates/cli-sub-agent/src/pipeline.rs index 8ff9380..2ea321e 100644 --- a/crates/cli-sub-agent/src/pipeline.rs +++ b/crates/cli-sub-agent/src/pipeline.rs @@ -8,19 +8,19 @@ use anyhow::{Context, Result}; use std::fs; use std::path::{Path, PathBuf}; -use tokio::signal::unix::{signal, SignalKind}; +use tokio::signal::unix::{SignalKind, signal}; use tracing::{error, info, warn}; use csa_config::{GlobalConfig, ProjectConfig}; use csa_core::types::ToolName; -use csa_executor::{create_session_log_writer, Executor}; -use csa_hooks::{global_hooks_path, load_hooks_config, run_hooks_for_event, HookEvent}; +use csa_executor::{Executor, create_session_log_writer}; +use csa_hooks::{HookEvent, global_hooks_path, load_hooks_config, run_hooks_for_event}; use csa_lock::acquire_lock; -use csa_process::check_tool_installed; +use csa_process::{ExecutionResult, check_tool_installed}; use csa_resource::{MemoryMonitor, ResourceGuard, ResourceLimits}; use csa_session::{ - create_session, get_session_dir, load_session, resolve_session_prefix, save_result, - save_session, SessionResult, TokenUsage, ToolState, + SessionResult, TokenUsage, ToolState, create_session, get_session_dir, save_result, + save_session, }; use crate::run_helpers::{is_compress_command, parse_token_usage, truncate_prompt}; @@ -188,6 +188,12 @@ fn write_pre_exec_error_result( } } +/// Execution result with the resolved CSA meta session ID used by this run. +pub(crate) struct SessionExecutionResult { + pub execution: ExecutionResult, + pub meta_session_id: String, +} + #[allow(clippy::too_many_arguments)] pub(crate) async fn execute_with_session( executor: &Executor, @@ -201,7 +207,39 @@ pub(crate) async fn execute_with_session( extra_env: Option<&std::collections::HashMap>, task_type: Option<&str>, tier_name: Option<&str>, -) -> Result { +) -> Result { + let execution = execute_with_session_and_meta( + executor, + tool, + prompt, + session_arg, + description, + parent, + project_root, + config, + extra_env, + task_type, + tier_name, + ) + .await?; + + Ok(execution.execution) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn execute_with_session_and_meta( + executor: &Executor, + tool: &ToolName, + prompt: &str, + session_arg: Option, + description: Option, + parent: Option, + project_root: &Path, + config: Option<&ProjectConfig>, + extra_env: Option<&std::collections::HashMap>, + task_type: Option<&str>, + tier_name: Option<&str>, +) -> Result { // Check for parent session violation: a child process must not operate on its own session if let Some(ref session_id) = session_arg { if let Ok(env_session) = std::env::var("CSA_SESSION_ID") { @@ -212,12 +250,19 @@ pub(crate) async fn execute_with_session( } // Resolve or create session + let mut resolved_provider_session_id: Option = None; let mut session = if let Some(ref session_id) = session_arg { - let sessions_dir = csa_session::get_session_root(project_root)?.join("sessions"); - let resolved_id = resolve_session_prefix(&sessions_dir, session_id)?; - // Validate tool access before loading - csa_session::validate_tool_access(project_root, &resolved_id, tool.as_str())?; - load_session(project_root, &resolved_id)? + let resolution = + csa_session::resolve_resume_session(project_root, session_id, tool.as_str())?; + resolved_provider_session_id = resolution.provider_session_id; + if resolved_provider_session_id.is_some() { + info!( + session = %resolution.meta_session_id, + tool = %executor.tool_name(), + "Resolved provider session ID from state.toml" + ); + } + csa_session::load_session(project_root, &resolution.meta_session_id)? } else { // Auto-generate description from prompt when not provided let effective_description = description.or_else(|| Some(truncate_prompt(prompt, 80))); @@ -293,7 +338,7 @@ pub(crate) async fn execute_with_session( info!("Executing in session: {}", session.meta_session_id); // Apply restrictions if configured - let can_edit = config.map_or(true, |cfg| cfg.can_tool_edit_existing(executor.tool_name())); + let can_edit = config.is_none_or(|cfg| cfg.can_tool_edit_existing(executor.tool_name())); let effective_prompt = if !can_edit { info!(tool = %executor.tool_name(), "Applying edit restriction: tool cannot modify existing files"); executor.apply_restrictions(prompt, false) @@ -302,7 +347,21 @@ pub(crate) async fn execute_with_session( }; // Build command - let tool_state = session.tools.get(executor.tool_name()).cloned(); + let tool_state = session + .tools + .get(executor.tool_name()) + .cloned() + .or_else(|| { + resolved_provider_session_id + .as_ref() + .map(|provider_session_id| ToolState { + provider_session_id: Some(provider_session_id.clone()), + last_action_summary: String::new(), + last_exit_code: 0, + updated_at: chrono::Utc::now(), + token_usage: None, + }) + }); let (cmd, stdin_data) = executor.build_command(&effective_prompt, tool_state.as_ref(), &session, extra_env); @@ -516,7 +575,10 @@ pub(crate) async fn execute_with_session( warn!("SessionComplete hook failed: {}", e); } - Ok(result) + Ok(SessionExecutionResult { + execution: result, + meta_session_id: session.meta_session_id.clone(), + }) } pub(crate) fn determine_project_root(cd: Option<&str>) -> Result { diff --git a/crates/cli-sub-agent/src/process_tree.rs b/crates/cli-sub-agent/src/process_tree.rs index f5026dd..f54eabe 100644 --- a/crates/cli-sub-agent/src/process_tree.rs +++ b/crates/cli-sub-agent/src/process_tree.rs @@ -76,7 +76,7 @@ fn read_ppid(pid: u32) -> Option { let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; let idx = stat.rfind(')')?; let after_comm = stat.get(idx + 2..)?; // skip ") " - // Fields after comm: state ppid ... + // Fields after comm: state ppid ... after_comm.split_whitespace().nth(1)?.parse().ok() } diff --git a/crates/cli-sub-agent/src/review_cmd.rs b/crates/cli-sub-agent/src/review_cmd.rs index bd76b9a..85f3d2c 100644 --- a/crates/cli-sub-agent/src/review_cmd.rs +++ b/crates/cli-sub-agent/src/review_cmd.rs @@ -1,12 +1,28 @@ use anyhow::{Context, Result}; use std::path::Path; +use tokio::task::JoinSet; use tracing::{debug, info}; use crate::cli::ReviewArgs; +use crate::review_consensus::{ + CLEAN, agreement_level, build_multi_reviewer_instruction, build_reviewer_tools, + consensus_strategy_label, consensus_verdict, parse_consensus_strategy, parse_review_verdict, + resolve_consensus, +}; use csa_config::global::{heterogeneous_counterpart, select_heterogeneous_tool}; use csa_config::{GlobalConfig, ProjectConfig}; +use csa_core::consensus::AgentResponse; use csa_core::types::ToolName; +#[derive(Debug, Clone)] +struct ReviewerOutcome { + reviewer_index: usize, + tool: ToolName, + output: String, + exit_code: i32, + verdict: &'static str, +} + pub(crate) async fn handle_review(args: ReviewArgs, current_depth: u32) -> Result { // 1. Determine project root let project_root = crate::pipeline::determine_project_root(args.cd.as_deref())?; @@ -43,57 +59,175 @@ pub(crate) async fn handle_review(args: ReviewArgs, current_depth: u32) -> Resul &project_root, )?; - // 6. Build executor and validate tool + if args.reviewers == 1 { + // Keep single-reviewer behavior unchanged. + let result = execute_review( + tool, + prompt, + args.session, + args.model, + format!( + "review: {}", + crate::run_helpers::truncate_prompt(&scope, 80) + ), + &project_root, + config.as_ref(), + &global_config, + ) + .await?; + print!("{}", result.output); + return Ok(result.exit_code); + } + + if args.fix { + anyhow::bail!("--fix is not supported when --reviewers > 1"); + } + if args.session.is_some() { + anyhow::bail!("--session is only supported when --reviewers=1"); + } + + let reviewers = args.reviewers as usize; + let consensus_strategy = parse_consensus_strategy(&args.consensus)?; + let reviewer_tools = build_reviewer_tools(args.tool, tool, config.as_ref(), reviewers); + + let mut join_set = JoinSet::new(); + for (reviewer_index, reviewer_tool) in reviewer_tools.into_iter().enumerate() { + let reviewer_prompt = + build_multi_reviewer_instruction(&prompt, reviewer_index + 1, reviewer_tool); + let reviewer_model = args.model.clone(); + let reviewer_project_root = project_root.clone(); + let reviewer_config = config.clone(); + let reviewer_global = global_config.clone(); + let reviewer_description = format!( + "review[{}]: {}", + reviewer_index + 1, + crate::run_helpers::truncate_prompt(&scope, 80) + ); + + join_set.spawn(async move { + let result = execute_review( + reviewer_tool, + reviewer_prompt, + None, + reviewer_model, + reviewer_description, + &reviewer_project_root, + reviewer_config.as_ref(), + &reviewer_global, + ) + .await?; + Ok::(ReviewerOutcome { + reviewer_index, + tool: reviewer_tool, + verdict: parse_review_verdict(&result.output, result.exit_code), + output: result.output, + exit_code: result.exit_code, + }) + }); + } + + let mut outcomes = Vec::with_capacity(reviewers); + while let Some(joined) = join_set.join_next().await { + let outcome = joined.context("reviewer task join failure")??; + outcomes.push(outcome); + } + outcomes.sort_by_key(|o| o.reviewer_index); + + let responses: Vec = outcomes + .iter() + .map(|o| AgentResponse { + agent: format!("reviewer-{}:{}", o.reviewer_index + 1, o.tool.as_str()), + content: o.verdict.to_string(), + weight: 1.0, + timed_out: false, + }) + .collect(); + + let consensus_result = resolve_consensus(consensus_strategy, &responses); + let final_verdict = consensus_verdict(&consensus_result); + let agreement = agreement_level(&consensus_result); + + for outcome in &outcomes { + println!( + "===== Reviewer {} ({}) | verdict={} | exit_code={} =====", + outcome.reviewer_index + 1, + outcome.tool, + outcome.verdict, + outcome.exit_code + ); + print!("{}", outcome.output); + if !outcome.output.ends_with('\n') { + println!(); + } + } + + println!("===== Consensus ====="); + println!( + "strategy: {}", + consensus_strategy_label(consensus_result.strategy_used) + ); + println!("consensus_reached: {}", consensus_result.consensus_reached); + println!("agreement_level: {:.0}%", agreement * 100.0); + println!("final_decision: {final_verdict}"); + println!("individual_verdicts:"); + for outcome in &outcomes { + println!( + "- reviewer {} ({}) => {}", + outcome.reviewer_index + 1, + outcome.tool, + outcome.verdict + ); + } + + Ok(if final_verdict == CLEAN { 0 } else { 1 }) +} + +#[allow(clippy::too_many_arguments)] +async fn execute_review( + tool: ToolName, + prompt: String, + session: Option, + model: Option, + description: String, + project_root: &Path, + project_config: Option<&ProjectConfig>, + global_config: &GlobalConfig, +) -> Result { let executor = crate::pipeline::build_and_validate_executor( &tool, None, - args.model.as_deref(), + model.as_deref(), None, - config.as_ref(), + project_config, ) .await?; - // 7. Apply restrictions if configured - let can_edit = config - .as_ref() - .map_or(true, |cfg| cfg.can_tool_edit_existing(executor.tool_name())); + let can_edit = + project_config.is_none_or(|cfg| cfg.can_tool_edit_existing(executor.tool_name())); let effective_prompt = if !can_edit { info!(tool = %executor.tool_name(), "Applying edit restriction: tool cannot modify existing files"); executor.apply_restrictions(&prompt, false) } else { - prompt.clone() + prompt }; - // 8. Get env injection from global config let extra_env = global_config.env_vars(executor.tool_name()); + let _slot_guard = crate::pipeline::acquire_slot(&executor, global_config)?; - // 9. Acquire global slot to enforce concurrency limit - let _slot_guard = crate::pipeline::acquire_slot(&executor, &global_config)?; - - // 10. Execute with session - let description = format!( - "review: {}", - crate::run_helpers::truncate_prompt(&scope, 80) - ); - let result = crate::pipeline::execute_with_session( + crate::pipeline::execute_with_session( &executor, &tool, &effective_prompt, - args.session, + session, Some(description), None, - &project_root, - config.as_ref(), + project_root, + project_config, extra_env, Some("review"), - None, // review does not use tier-based selection + None, ) - .await?; - - // 11. Print result - print!("{}", result.output); - - Ok(result.exit_code) + .await } fn resolve_review_tool( @@ -253,11 +387,23 @@ fn build_review_instruction( #[cfg(test)] mod tests { use super::*; + use crate::cli::{Cli, Commands}; + use clap::Parser; use csa_config::{ProjectMeta, ResourcesConfig, ToolConfig}; use std::collections::HashMap; fn project_config_with_enabled_tools(tools: &[&str]) -> ProjectConfig { let mut tool_map = HashMap::new(); + for tool in csa_config::global::all_known_tools() { + tool_map.insert( + tool.as_str().to_string(), + ToolConfig { + enabled: false, + restrictions: None, + suppress_notify: false, + }, + ); + } for tool in tools { tool_map.insert( (*tool).to_string(), @@ -282,6 +428,14 @@ mod tests { } } + fn parse_review_args(argv: &[&str]) -> ReviewArgs { + let cli = Cli::try_parse_from(argv).expect("review CLI args should parse"); + match cli.command { + Commands::Review(args) => args, + _ => panic!("expected review subcommand"), + } + } + // --- resolve_review_tool tests --- #[test] @@ -326,9 +480,10 @@ mod tests { std::path::Path::new("/tmp/test-project"), ) .unwrap_err(); - assert!(err - .to_string() - .contains("AUTO review tool selection failed")); + assert!( + err.to_string() + .contains("AUTO review tool selection failed") + ); } #[test] @@ -344,9 +499,10 @@ mod tests { std::path::Path::new("/tmp/test-project"), ) .unwrap_err(); - assert!(err - .to_string() - .contains("Invalid [review].tool value 'invalid-tool'")); + assert!( + err.to_string() + .contains("Invalid [review].tool value 'invalid-tool'") + ); } #[test] @@ -405,6 +561,8 @@ mod tests { fix: false, security_mode: "auto".to_string(), context: None, + reviewers: 1, + consensus: "majority".to_string(), cd: None, }; assert_eq!(derive_scope(&args), "uncommitted"); @@ -424,6 +582,8 @@ mod tests { fix: false, security_mode: "auto".to_string(), context: None, + reviewers: 1, + consensus: "majority".to_string(), cd: None, }; assert_eq!(derive_scope(&args), "commit:abc123"); @@ -443,6 +603,8 @@ mod tests { fix: false, security_mode: "auto".to_string(), context: None, + reviewers: 1, + consensus: "majority".to_string(), cd: None, }; assert_eq!(derive_scope(&args), "range:main...HEAD"); @@ -462,6 +624,8 @@ mod tests { fix: false, security_mode: "auto".to_string(), context: None, + reviewers: 1, + consensus: "majority".to_string(), cd: None, }; assert_eq!(derive_scope(&args), "files:src/**/*.rs"); @@ -481,6 +645,8 @@ mod tests { fix: false, security_mode: "auto".to_string(), context: None, + reviewers: 1, + consensus: "majority".to_string(), cd: None, }; assert_eq!(derive_scope(&args), "base:develop"); @@ -500,12 +666,68 @@ mod tests { fix: false, security_mode: "auto".to_string(), context: None, + reviewers: 1, + consensus: "majority".to_string(), cd: None, }; // --range has highest priority assert_eq!(derive_scope(&args), "range:v1...v2"); } + #[test] + fn review_cli_parses_range_scope_with_multiple_reviewers() { + let args = parse_review_args(&[ + "csa", + "review", + "--range", + "main...HEAD", + "--reviewers", + "3", + ]); + + assert_eq!(args.reviewers, 3); + assert_eq!(derive_scope(&args), "range:main...HEAD"); + } + + #[test] + fn review_cli_parses_weighted_consensus_for_multi_reviewer_mode() { + let args = parse_review_args(&[ + "csa", + "review", + "--diff", + "--reviewers", + "2", + "--consensus", + "weighted", + ]); + + let strategy = parse_consensus_strategy(&args.consensus).unwrap(); + assert_eq!(consensus_strategy_label(strategy), "weighted"); + } + + #[test] + fn review_cli_builds_multi_reviewer_config_from_args() { + let args = parse_review_args(&[ + "csa", + "review", + "--tool", + "codex", + "--reviewers", + "4", + "--consensus", + "unanimous", + ]); + + let strategy = parse_consensus_strategy(&args.consensus).unwrap(); + let reviewers = args.reviewers as usize; + let reviewer_tools = build_reviewer_tools(args.tool, ToolName::Codex, None, reviewers); + + assert!(reviewers > 1); + assert_eq!(consensus_strategy_label(strategy), "unanimous"); + assert_eq!(reviewer_tools.len(), reviewers); + assert!(reviewer_tools.iter().all(|tool| *tool == ToolName::Codex)); + } + // --- build_review_instruction tests --- #[test] diff --git a/crates/cli-sub-agent/src/review_consensus.rs b/crates/cli-sub-agent/src/review_consensus.rs new file mode 100644 index 0000000..d772e8e --- /dev/null +++ b/crates/cli-sub-agent/src/review_consensus.rs @@ -0,0 +1,367 @@ +use anyhow::Result; +use std::collections::HashMap; + +use csa_config::ProjectConfig; +use csa_core::consensus::{ + AgentResponse, ConsensusResult, ConsensusStrategy, resolve_majority, resolve_unanimous, + resolve_weighted, +}; +use csa_core::types::ToolName; + +pub(crate) const CLEAN: &str = "CLEAN"; +pub(crate) const HAS_ISSUES: &str = "HAS_ISSUES"; + +pub(crate) fn build_reviewer_tools( + explicit_tool: Option, + primary_tool: ToolName, + project_config: Option<&ProjectConfig>, + reviewer_count: usize, +) -> Vec { + if reviewer_count == 0 { + return Vec::new(); + } + if explicit_tool.is_some() { + return vec![primary_tool; reviewer_count]; + } + + let enabled_tools: Vec = if let Some(cfg) = project_config { + csa_config::global::all_known_tools() + .iter() + .filter(|t| cfg.is_tool_enabled(t.as_str())) + .copied() + .collect() + } else { + csa_config::global::all_known_tools().to_vec() + }; + + let mut pool = vec![primary_tool]; + for tool in enabled_tools { + if !pool.contains(&tool) { + pool.push(tool); + } + } + + (0..reviewer_count) + .map(|idx| pool[idx % pool.len()]) + .collect() +} + +pub(crate) fn build_multi_reviewer_instruction( + base_prompt: &str, + reviewer_index: usize, + tool: ToolName, +) -> String { + let output_dir = format!(".csa/reviewers/reviewer-{reviewer_index}"); + format!( + "{base_prompt}\n\ +You are reviewer {reviewer_index}. Emit exactly one final verdict token: {CLEAN} or {HAS_ISSUES}.\n\ +Write review artifacts to {output_dir}/review-findings.json and {output_dir}/review-report.md.\n\ +If no serious issues (P0/P1), verdict must be {CLEAN}; otherwise verdict must be {HAS_ISSUES}.\n\ +Reviewer tool hint: {}.", + tool.as_str() + ) +} + +pub(crate) fn parse_consensus_strategy(raw: &str) -> Result { + match raw { + "majority" => Ok(ConsensusStrategy::Majority), + "weighted" => Ok(ConsensusStrategy::Weighted), + "unanimous" => Ok(ConsensusStrategy::Unanimous), + _ => anyhow::bail!( + "Invalid consensus strategy '{raw}'. Supported values: majority, weighted, unanimous." + ), + } +} + +pub(crate) fn resolve_consensus( + strategy: ConsensusStrategy, + responses: &[AgentResponse], +) -> ConsensusResult { + match strategy { + ConsensusStrategy::Majority => resolve_majority(responses), + ConsensusStrategy::Weighted => resolve_weighted(responses), + ConsensusStrategy::Unanimous => resolve_unanimous(responses), + ConsensusStrategy::HumanInTheLoop => { + unreachable!("human-in-the-loop is not exposed by CLI") + } + } +} + +pub(crate) fn parse_review_verdict(output: &str, exit_code: i32) -> &'static str { + let has_issues = contains_verdict_token(output, HAS_ISSUES); + let clean = contains_verdict_token(output, CLEAN); + + if has_issues { + HAS_ISSUES + } else if clean || exit_code == 0 { + CLEAN + } else { + HAS_ISSUES + } +} + +fn contains_verdict_token(haystack: &str, token: &str) -> bool { + haystack + .split(|c: char| !c.is_ascii_alphanumeric() && c != '_') + .any(|part| part.eq_ignore_ascii_case(token)) +} + +pub(crate) fn consensus_verdict(consensus_result: &ConsensusResult) -> &'static str { + if let Some(decision) = &consensus_result.decision { + if decision.eq_ignore_ascii_case(CLEAN) { + return CLEAN; + } + if decision.eq_ignore_ascii_case(HAS_ISSUES) { + return HAS_ISSUES; + } + } + HAS_ISSUES +} + +pub(crate) fn agreement_level(consensus_result: &ConsensusResult) -> f32 { + let active: Vec<&AgentResponse> = consensus_result + .responses + .iter() + .filter(|r| !r.timed_out) + .collect(); + if active.is_empty() { + return 0.0; + } + + if let Some(decision) = consensus_result.decision.as_deref() { + let agreement = active + .iter() + .filter(|response| response.content == decision) + .count(); + return agreement as f32 / active.len() as f32; + } + + let mut counts: HashMap<&str, usize> = HashMap::new(); + for response in &active { + *counts.entry(response.content.as_str()).or_insert(0) += 1; + } + let max_count = counts.values().copied().max().unwrap_or(0); + max_count as f32 / active.len() as f32 +} + +pub(crate) fn consensus_strategy_label(strategy: ConsensusStrategy) -> &'static str { + match strategy { + ConsensusStrategy::Majority => "majority", + ConsensusStrategy::Weighted => "weighted", + ConsensusStrategy::Unanimous => "unanimous", + ConsensusStrategy::HumanInTheLoop => "human-in-the-loop", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use csa_config::{ProjectMeta, ResourcesConfig, ToolConfig}; + + fn project_config_with_enabled_tools(tools: &[&str]) -> ProjectConfig { + let mut tool_map = HashMap::new(); + for tool in csa_config::global::all_known_tools() { + tool_map.insert( + tool.as_str().to_string(), + ToolConfig { + enabled: false, + restrictions: None, + suppress_notify: false, + }, + ); + } + for tool in tools { + tool_map.insert( + (*tool).to_string(), + ToolConfig { + enabled: true, + restrictions: None, + suppress_notify: false, + }, + ); + } + + ProjectConfig { + schema_version: 1, + project: ProjectMeta::default(), + resources: ResourcesConfig::default(), + tools: tool_map, + review: None, + debate: None, + tiers: HashMap::new(), + tier_mapping: HashMap::new(), + aliases: HashMap::new(), + } + } + + fn response(agent: &str, verdict: &str, timed_out: bool) -> AgentResponse { + AgentResponse { + agent: agent.to_string(), + content: verdict.to_string(), + weight: 1.0, + timed_out, + } + } + + fn verdict_to_exit_code(verdict: &str) -> i32 { + if verdict == CLEAN { 0 } else { 1 } + } + + #[test] + fn build_reviewer_tools_returns_empty_when_reviewer_count_is_zero() { + let cfg = project_config_with_enabled_tools(&["codex", "opencode"]); + let tools = build_reviewer_tools(None, ToolName::Codex, Some(&cfg), 0); + assert!(tools.is_empty()); + } + + #[test] + fn build_reviewer_tools_round_robin_across_enabled_tools() { + let cfg = project_config_with_enabled_tools(&["codex", "claude-code", "opencode"]); + let tools = build_reviewer_tools(None, ToolName::Codex, Some(&cfg), 5); + assert_eq!( + tools, + vec![ + ToolName::Codex, + ToolName::Opencode, + ToolName::ClaudeCode, + ToolName::Codex, + ToolName::Opencode + ] + ); + } + + #[test] + fn build_reviewer_tools_respects_explicit_tool_override() { + let cfg = project_config_with_enabled_tools(&["codex", "claude-code", "opencode"]); + let tools = build_reviewer_tools(Some(ToolName::Codex), ToolName::Codex, Some(&cfg), 3); + assert_eq!( + tools, + vec![ToolName::Codex, ToolName::Codex, ToolName::Codex] + ); + } + + #[test] + fn parse_review_verdict_prefers_has_issues_token() { + let output = "result: CLEAN but escalation says HAS_ISSUES"; + assert_eq!(parse_review_verdict(output, 0), HAS_ISSUES); + } + + #[test] + fn parse_review_verdict_falls_back_to_exit_code() { + assert_eq!(parse_review_verdict("no explicit verdict", 0), CLEAN); + assert_eq!(parse_review_verdict("no explicit verdict", 1), HAS_ISSUES); + } + + #[test] + fn parse_review_verdict_is_case_insensitive_and_token_aware() { + assert_eq!( + parse_review_verdict("final verdict: clean.", 1), + CLEAN, + "token matching should be case-insensitive" + ); + assert_eq!( + parse_review_verdict("status: unclean output", 1), + HAS_ISSUES, + "partial-word matches must not be treated as CLEAN" + ); + } + + #[test] + fn parse_consensus_strategy_supports_all_cli_values() { + assert_eq!( + parse_consensus_strategy("majority").unwrap(), + ConsensusStrategy::Majority + ); + assert_eq!( + parse_consensus_strategy("weighted").unwrap(), + ConsensusStrategy::Weighted + ); + assert_eq!( + parse_consensus_strategy("unanimous").unwrap(), + ConsensusStrategy::Unanimous + ); + assert!(parse_consensus_strategy("invalid").is_err()); + } + + #[test] + fn agreement_level_uses_top_cluster_when_no_consensus_decision() { + let responses = vec![ + AgentResponse { + agent: "r1".to_string(), + content: CLEAN.to_string(), + weight: 1.0, + timed_out: false, + }, + AgentResponse { + agent: "r2".to_string(), + content: HAS_ISSUES.to_string(), + weight: 1.0, + timed_out: false, + }, + AgentResponse { + agent: "r3".to_string(), + content: HAS_ISSUES.to_string(), + weight: 1.0, + timed_out: false, + }, + ]; + let result = resolve_unanimous(&responses); + assert!((agreement_level(&result) - (2.0 / 3.0)).abs() < f32::EPSILON); + assert_eq!(consensus_verdict(&result), HAS_ISSUES); + } + + #[test] + fn multi_reviewer_majority_clean_maps_to_exit_code_zero() { + let responses = vec![ + response("reviewer-1:codex", parse_review_verdict("CLEAN", 0), false), + response( + "reviewer-2:opencode", + parse_review_verdict("CLEAN", 0), + false, + ), + response( + "reviewer-3:claude-code", + parse_review_verdict("HAS_ISSUES", 1), + false, + ), + ]; + + let consensus = resolve_consensus(ConsensusStrategy::Majority, &responses); + let final_verdict = consensus_verdict(&consensus); + + assert!(consensus.consensus_reached); + assert_eq!(final_verdict, CLEAN); + assert_eq!(verdict_to_exit_code(final_verdict), 0); + assert!((agreement_level(&consensus) - (2.0 / 3.0)).abs() < f32::EPSILON); + } + + #[test] + fn multi_reviewer_unanimous_disagreement_maps_to_exit_code_one() { + let responses = vec![ + response("reviewer-1:codex", CLEAN, false), + response("reviewer-2:opencode", HAS_ISSUES, false), + response("reviewer-3:claude-code", CLEAN, false), + ]; + + let consensus = resolve_consensus(ConsensusStrategy::Unanimous, &responses); + let final_verdict = consensus_verdict(&consensus); + + assert!(!consensus.consensus_reached); + assert!(consensus.decision.is_none()); + assert_eq!(final_verdict, HAS_ISSUES); + assert_eq!(verdict_to_exit_code(final_verdict), 1); + } + + #[test] + fn agreement_level_ignores_timed_out_responses_with_consensus_decision() { + let responses = vec![ + response("reviewer-1:codex", CLEAN, false), + response("reviewer-2:opencode", CLEAN, false), + response("reviewer-3:claude-code", HAS_ISSUES, true), + ]; + let consensus = resolve_consensus(ConsensusStrategy::Majority, &responses); + + assert_eq!(consensus.decision.as_deref(), Some(CLEAN)); + assert!((agreement_level(&consensus) - 1.0).abs() < f32::EPSILON); + } +} diff --git a/crates/cli-sub-agent/src/run_helpers.rs b/crates/cli-sub-agent/src/run_helpers.rs index 556ff92..bb795d1 100644 --- a/crates/cli-sub-agent/src/run_helpers.rs +++ b/crates/cli-sub-agent/src/run_helpers.rs @@ -205,11 +205,7 @@ pub(crate) fn parse_token_usage(output: &str) -> Option { } } - if found_any { - Some(usage) - } else { - None - } + if found_any { Some(usage) } else { None } } /// Extract a number after colon or equals sign. diff --git a/crates/cli-sub-agent/src/session_cmds.rs b/crates/cli-sub-agent/src/session_cmds.rs index 66c9c36..9999d4e 100644 --- a/crates/cli-sub-agent/src/session_cmds.rs +++ b/crates/cli-sub-agent/src/session_cmds.rs @@ -8,6 +8,25 @@ use csa_session::{ load_result, load_session, resolve_session_prefix, }; +fn truncate_with_ellipsis(input: &str, max_chars: usize) -> String { + if input.chars().count() <= max_chars { + return input.to_string(); + } + + if max_chars <= 3 { + return ".".repeat(max_chars); + } + + let visible_chars = max_chars - 3; + let end = input + .char_indices() + .map(|(idx, _)| idx) + .nth(visible_chars) + .unwrap_or(input.len()); + + format!("{}...", &input[..end]) +} + pub(crate) fn handle_session_list( cd: Option, tool: Option, @@ -69,12 +88,8 @@ pub(crate) fn handle_session_list( .as_deref() .filter(|d| !d.is_empty()) .unwrap_or("-"); - // Truncate description to 25 chars - let desc_display = if desc.len() > 25 { - format!("{}...", &desc[..22]) - } else { - desc.to_string() - }; + // Truncate description to 25 visible chars using UTF-8 safe boundaries. + let desc_display = truncate_with_ellipsis(desc, 25); let tools: Vec<&String> = session.tools.keys().collect(); let tools_str = if tools.is_empty() { "-".to_string() @@ -327,3 +342,27 @@ pub(crate) fn handle_session_log(session: String, cd: Option) -> Result< } Ok(()) } + +#[cfg(test)] +mod tests { + use super::truncate_with_ellipsis; + + #[test] + fn truncate_with_ellipsis_preserves_ascii_short_input() { + let input = "short description"; + assert_eq!(truncate_with_ellipsis(input, 25), "short description"); + } + + #[test] + fn truncate_with_ellipsis_handles_multibyte_chinese() { + let input = "\u{8FD9}\u{662F}\u{4E00}\u{4E2A}\u{7528}\u{4E8E}\u{6D4B}\u{8BD5}\u{622A}\u{65AD}\u{903B}\u{8F91}\u{7684}\u{4E2D}\u{6587}\u{63CF}\u{8FF0}\u{6587}\u{672C}"; + let expected = "\u{8FD9}\u{662F}\u{4E00}\u{4E2A}\u{7528}\u{4E8E}\u{6D4B}..."; + assert_eq!(truncate_with_ellipsis(input, 10), expected); + } + + #[test] + fn truncate_with_ellipsis_handles_emoji_without_panic() { + let input = "session 😀😃😄😁 description"; + assert_eq!(truncate_with_ellipsis(input, 12), "session 😀..."); + } +} diff --git a/crates/cli-sub-agent/src/skill_cmds.rs b/crates/cli-sub-agent/src/skill_cmds.rs index ed46c82..8264400 100644 --- a/crates/cli-sub-agent/src/skill_cmds.rs +++ b/crates/cli-sub-agent/src/skill_cmds.rs @@ -337,10 +337,12 @@ mod tests { fn determine_target_directory_unsupported_codex_errors() { let result = determine_target_directory(Some("codex")); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("not yet supported")); + assert!( + result + .unwrap_err() + .to_string() + .contains("not yet supported") + ); } #[test] diff --git a/crates/cli-sub-agent/src/todo_cmd.rs b/crates/cli-sub-agent/src/todo_cmd.rs index acc5aed..6ac4f5c 100644 --- a/crates/cli-sub-agent/src/todo_cmd.rs +++ b/crates/cli-sub-agent/src/todo_cmd.rs @@ -1,6 +1,8 @@ +use crate::cli::TodoDagFormat; use anyhow::Result; use csa_config::global::GlobalConfig; use csa_core::types::OutputFormat; +use csa_todo::dag::DependencyGraph; use csa_todo::{TodoManager, TodoStatus}; pub(crate) fn handle_create( @@ -308,6 +310,36 @@ pub(crate) fn handle_status(timestamp: String, status: String, cd: Option, + format: TodoDagFormat, + cd: Option, +) -> Result<()> { + let project_root = crate::pipeline::determine_project_root(cd.as_deref())?; + let manager = TodoManager::new(&project_root)?; + let ts = resolve_timestamp(&manager, timestamp.as_deref())?; + let plan = manager.load(&ts)?; + + let content = std::fs::read_to_string(plan.todo_md_path())?; + let graph = DependencyGraph::from_markdown(&content)?; + + if let Some(cycle_nodes) = graph.cycle_nodes_bfs() { + anyhow::bail!("Dependency cycle detected: {}", cycle_nodes.join(" -> ")); + } + + // Validate execution order exists for the graph before rendering. + let _ = graph.topological_sort()?; + + let rendered = match format { + TodoDagFormat::Mermaid => graph.to_mermaid(), + TodoDagFormat::Terminal => graph.to_terminal(), + TodoDagFormat::Dot => graph.to_dot(), + }; + + print!("{rendered}"); + Ok(()) +} + /// Resolve an optional timestamp to an actual plan timestamp. /// If `None`, uses the most recent plan. fn resolve_timestamp(manager: &TodoManager, timestamp: Option<&str>) -> Result { diff --git a/crates/csa-config/src/init.rs b/crates/csa-config/src/init.rs index df3b489..7624370 100644 --- a/crates/csa-config/src/init.rs +++ b/crates/csa-config/src/init.rs @@ -1,12 +1,12 @@ -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use chrono::Utc; use std::collections::HashMap; use std::ffi::OsStr; use std::path::Path; use crate::config::{ - ProjectConfig, ProjectMeta, ResourcesConfig, TierConfig, ToolConfig, ToolRestrictions, - CURRENT_SCHEMA_VERSION, + CURRENT_SCHEMA_VERSION, ProjectConfig, ProjectMeta, ResourcesConfig, TierConfig, ToolConfig, + ToolRestrictions, }; /// Detect which tools are installed on the system diff --git a/crates/csa-config/src/validate.rs b/crates/csa-config/src/validate.rs index 32619da..9a07731 100644 --- a/crates/csa-config/src/validate.rs +++ b/crates/csa-config/src/validate.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use std::path::Path; use crate::config::ProjectConfig; diff --git a/crates/csa-config/src/validate_tests.rs b/crates/csa-config/src/validate_tests.rs index 3b8d0b8..7ee8554 100644 --- a/crates/csa-config/src/validate_tests.rs +++ b/crates/csa-config/src/validate_tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::config::{ - ProjectConfig, ProjectMeta, ResourcesConfig, TierConfig, ToolConfig, CURRENT_SCHEMA_VERSION, + CURRENT_SCHEMA_VERSION, ProjectConfig, ProjectMeta, ResourcesConfig, TierConfig, ToolConfig, }; use crate::global::ReviewConfig; use chrono::Utc; @@ -145,10 +145,12 @@ fn test_validate_config_fails_on_invalid_review_tool() { let result = validate_config(dir.path()); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Invalid [review].tool value")); + assert!( + result + .unwrap_err() + .to_string() + .contains("Invalid [review].tool value") + ); } #[test] @@ -184,10 +186,12 @@ fn test_validate_config_fails_on_invalid_model_spec() { let result = validate_config(dir.path()); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("invalid model spec")); + assert!( + result + .unwrap_err() + .to_string() + .contains("invalid model spec") + ); } #[test] @@ -238,10 +242,12 @@ fn test_validate_config_fails_if_no_config() { let project_path = dir.path().join(".csa").join("config.toml"); let result = validate_config_with_paths(None, &project_path); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("No configuration found")); + assert!( + result + .unwrap_err() + .to_string() + .contains("No configuration found") + ); } #[test] @@ -277,10 +283,12 @@ fn test_validate_config_fails_on_empty_models() { let result = validate_config(dir.path()); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("must have at least one model")); + assert!( + result + .unwrap_err() + .to_string() + .contains("must have at least one model") + ); } #[test] @@ -347,10 +355,12 @@ fn test_validate_config_fails_on_invalid_debate_tool() { let result = validate_config(dir.path()); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Invalid [debate].tool value")); + assert!( + result + .unwrap_err() + .to_string() + .contains("Invalid [debate].tool value") + ); } #[test] @@ -436,10 +446,12 @@ fn test_validate_model_spec_two_parts() { config.save(dir.path()).unwrap(); let result = validate_config(dir.path()); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("invalid model spec")); + assert!( + result + .unwrap_err() + .to_string() + .contains("invalid model spec") + ); } #[test] @@ -474,10 +486,12 @@ fn test_validate_model_spec_five_parts() { config.save(dir.path()).unwrap(); let result = validate_config(dir.path()); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("invalid model spec")); + assert!( + result + .unwrap_err() + .to_string() + .contains("invalid model spec") + ); } #[test] @@ -663,133 +677,4 @@ fn test_validate_max_recursion_depth_zero() { assert!(result.is_ok(), "max_recursion_depth 0 should be valid"); } -#[test] -fn test_validate_multiple_tiers_all_valid() { - let dir = tempdir().unwrap(); - - let mut tiers = HashMap::new(); - tiers.insert( - "tier-1-quick".to_string(), - TierConfig { - description: "Quick tasks".to_string(), - models: vec!["gemini-cli/google/gemini-3-flash-preview/xhigh".to_string()], - }, - ); - tiers.insert( - "tier-2-standard".to_string(), - TierConfig { - description: "Standard tasks".to_string(), - models: vec!["codex/anthropic/claude-sonnet-4-5/default".to_string()], - }, - ); - tiers.insert( - "tier-3-complex".to_string(), - TierConfig { - description: "Complex tasks".to_string(), - models: vec!["claude-code/anthropic/claude-opus-4-6/default".to_string()], - }, - ); - - let mut tier_mapping = HashMap::new(); - tier_mapping.insert("default".to_string(), "tier-2-standard".to_string()); - tier_mapping.insert("quick_question".to_string(), "tier-1-quick".to_string()); - tier_mapping.insert("security_audit".to_string(), "tier-3-complex".to_string()); - - let config = ProjectConfig { - schema_version: CURRENT_SCHEMA_VERSION, - project: ProjectMeta { - name: "test".to_string(), - created_at: Utc::now(), - max_recursion_depth: 5, - }, - resources: ResourcesConfig::default(), - tools: HashMap::new(), - review: None, - debate: None, - tiers, - tier_mapping, - aliases: HashMap::new(), - }; - - config.save(dir.path()).unwrap(); - let result = validate_config(dir.path()); - assert!(result.is_ok()); -} - -#[test] -fn test_validate_tier_with_multiple_models_all_valid() { - let dir = tempdir().unwrap(); - - let mut tiers = HashMap::new(); - tiers.insert( - "multi-model-tier".to_string(), - TierConfig { - description: "Has multiple models".to_string(), - models: vec![ - "gemini-cli/google/gemini-3-flash-preview/xhigh".to_string(), - "codex/anthropic/claude-sonnet-4-5/default".to_string(), - ], - }, - ); - - let config = ProjectConfig { - schema_version: CURRENT_SCHEMA_VERSION, - project: ProjectMeta { - name: "test".to_string(), - created_at: Utc::now(), - max_recursion_depth: 5, - }, - resources: ResourcesConfig::default(), - tools: HashMap::new(), - review: None, - debate: None, - tiers, - tier_mapping: HashMap::new(), - aliases: HashMap::new(), - }; - - config.save(dir.path()).unwrap(); - let result = validate_config(dir.path()); - assert!(result.is_ok()); -} - -#[test] -fn test_validate_tier_with_one_bad_model_in_list() { - let dir = tempdir().unwrap(); - - let mut tiers = HashMap::new(); - tiers.insert( - "mixed-tier".to_string(), - TierConfig { - description: "One good, one bad".to_string(), - models: vec![ - "gemini-cli/google/gemini-3-flash-preview/xhigh".to_string(), - "bad-spec".to_string(), // invalid - ], - }, - ); - - let config = ProjectConfig { - schema_version: CURRENT_SCHEMA_VERSION, - project: ProjectMeta { - name: "test".to_string(), - created_at: Utc::now(), - max_recursion_depth: 5, - }, - resources: ResourcesConfig::default(), - tools: HashMap::new(), - review: None, - debate: None, - tiers, - tier_mapping: HashMap::new(), - aliases: HashMap::new(), - }; - - config.save(dir.path()).unwrap(); - let result = validate_config(dir.path()); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("invalid model spec")); -} +include!("validate_tests_tiers.rs"); diff --git a/crates/csa-config/src/validate_tests_tiers.rs b/crates/csa-config/src/validate_tests_tiers.rs new file mode 100644 index 0000000..e02e0b3 --- /dev/null +++ b/crates/csa-config/src/validate_tests_tiers.rs @@ -0,0 +1,132 @@ +#[test] +fn test_validate_multiple_tiers_all_valid() { + let dir = tempdir().unwrap(); + + let mut tiers = HashMap::new(); + tiers.insert( + "tier-1-quick".to_string(), + TierConfig { + description: "Quick tasks".to_string(), + models: vec!["gemini-cli/google/gemini-3-flash-preview/xhigh".to_string()], + }, + ); + tiers.insert( + "tier-2-standard".to_string(), + TierConfig { + description: "Standard tasks".to_string(), + models: vec!["codex/anthropic/claude-sonnet-4-5/default".to_string()], + }, + ); + tiers.insert( + "tier-3-complex".to_string(), + TierConfig { + description: "Complex tasks".to_string(), + models: vec!["claude-code/anthropic/claude-opus-4-6/default".to_string()], + }, + ); + + let mut tier_mapping = HashMap::new(); + tier_mapping.insert("default".to_string(), "tier-2-standard".to_string()); + tier_mapping.insert("quick_question".to_string(), "tier-1-quick".to_string()); + tier_mapping.insert("security_audit".to_string(), "tier-3-complex".to_string()); + + let config = ProjectConfig { + schema_version: CURRENT_SCHEMA_VERSION, + project: ProjectMeta { + name: "test".to_string(), + created_at: Utc::now(), + max_recursion_depth: 5, + }, + resources: ResourcesConfig::default(), + tools: HashMap::new(), + review: None, + debate: None, + tiers, + tier_mapping, + aliases: HashMap::new(), + }; + + config.save(dir.path()).unwrap(); + let result = validate_config(dir.path()); + assert!(result.is_ok()); +} + +#[test] +fn test_validate_tier_with_multiple_models_all_valid() { + let dir = tempdir().unwrap(); + + let mut tiers = HashMap::new(); + tiers.insert( + "multi-model-tier".to_string(), + TierConfig { + description: "Has multiple models".to_string(), + models: vec![ + "gemini-cli/google/gemini-3-flash-preview/xhigh".to_string(), + "codex/anthropic/claude-sonnet-4-5/default".to_string(), + ], + }, + ); + + let config = ProjectConfig { + schema_version: CURRENT_SCHEMA_VERSION, + project: ProjectMeta { + name: "test".to_string(), + created_at: Utc::now(), + max_recursion_depth: 5, + }, + resources: ResourcesConfig::default(), + tools: HashMap::new(), + review: None, + debate: None, + tiers, + tier_mapping: HashMap::new(), + aliases: HashMap::new(), + }; + + config.save(dir.path()).unwrap(); + let result = validate_config(dir.path()); + assert!(result.is_ok()); +} + +#[test] +fn test_validate_tier_with_one_bad_model_in_list() { + let dir = tempdir().unwrap(); + + let mut tiers = HashMap::new(); + tiers.insert( + "mixed-tier".to_string(), + TierConfig { + description: "One good, one bad".to_string(), + models: vec![ + "gemini-cli/google/gemini-3-flash-preview/xhigh".to_string(), + "bad-spec".to_string(), // invalid + ], + }, + ); + + let config = ProjectConfig { + schema_version: CURRENT_SCHEMA_VERSION, + project: ProjectMeta { + name: "test".to_string(), + created_at: Utc::now(), + max_recursion_depth: 5, + }, + resources: ResourcesConfig::default(), + tools: HashMap::new(), + review: None, + debate: None, + tiers, + tier_mapping: HashMap::new(), + aliases: HashMap::new(), + }; + + config.save(dir.path()).unwrap(); + let result = validate_config(dir.path()); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("invalid model spec") + ); +} diff --git a/crates/csa-core/Cargo.toml b/crates/csa-core/Cargo.toml index 59b27ba..03d2834 100644 --- a/crates/csa-core/Cargo.toml +++ b/crates/csa-core/Cargo.toml @@ -11,3 +11,4 @@ serde.workspace = true chrono.workspace = true ulid.workspace = true clap.workspace = true +agent-teams.workspace = true diff --git a/crates/csa-core/src/consensus.rs b/crates/csa-core/src/consensus.rs new file mode 100644 index 0000000..2609fb4 --- /dev/null +++ b/crates/csa-core/src/consensus.rs @@ -0,0 +1,6 @@ +//! Thin consensus re-exports from agent-teams. + +pub use agent_teams::consensus::{ + AgentResponse, ConsensusRequest, ConsensusResult, ConsensusStrategy, resolve, + resolve_human_in_the_loop, resolve_majority, resolve_unanimous, resolve_weighted, +}; diff --git a/crates/csa-core/src/lib.rs b/crates/csa-core/src/lib.rs index 5c74364..c028d59 100644 --- a/crates/csa-core/src/lib.rs +++ b/crates/csa-core/src/lib.rs @@ -1,2 +1,3 @@ +pub mod consensus; pub mod error; pub mod types; diff --git a/crates/csa-core/src/types.rs b/crates/csa-core/src/types.rs index d93fa6b..a592058 100644 --- a/crates/csa-core/src/types.rs +++ b/crates/csa-core/src/types.rs @@ -198,9 +198,11 @@ mod tests { fn test_tool_arg_from_str_invalid() { let result = ToolArg::from_str("invalid-tool"); assert!(result.is_err()); - assert!(result - .unwrap_err() - .contains("Invalid tool argument 'invalid-tool'")); + assert!( + result + .unwrap_err() + .contains("Invalid tool argument 'invalid-tool'") + ); } #[test] diff --git a/crates/csa-executor/Cargo.toml b/crates/csa-executor/Cargo.toml index 594be5c..abdaa03 100644 --- a/crates/csa-executor/Cargo.toml +++ b/crates/csa-executor/Cargo.toml @@ -9,10 +9,12 @@ license.workspace = true csa-core.workspace = true csa-session.workspace = true csa-process.workspace = true +agent-teams.workspace = true tokio.workspace = true serde.workspace = true toml.workspace = true anyhow.workspace = true +async-trait.workspace = true tracing.workspace = true tracing-appender.workspace = true chrono.workspace = true diff --git a/crates/csa-executor/src/agent_backend_adapter.rs b/crates/csa-executor/src/agent_backend_adapter.rs new file mode 100644 index 0000000..b32f006 --- /dev/null +++ b/crates/csa-executor/src/agent_backend_adapter.rs @@ -0,0 +1,375 @@ +//! Adapter from CSA `Executor` to `agent-teams` backend traits. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use agent_teams::{ + AgentBackend, AgentOutput, AgentSession, BackendType, Error as AgentTeamsError, SpawnConfig, +}; +use async_trait::async_trait; +use tokio::sync::mpsc; + +use crate::{Executor, ThinkingBudget}; + +const OUTPUT_CHANNEL_CAPACITY: usize = 128; + +/// Thin wrapper that exposes CSA's existing `Executor` as an `agent-teams` backend. +/// +/// This adapter is additive: it delegates execution to `Executor::execute_in` and +/// does not change CSA's existing execution behavior. +#[derive(Debug, Clone)] +pub struct ExecutorAgentBackend { + executor: Executor, + base_env: HashMap, +} + +impl ExecutorAgentBackend { + /// Create a backend adapter with no extra environment variables. + pub fn new(executor: Executor) -> Self { + Self { + executor, + base_env: HashMap::new(), + } + } + + /// Create a backend adapter with base environment variables. + pub fn with_env(executor: Executor, base_env: HashMap) -> Self { + Self { executor, base_env } + } + + /// Access the wrapped CSA executor. + pub fn executor(&self) -> &Executor { + &self.executor + } + + fn backend_type_for_executor(executor: &Executor) -> BackendType { + match executor { + Executor::ClaudeCode { .. } => BackendType::ClaudeCode, + Executor::GeminiCli { .. } => BackendType::GeminiCli, + Executor::Codex { .. } | Executor::Opencode { .. } => BackendType::Codex, + } + } + + fn apply_spawn_overrides( + mut executor: Executor, + agent_name: &str, + model: Option, + reasoning_effort: Option, + ) -> Result { + if let Some(model_override) = model { + match &mut executor { + Executor::GeminiCli { + model_override: m, .. + } => *m = Some(model_override.clone()), + Executor::Opencode { + model_override: m, .. + } => *m = Some(model_override.clone()), + Executor::Codex { + model_override: m, .. + } => *m = Some(model_override.clone()), + Executor::ClaudeCode { + model_override: m, .. + } => *m = Some(model_override), + } + } + + if let Some(effort) = reasoning_effort { + let budget = + ThinkingBudget::parse(&effort).map_err(|e| AgentTeamsError::SpawnFailed { + name: agent_name.to_string(), + reason: e.to_string(), + })?; + + match &mut executor { + Executor::GeminiCli { + thinking_budget, .. + } => *thinking_budget = Some(budget.clone()), + Executor::Opencode { + thinking_budget, .. + } => *thinking_budget = Some(budget.clone()), + Executor::Codex { + thinking_budget, .. + } => *thinking_budget = Some(budget.clone()), + Executor::ClaudeCode { + thinking_budget, .. + } => *thinking_budget = Some(budget), + } + } + + Ok(executor) + } +} + +#[async_trait] +impl AgentBackend for ExecutorAgentBackend { + fn backend_type(&self) -> BackendType { + Self::backend_type_for_executor(&self.executor) + } + + async fn spawn(&self, config: SpawnConfig) -> agent_teams::Result> { + let SpawnConfig { + name, + prompt, + model, + cwd, + reasoning_effort, + env, + .. + } = config; + + let cwd = match cwd { + Some(path) => path, + None => std::env::current_dir().map_err(|e| AgentTeamsError::SpawnFailed { + name: name.clone(), + reason: e.to_string(), + })?, + }; + + let executor = + Self::apply_spawn_overrides(self.executor.clone(), &name, model, reasoning_effort)?; + + let mut merged_env = self.base_env.clone(); + merged_env.extend(env); + + Ok(Box::new(ExecutorAgentSession::new( + name, prompt, executor, cwd, merged_env, + ))) + } +} + +#[derive(Debug)] +pub struct ExecutorAgentSession { + name: String, + system_prompt: String, + first_turn: bool, + executor: Executor, + cwd: PathBuf, + env: HashMap, + alive: Arc, + output_tx: mpsc::Sender, + output_rx: Option>, +} + +impl ExecutorAgentSession { + fn new( + name: String, + system_prompt: String, + executor: Executor, + cwd: PathBuf, + env: HashMap, + ) -> Self { + let (output_tx, output_rx) = mpsc::channel(OUTPUT_CHANNEL_CAPACITY); + Self { + name, + system_prompt, + first_turn: true, + executor, + cwd, + env, + alive: Arc::new(AtomicBool::new(true)), + output_tx, + output_rx: Some(output_rx), + } + } + + fn compose_prompt(&mut self, input: &str) -> String { + if self.first_turn { + self.first_turn = false; + if self.system_prompt.trim().is_empty() { + input.to_string() + } else { + format!("{}\n\n{}", self.system_prompt, input) + } + } else { + input.to_string() + } + } + + async fn emit(&self, output: AgentOutput) -> agent_teams::Result<()> { + self.output_tx.send(output).await.map_err(|_| { + self.alive.store(false, Ordering::Relaxed); + AgentTeamsError::AgentNotAlive { + name: self.name.clone(), + } + }) + } +} + +#[async_trait] +impl AgentSession for ExecutorAgentSession { + fn name(&self) -> &str { + &self.name + } + + async fn send_input(&mut self, input: &str) -> agent_teams::Result<()> { + if !self.alive.load(Ordering::Relaxed) { + return Err(AgentTeamsError::AgentNotAlive { + name: self.name.clone(), + }); + } + + let prompt = self.compose_prompt(input); + let extra_env = if self.env.is_empty() { + None + } else { + Some(&self.env) + }; + + match self + .executor + .execute_in(&prompt, &self.cwd, extra_env) + .await + { + Ok(result) => { + if !result.output.is_empty() { + self.emit(AgentOutput::Message(result.output)).await?; + } + + if result.exit_code != 0 { + self.alive.store(false, Ordering::Relaxed); + let reason = if result.summary.is_empty() { + format!("command exited with status {}", result.exit_code) + } else { + result.summary + }; + let _ = self.emit(AgentOutput::Error(reason.clone())).await; + return Err(AgentTeamsError::Other(reason)); + } + + self.emit(AgentOutput::TurnComplete).await + } + Err(e) => { + self.alive.store(false, Ordering::Relaxed); + let reason = e.to_string(); + let _ = self.emit(AgentOutput::Error(reason.clone())).await; + Err(AgentTeamsError::Other(reason)) + } + } + } + + fn output_receiver(&mut self) -> Option> { + self.output_rx.take() + } + + async fn is_alive(&self) -> bool { + self.alive.load(Ordering::Relaxed) + } + + async fn shutdown(&mut self) -> agent_teams::Result<()> { + self.alive.store(false, Ordering::Relaxed); + let _ = self.emit(AgentOutput::Idle).await; + Ok(()) + } + + async fn force_kill(&mut self) -> agent_teams::Result<()> { + self.alive.store(false, Ordering::Relaxed); + let _ = self + .emit(AgentOutput::Error("force_kill requested".to_string())) + .await; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn codex_executor() -> Executor { + Executor::Codex { + model_override: None, + thinking_budget: None, + suppress_notify: false, + } + } + + #[test] + fn backend_type_mapping_matches_supported_variants() { + assert_eq!( + ExecutorAgentBackend::backend_type_for_executor(&Executor::ClaudeCode { + model_override: None, + thinking_budget: None, + }), + BackendType::ClaudeCode + ); + assert_eq!( + ExecutorAgentBackend::backend_type_for_executor(&Executor::GeminiCli { + model_override: None, + thinking_budget: None, + }), + BackendType::GeminiCli + ); + assert_eq!( + ExecutorAgentBackend::backend_type_for_executor(&Executor::Codex { + model_override: None, + thinking_budget: None, + suppress_notify: false, + }), + BackendType::Codex + ); + assert_eq!( + ExecutorAgentBackend::backend_type_for_executor(&Executor::Opencode { + model_override: None, + agent: None, + thinking_budget: None, + }), + BackendType::Codex + ); + } + + #[test] + fn apply_spawn_overrides_updates_model_and_budget() { + let executor = ExecutorAgentBackend::apply_spawn_overrides( + codex_executor(), + "reviewer", + Some("gpt-5".to_string()), + Some("high".to_string()), + ) + .expect("spawn overrides should parse"); + + match executor { + Executor::Codex { + model_override, + thinking_budget, + suppress_notify, + } => { + assert_eq!(model_override.as_deref(), Some("gpt-5")); + assert!(matches!(thinking_budget, Some(ThinkingBudget::High))); + assert!(!suppress_notify); + } + _ => panic!("expected codex executor"), + } + } + + #[test] + fn compose_prompt_prefixes_system_prompt_once() { + let mut session = ExecutorAgentSession::new( + "agent".to_string(), + "system prompt".to_string(), + codex_executor(), + PathBuf::from("."), + HashMap::new(), + ); + + assert_eq!( + session.compose_prompt("first"), + "system prompt\n\nfirst".to_string() + ); + assert_eq!(session.compose_prompt("second"), "second".to_string()); + } + + #[tokio::test] + async fn output_receiver_can_only_be_taken_once() { + let mut session = ExecutorAgentSession::new( + "agent".to_string(), + String::new(), + codex_executor(), + PathBuf::from("."), + HashMap::new(), + ); + + assert!(session.output_receiver().is_some()); + assert!(session.output_receiver().is_none()); + } +} diff --git a/crates/csa-executor/src/executor.rs b/crates/csa-executor/src/executor.rs index c696cbf..945c2e7 100644 --- a/crates/csa-executor/src/executor.rs +++ b/crates/csa-executor/src/executor.rs @@ -1,7 +1,7 @@ //! Executor enum for 4 AI tools. -use anyhow::{bail, Result}; -use csa_core::types::{prompt_transport_capabilities, PromptTransport, ToolName}; +use anyhow::{Result, bail}; +use csa_core::types::{PromptTransport, ToolName, prompt_transport_capabilities}; use csa_process::ExecutionResult; use csa_session::state::{MetaSessionState, ToolState}; use serde::{Deserialize, Serialize}; diff --git a/crates/csa-executor/src/lib.rs b/crates/csa-executor/src/lib.rs index 3ff1490..933b365 100644 --- a/crates/csa-executor/src/lib.rs +++ b/crates/csa-executor/src/lib.rs @@ -1,10 +1,12 @@ //! Executor enum for 4 AI tools with unified model spec. +pub mod agent_backend_adapter; pub mod executor; pub mod logging; pub mod model_spec; pub mod session_id; +pub use agent_backend_adapter::ExecutorAgentBackend; pub use csa_process::ExecutionResult; pub use executor::Executor; pub use logging::create_session_log_writer; diff --git a/crates/csa-executor/src/model_spec.rs b/crates/csa-executor/src/model_spec.rs index 372297e..1abc5e0 100644 --- a/crates/csa-executor/src/model_spec.rs +++ b/crates/csa-executor/src/model_spec.rs @@ -1,6 +1,6 @@ //! Model specification parsing. -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use serde::{Deserialize, Serialize}; /// Unified model spec: tool/provider/model/thinking_budget @@ -122,10 +122,12 @@ mod tests { fn test_parse_invalid_spec_wrong_parts() { let result = ModelSpec::parse("opencode/google/gemini"); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("expected tool/provider/model/thinking_budget")); + assert!( + result + .unwrap_err() + .to_string() + .contains("expected tool/provider/model/thinking_budget") + ); } #[test] @@ -190,10 +192,12 @@ mod tests { fn test_thinking_budget_parse_invalid() { let result = ThinkingBudget::parse("invalid"); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Invalid thinking budget")); + assert!( + result + .unwrap_err() + .to_string() + .contains("Invalid thinking budget") + ); } #[test] diff --git a/crates/csa-hooks/src/lib.rs b/crates/csa-hooks/src/lib.rs index 67f19b5..3c4993a 100644 --- a/crates/csa-hooks/src/lib.rs +++ b/crates/csa-hooks/src/lib.rs @@ -46,6 +46,6 @@ pub mod event; pub mod runner; // Re-export key types -pub use config::{global_hooks_path, load_hooks_config, HookConfig, HooksConfig}; +pub use config::{HookConfig, HooksConfig, global_hooks_path, load_hooks_config}; pub use event::HookEvent; pub use runner::{run_hook, run_hooks_for_event}; diff --git a/crates/csa-hooks/src/runner.rs b/crates/csa-hooks/src/runner.rs index 242ef02..b18ccf2 100644 --- a/crates/csa-hooks/src/runner.rs +++ b/crates/csa-hooks/src/runner.rs @@ -2,7 +2,7 @@ use crate::config::HookConfig; use crate::event::HookEvent; -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use std::collections::HashMap; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; diff --git a/crates/csa-resource/src/guard.rs b/crates/csa-resource/src/guard.rs index 0730265..86cedb4 100644 --- a/crates/csa-resource/src/guard.rs +++ b/crates/csa-resource/src/guard.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use sysinfo::System; diff --git a/crates/csa-scheduler/src/lib.rs b/crates/csa-scheduler/src/lib.rs index 21107d3..b2af017 100644 --- a/crates/csa-scheduler/src/lib.rs +++ b/crates/csa-scheduler/src/lib.rs @@ -5,7 +5,7 @@ pub mod rate_limit; pub mod rotation; pub mod session_reuse; -pub use failover::{decide_failover, FailoverAction}; -pub use rate_limit::{detect_rate_limit, RateLimitDetected}; +pub use failover::{FailoverAction, decide_failover}; +pub use rate_limit::{RateLimitDetected, detect_rate_limit}; pub use rotation::resolve_tier_tool_rotated; -pub use session_reuse::{find_reusable_sessions, ReuseCandidate}; +pub use session_reuse::{ReuseCandidate, find_reusable_sessions}; diff --git a/crates/csa-scheduler/src/rotation.rs b/crates/csa-scheduler/src/rotation.rs index 99770e7..f2049bd 100644 --- a/crates/csa-scheduler/src/rotation.rs +++ b/crates/csa-scheduler/src/rotation.rs @@ -98,9 +98,7 @@ pub fn resolve_tier_tool_rotated( let mut chosen = None; for offset in 0..total { let candidate_idx = (start + offset) % total; - if let Some((_, ref tool, ref spec)) = - eligible.iter().find(|(i, _, _)| *i == candidate_idx) - { + if let Some((_, tool, spec)) = eligible.iter().find(|(i, _, _)| *i == candidate_idx) { chosen = Some((candidate_idx, tool.clone(), spec.clone())); break; } diff --git a/crates/csa-session/src/genealogy.rs b/crates/csa-session/src/genealogy.rs index 85f828d..9350e03 100644 --- a/crates/csa-session/src/genealogy.rs +++ b/crates/csa-session/src/genealogy.rs @@ -280,9 +280,11 @@ mod tests { // Verify session is stored in correct location let session_root = get_session_root(project_path).expect("Failed to get session root"); - assert!(session_root - .join("sessions") - .join(&root.meta_session_id) - .exists()); + assert!( + session_root + .join("sessions") + .join(&root.meta_session_id) + .exists() + ); } } diff --git a/crates/csa-session/src/lib.rs b/crates/csa-session/src/lib.rs index 978e333..f963472 100644 --- a/crates/csa-session/src/lib.rs +++ b/crates/csa-session/src/lib.rs @@ -22,10 +22,13 @@ pub use result::SessionResult; pub use manager::{ complete_session, create_session, delete_session, delete_session_from_root, get_session_dir, get_session_root, list_all_sessions, list_artifacts, list_sessions, list_sessions_from_root, - list_sessions_from_root_readonly, load_metadata, load_result, load_session, save_result, - save_session, save_session_in, update_last_accessed, validate_tool_access, + list_sessions_from_root_readonly, load_metadata, load_result, load_session, + resolve_resume_session, save_result, save_session, save_session_in, update_last_accessed, + validate_tool_access, }; +pub use manager::ResumeSessionResolution; + // Re-export genealogy functions pub use genealogy::{find_children, list_sessions_tree}; diff --git a/crates/csa-session/src/manager.rs b/crates/csa-session/src/manager.rs index 3dcebc2..0fc45a3 100644 --- a/crates/csa-session/src/manager.rs +++ b/crates/csa-session/src/manager.rs @@ -1,9 +1,9 @@ //! Session CRUD operations -use crate::result::{SessionResult, RESULT_FILE_NAME}; +use crate::result::{RESULT_FILE_NAME, SessionResult}; use crate::state::MetaSessionState; -use crate::validate::{new_session_id, validate_session_id}; -use anyhow::{bail, Context, Result}; +use crate::validate::{new_session_id, resolve_session_prefix, validate_session_id}; +use anyhow::{Context, Result, bail}; use chrono::Utc; use std::collections::HashMap; use std::fs; @@ -11,6 +11,15 @@ use std::path::{Path, PathBuf}; const STATE_FILE_NAME: &str = "state.toml"; +/// Resolved identifiers for resuming a tool session. +#[derive(Debug, Clone)] +pub struct ResumeSessionResolution { + /// Fully resolved CSA meta session ID (ULID). + pub meta_session_id: String, + /// Provider-native session ID for the requested tool, if present in state. + pub provider_session_id: Option, +} + /// Get the session root directory for a project (`~/.local/state/csa/{project_path}`) pub fn get_session_root(project_path: &Path) -> Result { let proj_dirs = directories::ProjectDirs::from("", "", "csa") @@ -350,6 +359,43 @@ pub(crate) fn list_sessions_in( } } +/// Resolve a user-provided session reference for resume. +/// +/// This function accepts a full ULID or unique prefix, validates tool ownership, +/// and returns both CSA meta session ID and provider session ID (if present) +/// from `state.toml`. +pub fn resolve_resume_session( + project_path: &Path, + session_ref: &str, + tool: &str, +) -> Result { + let base_dir = get_session_root(project_path)?; + resolve_resume_session_in(&base_dir, session_ref, tool) +} + +/// Internal implementation: resolve resume IDs from explicit base directory. +pub(crate) fn resolve_resume_session_in( + base_dir: &Path, + session_ref: &str, + tool: &str, +) -> Result { + let sessions_dir = base_dir.join("sessions"); + let meta_session_id = resolve_session_prefix(&sessions_dir, session_ref)?; + + validate_tool_access_in(base_dir, &meta_session_id, tool)?; + + let session = load_session_in(base_dir, &meta_session_id)?; + let provider_session_id = session + .tools + .get(tool) + .and_then(|state| state.provider_session_id.clone()); + + Ok(ResumeSessionResolution { + meta_session_id, + provider_session_id, + }) +} + /// Update the last_accessed timestamp and save pub fn update_last_accessed(state: &mut MetaSessionState) -> Result<()> { state.last_accessed = Utc::now(); @@ -558,6 +604,55 @@ mod tests { assert_eq!(filtered[0].meta_session_id, s1.meta_session_id); } + #[test] + fn test_resolve_resume_session_with_provider_id() { + let td = tempdir().unwrap(); + let mut state = + create_session_in(td.path(), td.path(), Some("Resume"), None, None).unwrap(); + state.tools.insert( + "codex".to_string(), + crate::state::ToolState { + provider_session_id: Some("provider_session_123".to_string()), + last_action_summary: "resume".to_string(), + last_exit_code: 0, + updated_at: Utc::now(), + token_usage: None, + }, + ); + save_session_in(td.path(), &state).unwrap(); + + let prefix = &state.meta_session_id[..10]; + let resolved = resolve_resume_session_in(td.path(), prefix, "codex").unwrap(); + + assert_eq!(resolved.meta_session_id, state.meta_session_id); + assert_eq!( + resolved.provider_session_id, + Some("provider_session_123".to_string()) + ); + } + + #[test] + fn test_resolve_resume_session_without_provider_id() { + let td = tempdir().unwrap(); + let state = create_session_in(td.path(), td.path(), Some("Resume"), None, None).unwrap(); + + let resolved = + resolve_resume_session_in(td.path(), &state.meta_session_id, "codex").unwrap(); + assert_eq!(resolved.meta_session_id, state.meta_session_id); + assert!(resolved.provider_session_id.is_none()); + } + + #[test] + fn test_resolve_resume_session_respects_tool_lock() { + let td = tempdir().unwrap(); + let state = + create_session_in(td.path(), td.path(), Some("Locked"), None, Some("codex")).unwrap(); + + let err = + resolve_resume_session_in(td.path(), &state.meta_session_id, "gemini-cli").unwrap_err(); + assert!(err.to_string().contains("locked to tool")); + } + #[test] fn test_create_child_session() { let td = tempdir().unwrap(); @@ -603,150 +698,5 @@ mod tests { assert!(meta.tool_locked); } - #[test] - fn test_tool_access_validation() { - let td = tempdir().unwrap(); - let state = create_session_in(td.path(), td.path(), None, None, Some("codex")).unwrap(); - validate_tool_access_in(td.path(), &state.meta_session_id, "codex").unwrap(); - let err = validate_tool_access_in(td.path(), &state.meta_session_id, "gemini-cli"); - assert!(err.unwrap_err().to_string().contains("locked to tool")); - } - - #[test] - fn test_no_tool_no_metadata() { - let td = tempdir().unwrap(); - let state = create_session_in(td.path(), td.path(), None, None, None).unwrap(); - assert!(load_metadata_in(td.path(), &state.meta_session_id) - .unwrap() - .is_none()); - } - - #[test] - fn test_complete_session() { - let td = tempdir().unwrap(); - let state = - create_session_in(td.path(), td.path(), Some("Test"), None, Some("codex")).unwrap(); - let hash = - complete_session_in(td.path(), &state.meta_session_id, "session complete").unwrap(); - assert!(!hash.is_empty()); - } - - #[test] - fn test_save_and_load_result() { - let td = tempdir().unwrap(); - let state = create_session_in(td.path(), td.path(), None, None, Some("codex")).unwrap(); - let result = crate::result::SessionResult { - status: "success".to_string(), - exit_code: 0, - summary: "Test completed".to_string(), - tool: "codex".to_string(), - started_at: chrono::Utc::now(), - completed_at: chrono::Utc::now(), - artifacts: vec!["output/result.txt".to_string()], - }; - save_result_in(td.path(), &state.meta_session_id, &result).unwrap(); - let loaded = load_result_in(td.path(), &state.meta_session_id) - .unwrap() - .unwrap(); - assert_eq!(loaded.status, "success"); - assert_eq!(loaded.exit_code, 0); - assert_eq!(loaded.tool, "codex"); - assert_eq!(loaded.artifacts.len(), 1); - } - - #[test] - fn test_load_result_not_found() { - let td = tempdir().unwrap(); - let state = create_session_in(td.path(), td.path(), None, None, None).unwrap(); - assert!(load_result_in(td.path(), &state.meta_session_id) - .unwrap() - .is_none()); - } - - #[test] - fn test_list_artifacts() { - let td = tempdir().unwrap(); - let state = create_session_in(td.path(), td.path(), None, None, Some("codex")).unwrap(); - let dir = get_session_dir_in(td.path(), &state.meta_session_id); - std::fs::write(dir.join("output/report.txt"), "test").unwrap(); - std::fs::write(dir.join("output/diff.patch"), "test").unwrap(); - let artifacts = list_artifacts_in(td.path(), &state.meta_session_id).unwrap(); - assert_eq!(artifacts.len(), 2); - assert!(artifacts.contains(&"diff.patch".to_string())); - assert!(artifacts.contains(&"report.txt".to_string())); - } - - #[test] - fn test_status_from_exit_code() { - use crate::result::SessionResult; - assert_eq!(SessionResult::status_from_exit_code(0), "success"); - assert_eq!(SessionResult::status_from_exit_code(1), "failure"); - assert_eq!(SessionResult::status_from_exit_code(137), "signal"); - assert_eq!(SessionResult::status_from_exit_code(143), "signal"); - } - - #[test] - fn test_save_session_in_explicit_base() { - let td = tempdir().unwrap(); - let mut state = - create_session_in(td.path(), td.path(), Some("Explicit save"), None, None).unwrap(); - state.description = Some("Modified".to_string()); - save_session_in(td.path(), &state).unwrap(); - let loaded = load_session_in(td.path(), &state.meta_session_id).unwrap(); - assert_eq!(loaded.description, Some("Modified".to_string())); - } - - #[test] - fn test_list_sessions_empty_and_missing() { - let td = tempdir().unwrap(); - assert!(list_all_sessions_in(td.path()).unwrap().is_empty()); - assert!(list_sessions_in(td.path(), None).unwrap().is_empty()); - } - - #[test] - fn test_delete_nonexistent_session() { - let td = tempdir().unwrap(); - std::fs::create_dir_all(td.path().join("sessions")).unwrap(); - let r = delete_session_in(td.path(), &crate::validate::new_session_id()); - assert!(r.unwrap_err().to_string().contains("not found")); - } - - #[test] - fn test_load_nonexistent_session() { - let td = tempdir().unwrap(); - let r = load_session_in(td.path(), &crate::validate::new_session_id()); - assert!(r.unwrap_err().to_string().contains("not found")); - } - - #[test] - fn test_update_last_accessed_advances_timestamp() { - let td = tempdir().unwrap(); - let state = create_session_in(td.path(), td.path(), Some("ts"), None, None).unwrap(); - let t0 = state.last_accessed; - std::thread::sleep(std::time::Duration::from_millis(10)); - let mut s = load_session_in(td.path(), &state.meta_session_id).unwrap(); - s.last_accessed = Utc::now(); - save_session_in(td.path(), &s).unwrap(); - let s2 = load_session_in(td.path(), &state.meta_session_id).unwrap(); - assert!(s2.last_accessed > t0); - } - - #[test] - fn test_list_artifacts_empty_output() { - let td = tempdir().unwrap(); - let state = create_session_in(td.path(), td.path(), None, None, None).unwrap(); - assert!(list_artifacts_in(td.path(), &state.meta_session_id) - .unwrap() - .is_empty()); - } - - #[test] - fn test_operations_with_invalid_session_id() { - let td = tempdir().unwrap(); - let bad = "not-a-valid-ulid"; - assert!(load_session_in(td.path(), bad).is_err()); - assert!(delete_session_in(td.path(), bad).is_err()); - assert!(load_metadata_in(td.path(), bad).is_err()); - assert!(validate_tool_access_in(td.path(), bad, "codex").is_err()); - } + include!("manager_tests_tail.rs"); } diff --git a/crates/csa-session/src/manager_tests_tail.rs b/crates/csa-session/src/manager_tests_tail.rs new file mode 100644 index 0000000..07f3a02 --- /dev/null +++ b/crates/csa-session/src/manager_tests_tail.rs @@ -0,0 +1,150 @@ +#[test] +fn test_tool_access_validation() { + let td = tempdir().unwrap(); + let state = create_session_in(td.path(), td.path(), None, None, Some("codex")).unwrap(); + validate_tool_access_in(td.path(), &state.meta_session_id, "codex").unwrap(); + let err = validate_tool_access_in(td.path(), &state.meta_session_id, "gemini-cli"); + assert!(err.unwrap_err().to_string().contains("locked to tool")); +} + +#[test] +fn test_no_tool_no_metadata() { + let td = tempdir().unwrap(); + let state = create_session_in(td.path(), td.path(), None, None, None).unwrap(); + assert!( + load_metadata_in(td.path(), &state.meta_session_id) + .unwrap() + .is_none() + ); +} + +#[test] +fn test_complete_session() { + let td = tempdir().unwrap(); + let state = create_session_in(td.path(), td.path(), Some("Test"), None, Some("codex")).unwrap(); + let hash = complete_session_in(td.path(), &state.meta_session_id, "session complete").unwrap(); + assert!(!hash.is_empty()); +} + +#[test] +fn test_save_and_load_result() { + let td = tempdir().unwrap(); + let state = create_session_in(td.path(), td.path(), None, None, Some("codex")).unwrap(); + let result = crate::result::SessionResult { + status: "success".to_string(), + exit_code: 0, + summary: "Test completed".to_string(), + tool: "codex".to_string(), + started_at: chrono::Utc::now(), + completed_at: chrono::Utc::now(), + artifacts: vec!["output/result.txt".to_string()], + }; + save_result_in(td.path(), &state.meta_session_id, &result).unwrap(); + let loaded = load_result_in(td.path(), &state.meta_session_id) + .unwrap() + .unwrap(); + assert_eq!(loaded.status, "success"); + assert_eq!(loaded.exit_code, 0); + assert_eq!(loaded.tool, "codex"); + assert_eq!(loaded.artifacts.len(), 1); +} + +#[test] +fn test_load_result_not_found() { + let td = tempdir().unwrap(); + let state = create_session_in(td.path(), td.path(), None, None, None).unwrap(); + assert!( + load_result_in(td.path(), &state.meta_session_id) + .unwrap() + .is_none() + ); +} + +#[test] +fn test_list_artifacts() { + let td = tempdir().unwrap(); + let state = create_session_in(td.path(), td.path(), None, None, Some("codex")).unwrap(); + let dir = get_session_dir_in(td.path(), &state.meta_session_id); + std::fs::write(dir.join("output/report.txt"), "test").unwrap(); + std::fs::write(dir.join("output/diff.patch"), "test").unwrap(); + let artifacts = list_artifacts_in(td.path(), &state.meta_session_id).unwrap(); + assert_eq!(artifacts.len(), 2); + assert!(artifacts.contains(&"diff.patch".to_string())); + assert!(artifacts.contains(&"report.txt".to_string())); +} + +#[test] +fn test_status_from_exit_code() { + use crate::result::SessionResult; + assert_eq!(SessionResult::status_from_exit_code(0), "success"); + assert_eq!(SessionResult::status_from_exit_code(1), "failure"); + assert_eq!(SessionResult::status_from_exit_code(137), "signal"); + assert_eq!(SessionResult::status_from_exit_code(143), "signal"); +} + +#[test] +fn test_save_session_in_explicit_base() { + let td = tempdir().unwrap(); + let mut state = + create_session_in(td.path(), td.path(), Some("Explicit save"), None, None).unwrap(); + state.description = Some("Modified".to_string()); + save_session_in(td.path(), &state).unwrap(); + let loaded = load_session_in(td.path(), &state.meta_session_id).unwrap(); + assert_eq!(loaded.description, Some("Modified".to_string())); +} + +#[test] +fn test_list_sessions_empty_and_missing() { + let td = tempdir().unwrap(); + assert!(list_all_sessions_in(td.path()).unwrap().is_empty()); + assert!(list_sessions_in(td.path(), None).unwrap().is_empty()); +} + +#[test] +fn test_delete_nonexistent_session() { + let td = tempdir().unwrap(); + std::fs::create_dir_all(td.path().join("sessions")).unwrap(); + let r = delete_session_in(td.path(), &crate::validate::new_session_id()); + assert!(r.unwrap_err().to_string().contains("not found")); +} + +#[test] +fn test_load_nonexistent_session() { + let td = tempdir().unwrap(); + let r = load_session_in(td.path(), &crate::validate::new_session_id()); + assert!(r.unwrap_err().to_string().contains("not found")); +} + +#[test] +fn test_update_last_accessed_advances_timestamp() { + let td = tempdir().unwrap(); + let state = create_session_in(td.path(), td.path(), Some("ts"), None, None).unwrap(); + let t0 = state.last_accessed; + std::thread::sleep(std::time::Duration::from_millis(10)); + let mut s = load_session_in(td.path(), &state.meta_session_id).unwrap(); + s.last_accessed = Utc::now(); + save_session_in(td.path(), &s).unwrap(); + let s2 = load_session_in(td.path(), &state.meta_session_id).unwrap(); + assert!(s2.last_accessed > t0); +} + +#[test] +fn test_list_artifacts_empty_output() { + let td = tempdir().unwrap(); + let state = create_session_in(td.path(), td.path(), None, None, None).unwrap(); + assert!( + list_artifacts_in(td.path(), &state.meta_session_id) + .unwrap() + .is_empty() + ); +} + +#[test] +fn test_operations_with_invalid_session_id() { + let td = tempdir().unwrap(); + let bad = "not-a-valid-ulid"; + assert!(load_session_in(td.path(), bad).is_err()); + assert!(delete_session_in(td.path(), bad).is_err()); + assert!(load_metadata_in(td.path(), bad).is_err()); + assert!(validate_tool_access_in(td.path(), bad, "codex").is_err()); +} diff --git a/crates/csa-session/src/validate.rs b/crates/csa-session/src/validate.rs index bd8edbc..6072418 100644 --- a/crates/csa-session/src/validate.rs +++ b/crates/csa-session/src/validate.rs @@ -1,6 +1,6 @@ //! ULID validation and prefix matching -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use std::path::Path; /// Generate a new ULID session ID diff --git a/crates/csa-todo/src/dag.rs b/crates/csa-todo/src/dag.rs new file mode 100644 index 0000000..a4488c6 --- /dev/null +++ b/crates/csa-todo/src/dag.rs @@ -0,0 +1,552 @@ +use anyhow::{Result, anyhow, bail}; +use std::collections::{BTreeSet, HashMap, VecDeque}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DependencyNode { + pub title: String, + pub is_done: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct DependencyGraph { + nodes: Vec, + edges: Vec>, + incoming: Vec>, +} + +impl DependencyGraph { + /// Build a dependency graph from TODO markdown content. + /// + /// Supported dependency formats: + /// - Inline annotation: `- [ ] Task B (depends: Task A, Task C)` + /// - `## Dependencies` section with `Task A -> Task B` lines + pub fn from_markdown(markdown: &str) -> Result { + let mut parsed_nodes: Vec = Vec::new(); + let mut section_dependencies: Vec<(String, String)> = Vec::new(); + let mut in_dependencies_section = false; + + for raw_line in markdown.lines() { + let line = raw_line.trim(); + + if let Some(heading) = parse_heading(line) { + in_dependencies_section = heading.eq_ignore_ascii_case("dependencies"); + continue; + } + + if in_dependencies_section { + if let Some((from, to)) = parse_dependency_relation(line) { + section_dependencies.push((from, to)); + } + continue; + } + + if let Some((is_done, raw_title)) = parse_checkbox_item(line) { + let (title, inline_dependencies) = split_inline_dependencies(&raw_title); + if title.is_empty() { + continue; + } + + parsed_nodes.push(ParsedNode { + title, + is_done, + inline_dependencies, + }); + } + } + + let nodes: Vec = parsed_nodes + .iter() + .map(|n| DependencyNode { + title: n.title.clone(), + is_done: n.is_done, + }) + .collect(); + + let mut title_to_indices: HashMap> = HashMap::new(); + for (index, node) in nodes.iter().enumerate() { + title_to_indices + .entry(normalize_reference(&node.title)) + .or_default() + .push(index); + } + + let mut edge_set: BTreeSet<(usize, usize)> = BTreeSet::new(); + + for (to_index, parsed) in parsed_nodes.iter().enumerate() { + for dependency in &parsed.inline_dependencies { + let from_index = resolve_reference(dependency, &title_to_indices)?; + edge_set.insert((from_index, to_index)); + } + } + + for (from_ref, to_ref) in section_dependencies { + let from_index = resolve_reference(&from_ref, &title_to_indices)?; + let to_index = resolve_reference(&to_ref, &title_to_indices)?; + edge_set.insert((from_index, to_index)); + } + + let mut edges = vec![Vec::new(); nodes.len()]; + let mut incoming = vec![Vec::new(); nodes.len()]; + for (from, to) in edge_set { + edges[from].push(to); + incoming[to].push(from); + } + + Ok(Self { + nodes, + edges, + incoming, + }) + } + + pub fn nodes(&self) -> &[DependencyNode] { + &self.nodes + } + + pub fn node_count(&self) -> usize { + self.nodes.len() + } + + pub fn edge_count(&self) -> usize { + self.edges.iter().map(std::vec::Vec::len).sum() + } + + /// Detect a cycle using a BFS-style topological reduction (Kahn's algorithm). + /// Returns the titles still carrying in-degree after traversal. + pub fn cycle_nodes_bfs(&self) -> Option> { + let mut indegree: Vec = self.incoming.iter().map(std::vec::Vec::len).collect(); + let mut queue: VecDeque = indegree + .iter() + .enumerate() + .filter_map(|(index, degree)| (*degree == 0).then_some(index)) + .collect(); + let mut visited = 0usize; + + while let Some(node) = queue.pop_front() { + visited += 1; + for &next in &self.edges[node] { + indegree[next] = indegree[next].saturating_sub(1); + if indegree[next] == 0 { + queue.push_back(next); + } + } + } + + if visited == self.nodes.len() { + None + } else { + Some( + indegree + .iter() + .enumerate() + .filter_map(|(index, degree)| { + (*degree > 0).then_some(self.nodes[index].title.clone()) + }) + .collect(), + ) + } + } + + pub fn has_cycle_bfs(&self) -> bool { + self.cycle_nodes_bfs().is_some() + } + + /// Return a topological execution order. Errors if the graph has cycles. + pub fn topological_sort(&self) -> Result> { + let mut indegree: Vec = self.incoming.iter().map(std::vec::Vec::len).collect(); + let mut queue: VecDeque = indegree + .iter() + .enumerate() + .filter_map(|(index, degree)| (*degree == 0).then_some(index)) + .collect(); + let mut order = Vec::with_capacity(self.nodes.len()); + + while let Some(node) = queue.pop_front() { + order.push(node); + for &next in &self.edges[node] { + indegree[next] = indegree[next].saturating_sub(1); + if indegree[next] == 0 { + queue.push_back(next); + } + } + } + + if order.len() == self.nodes.len() { + Ok(order) + } else { + let cycle = self + .cycle_nodes_bfs() + .unwrap_or_else(|| vec!["unknown".to_string()]); + bail!("Dependency cycle detected: {}", cycle.join(" -> ")); + } + } + + pub fn to_mermaid(&self) -> String { + let mut output = String::from("graph TD\n"); + + for (index, node) in self.nodes.iter().enumerate() { + output.push_str(&format!( + " N{index}[\"{}\"]\n", + escape_mermaid_label(&node.title) + )); + } + + for (from, children) in self.edges.iter().enumerate() { + for to in children { + output.push_str(&format!(" N{from} --> N{to}\n")); + } + } + + output + } + + pub fn to_dot(&self) -> String { + let mut output = String::from("digraph TODO {\n rankdir=LR;\n"); + + for (index, node) in self.nodes.iter().enumerate() { + output.push_str(&format!( + " n{index} [label=\"{}\"];\n", + escape_dot_label(&node.title) + )); + } + + for (from, children) in self.edges.iter().enumerate() { + for to in children { + output.push_str(&format!(" n{from} -> n{to};\n")); + } + } + + output.push_str("}\n"); + output + } + + /// Render dependency trees from all roots (in-degree 0) to leaf tasks. + pub fn to_terminal(&self) -> String { + if self.nodes.is_empty() { + return String::new(); + } + + let mut roots: Vec = self + .incoming + .iter() + .enumerate() + .filter_map(|(index, incoming)| incoming.is_empty().then_some(index)) + .collect(); + + if roots.is_empty() { + roots = (0..self.nodes.len()).collect(); + } + + let mut lines = Vec::new(); + for root in roots { + self.render_terminal_node(root, "", true, &mut Vec::new(), &mut lines); + } + lines.join("\n") + } + + fn render_terminal_node( + &self, + node_index: usize, + prefix: &str, + is_last: bool, + path: &mut Vec, + lines: &mut Vec, + ) { + let done_suffix = if self.nodes[node_index].is_done { + " \u{2713}" + } else { + "" + }; + + if path.is_empty() { + lines.push(format!("{}{}", self.nodes[node_index].title, done_suffix)); + } else { + let branch = if is_last { "└── " } else { "├── " }; + lines.push(format!( + "{prefix}{branch}{}{}", + self.nodes[node_index].title, done_suffix + )); + } + + if path.contains(&node_index) { + return; + } + + path.push(node_index); + let children = &self.edges[node_index]; + for (index, child) in children.iter().enumerate() { + let next_prefix = if path.len() == 1 { + String::new() + } else { + let suffix = if is_last { " " } else { "│ " }; + format!("{prefix}{suffix}") + }; + self.render_terminal_node( + *child, + &next_prefix, + index + 1 == children.len(), + path, + lines, + ); + } + let _ = path.pop(); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedNode { + title: String, + is_done: bool, + inline_dependencies: Vec, +} + +fn parse_heading(line: &str) -> Option<&str> { + let hash_count = line.chars().take_while(|c| *c == '#').count(); + if hash_count == 0 { + return None; + } + + let rest = line.get(hash_count..)?.trim(); + if rest.is_empty() { None } else { Some(rest) } +} + +fn parse_checkbox_item(line: &str) -> Option<(bool, String)> { + let mut rest = line.trim_start(); + rest = rest + .strip_prefix('-') + .or_else(|| rest.strip_prefix('*')) + .map(str::trim_start)?; + rest = rest.strip_prefix('[')?; + + let marker = rest.chars().next()?; + let is_done = match marker { + 'x' | 'X' => true, + ' ' => false, + _ => return None, + }; + + rest = rest.get(marker.len_utf8()..)?; + rest = rest.strip_prefix(']')?; + + let title = rest.trim_start(); + if title.is_empty() { + return None; + } + + Some((is_done, title.to_string())) +} + +fn split_inline_dependencies(title: &str) -> (String, Vec) { + let lower = title.to_ascii_lowercase(); + let Some(start) = lower.rfind("(depends:") else { + return (title.trim().to_string(), Vec::new()); + }; + + if !title.ends_with(')') { + return (title.trim().to_string(), Vec::new()); + } + + let dep_start = start + "(depends:".len(); + if dep_start >= title.len() - 1 { + return (title.trim().to_string(), Vec::new()); + } + + let dependencies_raw = &title[dep_start..title.len() - 1]; + let dependencies: Vec = dependencies_raw + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect(); + + if dependencies.is_empty() { + return (title.trim().to_string(), Vec::new()); + } + + let clean_title = title[..start].trim_end().to_string(); + if clean_title.is_empty() { + return (title.trim().to_string(), Vec::new()); + } + + (clean_title, dependencies) +} + +fn parse_dependency_relation(line: &str) -> Option<(String, String)> { + if line.is_empty() { + return None; + } + + let relation = line + .strip_prefix('-') + .or_else(|| line.strip_prefix('*')) + .map(str::trim_start) + .unwrap_or(line); + let (from, to) = relation.split_once("->")?; + let from = from.trim(); + let to = to.trim(); + if from.is_empty() || to.is_empty() { + return None; + } + Some((from.to_string(), to.to_string())) +} + +fn normalize_reference(value: &str) -> String { + value + .split_whitespace() + .collect::>() + .join(" ") + .to_ascii_lowercase() +} + +fn resolve_reference( + reference: &str, + title_to_indices: &HashMap>, +) -> Result { + let normalized = normalize_reference(reference); + let indices = title_to_indices + .get(&normalized) + .ok_or_else(|| anyhow!("Unknown dependency reference: '{reference}'"))?; + + match indices.as_slice() { + [index] => Ok(*index), + _ => bail!("Ambiguous dependency reference: '{reference}'"), + } +} + +fn escape_mermaid_label(label: &str) -> String { + label.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn escape_dot_label(label: &str) -> String { + label.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn parse_inline_dependencies_and_topological_order() { + let markdown = r#" +# TODO + +- [x] Migrate to edition 2024 +- [x] Fix UTF-8 truncation (depends: Migrate to edition 2024) +- [x] Fix session resume (depends: Migrate to edition 2024) +- [ ] Integrate agent-teams (depends: Fix UTF-8 truncation, Fix session resume) +"#; + + let graph = DependencyGraph::from_markdown(markdown).unwrap(); + assert_eq!(graph.node_count(), 4); + assert_eq!(graph.edge_count(), 4); + assert!(!graph.has_cycle_bfs()); + + let order = graph.topological_sort().unwrap(); + let mut position = HashMap::new(); + for (rank, node_index) in order.iter().enumerate() { + position.insert(graph.nodes()[*node_index].title.clone(), rank); + } + + assert!( + position["Migrate to edition 2024"] < position["Fix UTF-8 truncation"], + "migrate should run before utf-8 fix" + ); + assert!( + position["Migrate to edition 2024"] < position["Fix session resume"], + "migrate should run before session fix" + ); + assert!( + position["Fix UTF-8 truncation"] < position["Integrate agent-teams"], + "utf-8 fix should run before integration" + ); + assert!( + position["Fix session resume"] < position["Integrate agent-teams"], + "session fix should run before integration" + ); + } + + #[test] + fn parse_dependencies_section_edges() { + let markdown = r#" +# TODO + +- [ ] Task A +- [ ] Task B +- [ ] Task C + +## Dependencies +- Task A -> Task B +- Task B -> Task C +"#; + + let graph = DependencyGraph::from_markdown(markdown).unwrap(); + assert_eq!(graph.node_count(), 3); + assert_eq!(graph.edge_count(), 2); + + let mermaid = graph.to_mermaid(); + assert!(mermaid.contains("graph TD")); + assert!(mermaid.contains("N0 --> N1")); + assert!(mermaid.contains("N1 --> N2")); + } + + #[test] + fn detect_cycle_with_bfs() { + let markdown = r#" +- [ ] Task A (depends: Task B) +- [ ] Task B (depends: Task A) +"#; + + let graph = DependencyGraph::from_markdown(markdown).unwrap(); + assert!(graph.has_cycle_bfs()); + + let cycle_nodes = graph.cycle_nodes_bfs().unwrap(); + assert_eq!(cycle_nodes.len(), 2); + assert!(cycle_nodes.iter().any(|n| n == "Task A")); + assert!(cycle_nodes.iter().any(|n| n == "Task B")); + assert!(graph.topological_sort().is_err()); + } + + #[test] + fn terminal_output_includes_tree_and_status() { + let markdown = r#" +- [x] [P0] Migrate to edition 2024 +- [x] [P0] Fix UTF-8 truncation (depends: [P0] Migrate to edition 2024) +- [x] [P0] Fix session resume (depends: [P0] Migrate to edition 2024) +- [ ] [1] Integrate agent-teams (depends: [P0] Fix UTF-8 truncation, [P0] Fix session resume) +"#; + + let graph = DependencyGraph::from_markdown(markdown).unwrap(); + let terminal = graph.to_terminal(); + + assert!(terminal.contains("[P0] Migrate to edition 2024 ✓")); + assert!(terminal.contains("├── [P0] Fix UTF-8 truncation ✓")); + assert!(terminal.contains("└── [P0] Fix session resume ✓")); + assert!(terminal.contains("[1] Integrate agent-teams")); + } + + #[test] + fn dot_output_contains_nodes_and_edges() { + let markdown = r#" +- [ ] Task A +- [ ] Task B (depends: Task A) +"#; + + let graph = DependencyGraph::from_markdown(markdown).unwrap(); + let dot = graph.to_dot(); + + assert!(dot.starts_with("digraph TODO")); + assert!(dot.contains("n0 [label=\"Task A\"];")); + assert!(dot.contains("n0 -> n1;")); + } + + #[test] + fn unknown_dependency_reference_returns_error() { + let markdown = r#" +- [ ] Task B (depends: Task A) +"#; + + let err = DependencyGraph::from_markdown(markdown).unwrap_err(); + assert!(err.to_string().contains("Unknown dependency reference")); + } +} diff --git a/crates/csa-todo/src/git.rs b/crates/csa-todo/src/git.rs index ad041ad..4953de7 100644 --- a/crates/csa-todo/src/git.rs +++ b/crates/csa-todo/src/git.rs @@ -282,9 +282,11 @@ fn save_paths( /// Get the diff of a plan's TODO.md against a revision. /// -/// When `revision` is `None`, diffs against the file's own last commit -/// (not HEAD, which may belong to a different plan in this multi-plan repo). -/// If the file has never been committed, shows the entire file as new content. +/// When `revision` is `None`: +/// - if working copy has uncommitted TODO.md changes, show working copy diff +/// - if working copy is clean and there are >=2 committed versions, show v2 -> v1 +/// - if working copy is clean and there is 1 committed version, show initial content as new +/// - if file has never been committed, show the entire working file as new content pub fn diff(todos_dir: &Path, timestamp: &str, revision: Option<&str>) -> Result { crate::validate_timestamp(timestamp)?; @@ -306,22 +308,7 @@ pub fn diff(todos_dir: &Path, timestamp: &str, revision: Option<&str>) -> Result Some(hash) => hash, None => { // File never committed — show full working copy as new - let full_path = todos_dir.join(&file_path); - return if full_path.exists() { - let content = std::fs::read_to_string(&full_path) - .with_context(|| format!("Failed to read {}", full_path.display()))?; - Ok(format!( - "--- /dev/null\n+++ b/{file_path}\n@@ -0,0 +1,{} @@\n{}", - content.lines().count(), - content - .lines() - .map(|l| format!("+{l}")) - .collect::>() - .join("\n") - )) - } else { - Ok(String::new()) - }; + return new_file_diff_from_working_copy(todos_dir, &file_path); } } } @@ -341,7 +328,59 @@ pub fn diff(todos_dir: &Path, timestamp: &str, revision: Option<&str>) -> Result ); } - Ok(String::from_utf8_lossy(&output.stdout).to_string()) + let working_diff = String::from_utf8_lossy(&output.stdout).to_string(); + if revision.is_some() || !working_diff.is_empty() { + return Ok(working_diff); + } + + // Working copy is clean: show most recent saved changes by default. + let versions = list_versions(todos_dir, timestamp)?; + match versions.len() { + 0 => Ok(String::new()), + 1 => new_file_diff_from_commit(todos_dir, &versions[0], &file_path), + _ => diff_versions(todos_dir, timestamp, 2, 1), + } +} + +fn new_file_diff_from_working_copy(todos_dir: &Path, file_path: &str) -> Result { + let full_path = todos_dir.join(file_path); + if !full_path.exists() { + return Ok(String::new()); + } + + let content = std::fs::read_to_string(&full_path) + .with_context(|| format!("Failed to read {}", full_path.display()))?; + Ok(render_new_file_diff(file_path, &content)) +} + +fn new_file_diff_from_commit(todos_dir: &Path, hash: &str, file_path: &str) -> Result { + let output = Command::new("git") + .args(["show", &format!("{hash}:{file_path}")]) + .current_dir(todos_dir) + .output() + .context("Failed to run git show")?; + + if !output.status.success() { + anyhow::bail!( + "git show failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let content = String::from_utf8_lossy(&output.stdout); + Ok(render_new_file_diff(file_path, &content)) +} + +fn render_new_file_diff(file_path: &str, content: &str) -> String { + format!( + "--- /dev/null\n+++ b/{file_path}\n@@ -0,0 +1,{} @@\n{}", + content.lines().count(), + content + .lines() + .map(|line| format!("+{line}")) + .collect::>() + .join("\n") + ) } /// Find the last commit hash that touched a specific file. diff --git a/crates/csa-todo/src/git_ext_tests.rs b/crates/csa-todo/src/git_ext_tests.rs index ff033bf..89ce548 100644 --- a/crates/csa-todo/src/git_ext_tests.rs +++ b/crates/csa-todo/src/git_ext_tests.rs @@ -245,6 +245,51 @@ fn test_diff_against_last_commit() { ); } +#[test] +fn test_diff_clean_working_copy_defaults_to_last_two_versions() { + let dir = tempdir().unwrap(); + let todos = dir.path(); + let ts = "20260101T000000"; + + setup_todos_dir(todos, ts); + fs::write(todos.join(ts).join("TODO.md"), "# Version 1\n").unwrap(); + save(todos, ts, "v1").unwrap(); + + fs::write(todos.join(ts).join("TODO.md"), "# Version 2\n").unwrap(); + save(todos, ts, "v2").unwrap(); + + let diff_output = diff(todos, ts, None).unwrap(); + assert!( + diff_output.contains("-# Version 1"), + "clean default diff should include previous version, got: {diff_output}" + ); + assert!( + diff_output.contains("+# Version 2"), + "clean default diff should include latest version, got: {diff_output}" + ); +} + +#[test] +fn test_diff_clean_working_copy_with_single_version_shows_initial_content() { + let dir = tempdir().unwrap(); + let todos = dir.path(); + let ts = "20260101T000000"; + + setup_todos_dir(todos, ts); + fs::write(todos.join(ts).join("TODO.md"), "# Initial save\n").unwrap(); + save(todos, ts, "v1").unwrap(); + + let diff_output = diff(todos, ts, None).unwrap(); + assert!( + diff_output.contains("--- /dev/null"), + "single-version clean diff should render as new file, got: {diff_output}" + ); + assert!( + diff_output.contains("+# Initial save"), + "single-version clean diff should include initial content, got: {diff_output}" + ); +} + #[test] fn test_diff_uncommitted_file_shows_full_content() { let dir = tempdir().unwrap(); diff --git a/crates/csa-todo/src/lib.rs b/crates/csa-todo/src/lib.rs index 2d68ed2..b330782 100644 --- a/crates/csa-todo/src/lib.rs +++ b/crates/csa-todo/src/lib.rs @@ -408,6 +408,7 @@ fn atomic_write(target: &Path, data: &[u8]) -> Result<()> { Ok(()) } +pub mod dag; pub mod git; // --------------------------------------------------------------------------- @@ -543,9 +544,11 @@ mod tests { let found = manager.find_by_branch("feat/alpha").unwrap(); assert_eq!(found.len(), 2); - assert!(found - .iter() - .all(|p| p.metadata.branch.as_deref() == Some("feat/alpha"))); + assert!( + found + .iter() + .all(|p| p.metadata.branch.as_deref() == Some("feat/alpha")) + ); } #[test] diff --git a/deny.toml b/deny.toml index 7ed8e1d..5a106e5 100644 --- a/deny.toml +++ b/deny.toml @@ -3,7 +3,11 @@ [advisories] version = 2 -ignore = [] +ignore = [ + # Transitive from agent-teams -> cc-sdk -> reqwest. + # rustls-pemfile is archived; no non-breaking safe upgrade is available yet. + "RUSTSEC-2025-0134", +] [licenses] version = 2