diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0c3aa48 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,74 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-action@stable + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Run tests + run: cargo test --workspace --all-features + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-action@stable + with: + components: clippy + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Run clippy + run: cargo clippy --workspace --all-features -- -D warnings + + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-action@stable + with: + components: rustfmt + + - name: Check formatting + run: cargo fmt --all -- --check diff --git a/Cargo.lock b/Cargo.lock index 0e18731..988a0c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,36 +11,128 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[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 = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-future" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core 0.4.5", + "bytes", + "futures-util", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "axum" version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ - "axum-core", + "axum-core 0.5.6", "axum-macros", + "base64", "bytes", "form_urlencoded", "futures-util", - "http", + "http 1.4.0", "http-body", "http-body-util", "hyper", "hyper-util", "itoa", - "matchit", + "matchit 0.8.4", "memchr", "mime", "percent-encoding", @@ -49,14 +141,37 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", "tracing", ] +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.4.0", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "axum-core" version = "0.5.6" @@ -65,7 +180,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", + "http 1.4.0", "http-body", "http-body-util", "mime", @@ -87,11 +202,77 @@ dependencies = [ "syn", ] +[[package]] +name = "axum-test" +version = "16.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e3a443d2608936a02a222da7b746eb412fede7225b3030b64fe9be99eab8dc" +dependencies = [ + "anyhow", + "assert-json-diff", + "auto-future", + "axum 0.7.9", + "bytes", + "bytesize", + "cookie", + "http 1.4.0", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "tokio", + "tower", + "url", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" + [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" @@ -99,6 +280,22 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "bytesize" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e93abca9e28e0a1b9877922aacb20576e05d4679ffa78c3d6dc22a26a216659" + +[[package]] +name = "cc" +version = "1.2.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -106,346 +303,385 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "errno" -version = "0.3.14" +name = "chrono" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "libc", - "windows-sys 0.61.2", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", ] [[package]] -name = "form_urlencoded" -version = "1.2.2" +name = "concurrent-queue" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ - "percent-encoding", + "crossbeam-utils", ] [[package]] -name = "futures-channel" -version = "0.3.31" +name = "const-oid" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ - "futures-core", + "time", + "version_check", ] [[package]] -name = "futures-core" -version = "0.3.31" +name = "core-foundation" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] [[package]] -name = "futures-task" -version = "0.3.31" +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "futures-util" -version = "0.3.31" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "pin-utils", + "libc", ] [[package]] -name = "http" -version = "1.4.0" +name = "crc" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ - "bytes", - "itoa", + "crc-catalog", ] [[package]] -name = "http-body" -version = "1.0.1" +name = "crc-catalog" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] -name = "http-body-util" -version = "0.1.3" +name = "crossbeam-queue" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", + "crossbeam-utils", ] [[package]] -name = "httparse" -version = "1.10.1" +name = "crossbeam-utils" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] -name = "httpdate" -version = "1.0.3" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] [[package]] -name = "hyper" -version = "1.8.1" +name = "data-encoding" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "db" +version = "0.1.0" dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", + "chrono", + "opencode_core", + "sqlx", + "thiserror 2.0.17", "tokio", + "tracing", + "uuid", ] [[package]] -name = "hyper-util" -version = "0.1.19" +name = "der" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "hyper", - "pin-project-lite", - "tokio", - "tower-service", + "const-oid", + "pem-rfc7468", + "zeroize", ] [[package]] -name = "itoa" -version = "1.0.17" +name = "deranged" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] [[package]] -name = "lazy_static" -version = "1.5.0" +name = "diff" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" [[package]] -name = "libc" -version = "0.2.178" +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] [[package]] -name = "lock_api" -version = "0.4.14" +name = "displaydoc" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ - "scopeguard", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "log" -version = "0.4.29" +name = "dotenvy" +version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] -name = "matchers" -version = "0.2.0" +name = "either" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" dependencies = [ - "regex-automata", + "serde", ] [[package]] -name = "matchit" -version = "0.8.4" +name = "encoding_rs" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] [[package]] -name = "memchr" -version = "2.7.6" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[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" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "wasi", "windows-sys 0.61.2", ] [[package]] -name = "nu-ansi-term" -version = "0.50.3" +name = "etcetera" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" dependencies = [ - "windows-sys 0.61.2", + "cfg-if", + "home", + "windows-sys 0.48.0", ] [[package]] -name = "once_cell" -version = "1.21.3" +name = "event-listener" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] [[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +name = "events" +version = "0.1.0" dependencies = [ - "lock_api", - "parking_lot_core", + "chrono", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "uuid", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "eventsource-stream" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", + "futures-core", + "nom", + "pin-project-lite", ] [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "fastrand" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] -name = "pin-project-lite" -version = "0.2.16" +name = "find-msvc-tools" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" [[package]] -name = "pin-utils" -version = "0.1.0" +name = "flume" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] [[package]] -name = "proc-macro2" -version = "1.0.104" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" -dependencies = [ - "unicode-ident", -] +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "quote" -version = "1.0.42" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" -dependencies = [ - "proc-macro2", -] +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "foreign-types" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "bitflags", + "foreign-types-shared", ] [[package]] -name = "regex-automata" -version = "0.4.13" +name = "foreign-types-shared" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", + "percent-encoding", ] [[package]] -name = "regex-syntax" -version = "0.8.8" +name = "futures" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] [[package]] -name = "ryu" -version = "1.0.22" +name = "futures-channel" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "futures-core" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] -name = "serde" -version = "1.0.228" +name = "futures-executor" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ - "serde_core", - "serde_derive", + "futures-core", + "futures-task", + "futures-util", ] [[package]] -name = "serde_core" -version = "1.0.228" +name = "futures-intrusive" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" dependencies = [ - "serde_derive", + "futures-core", + "lock_api", + "parking_lot", ] [[package]] -name = "serde_derive" -version = "1.0.228" +name = "futures-io" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +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", @@ -453,151 +689,1879 @@ dependencies = [ ] [[package]] -name = "serde_json" -version = "1.0.148" +name = "futures-sink" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +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 = [ - "itoa", + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", "memchr", - "serde", - "serde_core", - "zmij", + "pin-project-lite", + "pin-utils", + "slab", ] [[package]] -name = "serde_path_to_error" -version = "0.1.20" +name = "generic-array" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ - "itoa", - "serde", - "serde_core", + "typenum", + "version_check", ] [[package]] -name = "serde_urlencoded" -version = "0.7.1" +name = "getrandom" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", + "cfg-if", + "libc", + "wasi", ] [[package]] -name = "server" -version = "0.1.0" +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "anyhow", - "axum", - "serde", - "serde_json", - "tokio", - "tower-http", - "tracing", - "tracing-subscriber", + "cfg-if", + "libc", + "r-efi", + "wasip2", ] [[package]] -name = "sharded-slab" -version = "0.1.7" +name = "h2" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ - "lazy_static", + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] -name = "signal-hook-registry" -version = "1.4.8" +name = "hashbrown" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "errno", - "libc", + "allocator-api2", + "equivalent", + "foldhash", ] [[package]] -name = "smallvec" -version = "1.15.1" +name = "hashbrown" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] -name = "socket2" -version = "0.6.1" +name = "hashlink" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "libc", - "windows-sys 0.60.2", + "hashbrown 0.15.5", ] [[package]] -name = "syn" -version = "2.0.111" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "sync_wrapper" -version = "1.0.2" +name = "hex" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "thread_local" -version = "1.1.9" +name = "hkdf" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "cfg-if", + "hmac", ] [[package]] -name = "tokio" -version = "1.48.0" +name = "hmac" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", "windows-sys 0.61.2", ] [[package]] -name = "tokio-macros" -version = "2.6.0" +name = "http" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" dependencies = [ - "proc-macro2", - "quote", - "syn", + "bytes", + "fnv", + "itoa", ] [[package]] -name = "tower" -version = "0.5.2" +name = "http" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ - "futures-core", + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body", + "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 = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http 1.4.0", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +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.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", + "redox_syscall 0.7.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +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 = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "opencode" +version = "0.1.0" +dependencies = [ + "bytes", + "chrono", + "eventsource-stream", + "futures", + "opencode_core", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "opencode_core" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "uuid", +] + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "orchestrator" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "db", + "opencode", + "opencode_core", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[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 = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +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 0.9.0", + "rand_core 0.9.3", +] + +[[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]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http 0.6.8", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "reserve-port" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356" +dependencies = [ + "thiserror 2.0.17", +] + +[[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.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a0376c50d0358279d9d643e4bf7b7be212f1f4ff1da9070a7b54d22ef75c88" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b748410c0afdef2ebbe3685a6a862e2ee937127cdaae623336a459451c8d57" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http 0.2.12", + "mime", + "mime_guess", + "rand 0.8.5", + "thiserror 1.0.69", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "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.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.148" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[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 = "server" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum 0.8.8", + "axum-test", + "db", + "events", + "opencode", + "opencode_core", + "orchestrator", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.17", + "tokio", + "tower-http 0.5.2", + "tracing", + "tracing-subscriber", + "uuid", + "vcs", + "websocket", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.17", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.17", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.17", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[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 = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "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 = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", "futures-util", "pin-project-lite", "sync_wrapper", @@ -608,51 +2572,404 @@ dependencies = [ ] [[package]] -name = "tower-http" -version = "0.5.2" +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags", + "bytes", + "http 1.4.0", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.4.0", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.17", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +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 = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vcs" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "opencode_core", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.17", + "tokio", + "tracing", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ - "bitflags", - "bytes", - "http", - "http-body", - "http-body-util", - "pin-project-lite", - "tower-layer", - "tower-service", - "tracing", + "unicode-ident", ] [[package]] -name = "tower-layer" -version = "0.3.3" +name = "wasm-streams" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] [[package]] -name = "tower-service" -version = "0.3.3" +name = "web-sys" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] -name = "tracing" -version = "0.1.44" +name = "websocket" +version = "0.1.0" +dependencies = [ + "axum 0.8.8", + "events", + "futures-util", + "serde", + "serde_json", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "whoami" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", + "libredox", + "wasite", ] [[package]] -name = "tracing-attributes" -version = "0.1.31" +name = "windows-core" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", @@ -660,67 +2977,68 @@ dependencies = [ ] [[package]] -name = "tracing-core" -version = "0.1.36" +name = "windows-interface" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ - "once_cell", - "valuable", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "tracing-log" -version = "0.2.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "tracing-subscriber" -version = "0.3.22" +name = "windows-registry" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] -name = "unicode-ident" -version = "1.0.22" +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] [[package]] -name = "valuable" -version = "0.1.1" +name = "windows-strings" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] [[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +name = "windows-sys" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] [[package]] -name = "windows-link" -version = "0.2.1" +name = "windows-sys" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] [[package]] name = "windows-sys" @@ -728,7 +3046,7 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -740,6 +3058,37 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -747,64 +3096,275 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[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.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +dependencies = [ + "proc-macro2", + "quote", + "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 = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[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.3" diff --git a/Cargo.toml b/Cargo.toml index ac4b95a..0ea44ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,19 +1,63 @@ [workspace] resolver = "2" members = [ + "crates/core", + "crates/db", "crates/server", + "crates/opencode", + "crates/orchestrator", + "crates/vcs", + "crates/events", + "crates/websocket", ] +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.75" + [workspace.dependencies] +# Internal crates +opencode_core = { path = "crates/core" } +db = { path = "crates/db" } +opencode = { path = "crates/opencode" } +orchestrator = { path = "crates/orchestrator" } +vcs = { path = "crates/vcs" } +events = { path = "crates/events" } +websocket = { path = "crates/websocket" } + +# HTTP client +reqwest = { version = "0.12", features = ["json", "stream"] } +bytes = "1.0" +futures = "0.3" +eventsource-stream = "0.2" + +# Async runtime tokio = { version = "1.0", features = ["full"] } -axum = { version = "0.8", features = ["macros"] } + +# Web framework +axum = { version = "0.8", features = ["macros", "ws"] } tower-http = { version = "0.5", features = ["cors", "trace"] } + +# Database +sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } + +# Serialization serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" + +# Error handling anyhow = "1.0" thiserror = "2.0" + +# Logging tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +# Utils +uuid = { version = "1.0", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +async-trait = "0.1" + [profile.release] strip = true diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..2ff8336 --- /dev/null +++ b/agents.md @@ -0,0 +1,360 @@ +# OpenCode Studio - Agent Context + +> Tento dokument slouzi jako kontext pro AI agenty pracujici na projektu. +> Posledni aktualizace: 2025-12-30 + +--- + +## 1. Co je OpenCode Studio + +**OpenCode Studio** je autonomni AI-powered development platform, ktera orchestruje OpenCode sessions pro automatizovany vyvoj software. + +### Klicove principy +- **Autonomie**: Minimalni lidska intervence behem vyvoje +- **Transparentnost**: Komunikace pres soubory (plany, reviews, roadmapa) +- **Modularita**: Plugovatelne moduly pro ruzne AI-powered funkce +- **Skalovatnost**: Paralelni beh vice agentu + +### Dvouvrstva architektura +``` +ROADMAP (produktova vrstva) - "Co a proc" + │ + │ [Presunout do vyvoje] + ▼ +KANBAN (implementacni vrstva) - "Jak" +``` + +--- + +## 2. Task Lifecycle (State Machine) + +``` +TODO → PLANNING → PLANNING_REVIEW → IN_PROGRESS → AI_REVIEW → REVIEW → DONE + (AI plan) (optional) (OpenCode) (AI check) (human) +``` + +### Prechody stavu +| From | Allowed To | +|------|------------| +| Todo | Planning | +| Planning | PlanningReview, Todo | +| PlanningReview | InProgress, Planning | +| InProgress | AiReview, PlanningReview | +| AiReview | Review, InProgress | +| Review | Done, InProgress | +| Done | (terminal) | + +### Session Strategy +Kazda faze = vlastni OpenCode session, komunikace pres soubory: + +| Faze | Input | Output | +|------|-------|--------| +| PLANNING | task description | `plans/{id}.md` | +| IN_PROGRESS | plan | kod ve workspace | +| AI_REVIEW | diff, task | `reviews/{id}.md` | + +--- + +## 3. Crates Architecture + +``` +crates/ +├── core/ # Domain models, traits (NO I/O) +│ └── domain/ # Task, Session, TaskStatus +├── db/ # SQLite persistence (sqlx) +│ ├── models/ # DB models +│ └── repositories/ # TaskRepository, SessionRepository +├── opencode/ # OpenCode HTTP client +│ ├── client.rs # OpenCodeClient (create_session, send_message, etc.) +│ ├── types.rs # Session, Message, SendMessageRequest +│ └── events.rs # SSE EventStream, OpenCodeEvent +├── orchestrator/ # Task lifecycle, scheduling +│ ├── executor.rs # TaskExecutor (execute_phase, run_planning_session, etc.) +│ ├── state_machine.rs # TaskStateMachine (validate_transition) +│ └── prompts.rs # PhasePrompts (planning, implementation, review) +├── events/ # Event system +│ ├── types.rs # Event, EventEnvelope, AgentMessageData, ToolExecutionData +│ └── bus.rs # EventBus (tokio::sync::broadcast) +├── websocket/ # WebSocket real-time updates +│ ├── handler.rs # ws_handler, WsState +│ └── messages.rs # ClientMessage, ServerMessage, SubscriptionFilter +├── vcs/ # Version control (jj, git) +│ ├── traits.rs # VersionControl trait, Workspace, MergeResult +│ ├── jj.rs # Jujutsu implementation +│ ├── git.rs # Git fallback +│ └── workspace.rs # WorkspaceManager, WorkspaceConfig +├── server/ # Axum HTTP server +│ └── routes/ # health, tasks, sessions, workspaces, ws +└── github/ # [Phase 6] GitHub integration +``` + +### Dependency Graph +``` + server + │ + ┌─────────┼─────────┐ + │ │ │ +orchestrator db opencode + │ │ │ + └─────────┼─────────┘ + │ + core +``` + +--- + +## 4. Current API Endpoints + +### Tasks +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/tasks` | List all tasks | +| POST | `/api/tasks` | Create task | +| GET | `/api/tasks/{id}` | Get task detail | +| PATCH | `/api/tasks/{id}` | Update task | +| DELETE | `/api/tasks/{id}` | Delete task | +| POST | `/api/tasks/{id}/transition` | Change task status | +| POST | `/api/tasks/{id}/execute` | Execute current phase | +| GET | `/api/tasks/{id}/sessions` | List sessions for task | + +### Sessions +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/sessions` | List all sessions | +| GET | `/api/sessions/{id}` | Get session detail | +| DELETE | `/api/sessions/{id}` | Delete session | + +### Workspaces +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/tasks/{id}/workspace` | Create workspace for task | +| GET | `/api/workspaces` | List all workspaces | +| GET | `/api/workspaces/{id}` | Get workspace status | +| GET | `/api/workspaces/{id}/diff` | Get workspace diff | +| POST | `/api/workspaces/{id}/merge` | Merge workspace | +| DELETE | `/api/workspaces/{id}` | Delete/cleanup workspace | + +### WebSocket +| Method | Path | Description | +|--------|------|-------------| +| GET | `/ws` | WebSocket connection for real-time events | + +### Health +| Method | Path | Description | +|--------|------|-------------| +| GET | `/health` | Health check | + +--- + +## 5. OpenCode Integration + +### OpenCodeClient methods +```rust +create_session(title: Option) -> Session +get_session(session_id: &str) -> Session +list_sessions() -> Vec +send_message(session_id, prompt, model) -> MessageResponse +send_message_async(session_id, prompt) -> () +abort_session(session_id) -> () +get_messages(session_id) -> Vec +``` + +### SSE Events +```rust +enum OpenCodeEvent { + SessionMessage { session_id, content } + SessionCompleted { session_id } + SessionError { session_id, error } + TaskStatusChanged { task_id, status } +} +``` + +### EventStream usage +```rust +let stream = EventStream::new("http://localhost:4096"); +let mut receiver = stream.connect().await?; +while let Some(event) = receiver.next_event().await { + // handle event +} +``` + +--- + +## 6. Key Domain Types + +### Task (core) +```rust +struct Task { + id: Uuid, + title: String, + description: String, + status: TaskStatus, + roadmap_item_id: Option, + workspace_path: Option, + created_at: DateTime, + updated_at: DateTime, +} +``` + +### TaskStatus (core) +```rust +enum TaskStatus { + Todo, + Planning, + PlanningReview, + InProgress, + AiReview, + Review, + Done, +} +``` + +### Session (core) +```rust +struct Session { + id: Uuid, + task_id: Uuid, + opencode_session_id: Option, + phase: SessionPhase, // Planning, Implementation, Review + status: SessionStatus, // Pending, Running, Completed, Failed + started_at: Option>, + completed_at: Option>, + created_at: DateTime, +} +``` + +--- + +## 7. Implementation Phases + +### Phase 1: Foundation ✅ DONE +- [x] Workspace setup (core, db, server crates) +- [x] Domain models (Task, Session, TaskStatus) +- [x] SQLite database s migraci +- [x] Basic CRUD API pro tasks +- [x] Health endpoint +- [x] Tracing/logging setup + +### Phase 2: OpenCode Integration ✅ DONE +- [x] OpenCode SDK (HTTP client wrapper) +- [x] SSE event stream handling +- [x] Task executor s phase logic +- [x] State machine pro task transitions +- [x] Session tracking v DB +- [x] API endpoints pro sessions + +### Phase 3: VCS & Workspace Management ✅ DONE +- [x] VCS trait + Jujutsu implementation +- [x] Git fallback implementation +- [x] Workspace manager +- [x] Init/cleanup script runner +- [x] API endpoints pro workspaces + +### Phase 4: WebSocket & Real-time ✅ DONE +- [x] WebSocket handler v Axum +- [x] Event bus (tokio::sync::broadcast) +- [x] Event types (Task, Session, Workspace, Error events) +- [x] WebSocket route at /ws +- [x] Event emission from task routes + +### Phase 5: Full Kanban Flow 🔜 NEXT +- [ ] Planning phase implementation +- [ ] Implementation phase +- [ ] AI Review phase +- [ ] Human Review support +- [ ] Retry logic pro failed reviews + +### Phase 6: GitHub Integration +- [ ] GitHub client (octocrab) +- [ ] Auto PR creation +- [ ] CI status polling +- [ ] Issue import + +### Phase 7: Frontend Integration +- [ ] ts-rs setup pro vsechny typy +- [ ] Generated types v frontend/ +- [ ] React Query hooks +- [ ] WebSocket hook + +--- + +## 8. Tech Stack + +| Layer | Technology | +|-------|------------| +| Runtime | Rust + Tokio | +| HTTP Server | Axum 0.8 | +| Database | SQLite + sqlx 0.8 | +| Serialization | serde + serde_json | +| Error handling | anyhow + thiserror | +| Logging | tracing | +| OpenCode | HTTP client (reqwest) + SSE (eventsource-stream) | + +--- + +## 9. File Structure + +``` +.opencode-studio/ +├── config.toml +├── studio.db +├── kanban/ +│ ├── tasks/{id}.md +│ ├── plans/{id}.md +│ └── reviews/{id}.md +├── roadmap/ +│ ├── roadmap.md +│ └── items/{id}.md +├── scripts/ +│ ├── workspace-init.sh +│ └── workspace-cleanup.sh +└── sessions/ + └── {module}_{timestamp}.log +``` + +--- + +## 10. Running the Project + +```bash +# Run all tests +cargo test --workspace + +# Run server +DATABASE_URL=sqlite:./studio.db cargo run --package server + +# Server runs on http://localhost:3001 +``` + +### Environment Variables +| Variable | Default | Description | +|----------|---------|-------------| +| DATABASE_URL | sqlite:./studio.db | SQLite connection | +| OPENCODE_URL | http://localhost:4096 | OpenCode server URL | +| PORT | 3001 | Server port | + +--- + +## 11. Coding Conventions + +1. **Crate naming**: Avoid reserved names (`core` → `opencode_core`) +2. **Error handling**: Use `thiserror` for custom errors, `anyhow` for application errors +3. **Parsing**: Use `str.parse()` instead of `FromStr::from_str()` +4. **Type safety**: Never use `as any`, `@ts-ignore`, `@ts-expect-error` +5. **Tests**: Each module should have unit tests + +--- + +## 12. Test Coverage + +| Crate | Tests | Status | +|-------|-------|--------| +| db | 10 | ✅ | +| events | 12 | ✅ | +| opencode | 2 | ✅ | +| opencode_core | 8 | ✅ | +| orchestrator | 7 | ✅ | +| vcs | 12 | ✅ | +| websocket | 9 | ✅ | +| server | 0 | (no tests yet) | +| **Total** | **60** | ✅ All passing | diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..a0aef09 --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "opencode_core" +version.workspace = true +edition.workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/core/src/domain/mod.rs b/crates/core/src/domain/mod.rs new file mode 100644 index 0000000..6783b75 --- /dev/null +++ b/crates/core/src/domain/mod.rs @@ -0,0 +1,5 @@ +mod session; +mod task; + +pub use session::*; +pub use task::*; diff --git a/crates/core/src/domain/session.rs b/crates/core/src/domain/session.rs new file mode 100644 index 0000000..3d7913d --- /dev/null +++ b/crates/core/src/domain/session.rs @@ -0,0 +1,154 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum SessionPhase { + #[default] + Planning, + Implementation, + Review, +} + +impl SessionPhase { + pub fn as_str(&self) -> &'static str { + match self { + Self::Planning => "planning", + Self::Implementation => "implementation", + Self::Review => "review", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "planning" => Some(Self::Planning), + "implementation" => Some(Self::Implementation), + "review" => Some(Self::Review), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum SessionStatus { + #[default] + Pending, + Running, + Completed, + Failed, + Aborted, +} + +impl SessionStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Running => "running", + Self::Completed => "completed", + Self::Failed => "failed", + Self::Aborted => "aborted", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "pending" => Some(Self::Pending), + "running" => Some(Self::Running), + "completed" => Some(Self::Completed), + "failed" => Some(Self::Failed), + "aborted" => Some(Self::Aborted), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub id: Uuid, + pub task_id: Uuid, + pub opencode_session_id: Option, + pub phase: SessionPhase, + pub status: SessionStatus, + pub started_at: Option>, + pub completed_at: Option>, + pub created_at: DateTime, +} + +impl Session { + pub fn new(task_id: Uuid, phase: SessionPhase) -> Self { + Self { + id: Uuid::new_v4(), + task_id, + opencode_session_id: None, + phase, + status: SessionStatus::default(), + started_at: None, + completed_at: None, + created_at: Utc::now(), + } + } + + pub fn start(&mut self, opencode_session_id: String) { + self.opencode_session_id = Some(opencode_session_id); + self.status = SessionStatus::Running; + self.started_at = Some(Utc::now()); + } + + pub fn complete(&mut self) { + self.status = SessionStatus::Completed; + self.completed_at = Some(Utc::now()); + } + + pub fn fail(&mut self) { + self.status = SessionStatus::Failed; + self.completed_at = Some(Utc::now()); + } + + pub fn abort(&mut self) { + self.status = SessionStatus::Aborted; + self.completed_at = Some(Utc::now()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_session_creation() { + let task_id = Uuid::new_v4(); + let session = Session::new(task_id, SessionPhase::Planning); + + assert_eq!(session.task_id, task_id); + assert_eq!(session.phase, SessionPhase::Planning); + assert_eq!(session.status, SessionStatus::Pending); + assert!(session.opencode_session_id.is_none()); + } + + #[test] + fn test_session_lifecycle() { + let task_id = Uuid::new_v4(); + let mut session = Session::new(task_id, SessionPhase::Implementation); + + session.start("opencode-123".to_string()); + assert_eq!(session.status, SessionStatus::Running); + assert_eq!( + session.opencode_session_id, + Some("opencode-123".to_string()) + ); + assert!(session.started_at.is_some()); + + session.complete(); + assert_eq!(session.status, SessionStatus::Completed); + assert!(session.completed_at.is_some()); + } + + #[test] + fn test_session_phase_serialization() { + assert_eq!(SessionPhase::Planning.as_str(), "planning"); + assert_eq!(SessionPhase::Implementation.as_str(), "implementation"); + assert_eq!(SessionPhase::parse("review"), Some(SessionPhase::Review)); + } +} diff --git a/crates/core/src/domain/task.rs b/crates/core/src/domain/task.rs new file mode 100644 index 0000000..9283176 --- /dev/null +++ b/crates/core/src/domain/task.rs @@ -0,0 +1,132 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum TaskStatus { + #[default] + Todo, + Planning, + PlanningReview, + InProgress, + AiReview, + Review, + Done, +} + +impl TaskStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Todo => "todo", + Self::Planning => "planning", + Self::PlanningReview => "planning_review", + Self::InProgress => "in_progress", + Self::AiReview => "ai_review", + Self::Review => "review", + Self::Done => "done", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "todo" => Some(Self::Todo), + "planning" => Some(Self::Planning), + "planning_review" => Some(Self::PlanningReview), + "in_progress" => Some(Self::InProgress), + "ai_review" => Some(Self::AiReview), + "review" => Some(Self::Review), + "done" => Some(Self::Done), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Task { + pub id: Uuid, + pub title: String, + pub description: String, + pub status: TaskStatus, + pub roadmap_item_id: Option, + pub workspace_path: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Task { + pub fn new(title: impl Into, description: impl Into) -> Self { + let now = Utc::now(); + Self { + id: Uuid::new_v4(), + title: title.into(), + description: description.into(), + status: TaskStatus::default(), + roadmap_item_id: None, + workspace_path: None, + created_at: now, + updated_at: now, + } + } + + pub fn with_id(mut self, id: Uuid) -> Self { + self.id = id; + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateTaskRequest { + pub title: String, + pub description: String, + pub roadmap_item_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct UpdateTaskRequest { + pub title: Option, + pub description: Option, + pub status: Option, + pub workspace_path: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_task_creation() { + let task = Task::new("Test Task", "Test Description"); + + assert_eq!(task.title, "Test Task"); + assert_eq!(task.description, "Test Description"); + assert_eq!(task.status, TaskStatus::Todo); + assert!(task.roadmap_item_id.is_none()); + assert!(task.workspace_path.is_none()); + } + + #[test] + fn test_task_status_serialization() { + assert_eq!(TaskStatus::Todo.as_str(), "todo"); + assert_eq!(TaskStatus::InProgress.as_str(), "in_progress"); + assert_eq!(TaskStatus::AiReview.as_str(), "ai_review"); + } + + #[test] + fn test_task_status_parsing() { + assert_eq!(TaskStatus::parse("todo"), Some(TaskStatus::Todo)); + assert_eq!( + TaskStatus::parse("in_progress"), + Some(TaskStatus::InProgress) + ); + assert_eq!(TaskStatus::parse("invalid"), None); + } + + #[test] + fn test_task_with_id() { + let id = Uuid::new_v4(); + let task = Task::new("Test", "Description").with_id(id); + + assert_eq!(task.id, id); + } +} diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs new file mode 100644 index 0000000..86986ae --- /dev/null +++ b/crates/core/src/error.rs @@ -0,0 +1,29 @@ +use thiserror::Error; +use uuid::Uuid; + +#[derive(Error, Debug)] +pub enum CoreError { + #[error("Task not found: {0}")] + TaskNotFound(Uuid), + + #[error("Session not found: {0}")] + SessionNotFound(Uuid), + + #[error("Invalid task status transition from {from} to {to}")] + InvalidStatusTransition { from: String, to: String }, + + #[error("Validation error: {0}")] + Validation(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_display() { + let id = Uuid::new_v4(); + let error = CoreError::TaskNotFound(id); + assert!(error.to_string().contains(&id.to_string())); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..b7e721d --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,5 @@ +pub mod domain; +pub mod error; + +pub use domain::*; +pub use error::*; diff --git a/crates/db/Cargo.toml b/crates/db/Cargo.toml new file mode 100644 index 0000000..daa265d --- /dev/null +++ b/crates/db/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "db" +version.workspace = true +edition.workspace = true + +[dependencies] +opencode_core = { workspace = true } +sqlx = { workspace = true } +tokio = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/db/migrations/001_initial.sql b/crates/db/migrations/001_initial.sql new file mode 100644 index 0000000..b12f0d4 --- /dev/null +++ b/crates/db/migrations/001_initial.sql @@ -0,0 +1,37 @@ +CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'todo', + roadmap_item_id TEXT, + workspace_path TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); +CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks(created_at); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + opencode_session_id TEXT, + phase TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + started_at INTEGER, + completed_at INTEGER, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sessions_task_id ON sessions(task_id); +CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status); + +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + payload TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type); +CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at); diff --git a/crates/db/src/error.rs b/crates/db/src/error.rs new file mode 100644 index 0000000..c564946 --- /dev/null +++ b/crates/db/src/error.rs @@ -0,0 +1,17 @@ +use thiserror::Error; +use uuid::Uuid; + +#[derive(Error, Debug)] +pub enum DbError { + #[error("Database error: {0}")] + Sqlx(#[from] sqlx::Error), + + #[error("Migration error: {0}")] + Migration(#[from] sqlx::migrate::MigrateError), + + #[error("Task not found: {0}")] + TaskNotFound(Uuid), + + #[error("Session not found: {0}")] + SessionNotFound(Uuid), +} diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs new file mode 100644 index 0000000..c1aecf9 --- /dev/null +++ b/crates/db/src/lib.rs @@ -0,0 +1,8 @@ +mod error; +pub mod models; +mod pool; +pub mod repositories; + +pub use error::*; +pub use pool::*; +pub use repositories::*; diff --git a/crates/db/src/models/mod.rs b/crates/db/src/models/mod.rs new file mode 100644 index 0000000..6783b75 --- /dev/null +++ b/crates/db/src/models/mod.rs @@ -0,0 +1,5 @@ +mod session; +mod task; + +pub use session::*; +pub use task::*; diff --git a/crates/db/src/models/session.rs b/crates/db/src/models/session.rs new file mode 100644 index 0000000..1bf3e75 --- /dev/null +++ b/crates/db/src/models/session.rs @@ -0,0 +1,53 @@ +use chrono::{DateTime, TimeZone, Utc}; +use opencode_core::{Session, SessionPhase, SessionStatus}; +use uuid::Uuid; + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct SessionRow { + pub id: String, + pub task_id: String, + pub opencode_session_id: Option, + pub phase: String, + pub status: String, + pub started_at: Option, + pub completed_at: Option, + pub created_at: i64, +} + +impl SessionRow { + pub fn into_domain(self) -> Session { + Session { + id: Uuid::parse_str(&self.id).unwrap_or_default(), + task_id: Uuid::parse_str(&self.task_id).unwrap_or_default(), + opencode_session_id: self.opencode_session_id, + phase: SessionPhase::parse(&self.phase).unwrap_or_default(), + status: SessionStatus::parse(&self.status).unwrap_or_default(), + started_at: self.started_at.map(timestamp_to_datetime), + completed_at: self.completed_at.map(timestamp_to_datetime), + created_at: timestamp_to_datetime(self.created_at), + } + } +} + +impl From<&Session> for SessionRow { + fn from(session: &Session) -> Self { + Self { + id: session.id.to_string(), + task_id: session.task_id.to_string(), + opencode_session_id: session.opencode_session_id.clone(), + phase: session.phase.as_str().to_string(), + status: session.status.as_str().to_string(), + started_at: session.started_at.map(datetime_to_timestamp), + completed_at: session.completed_at.map(datetime_to_timestamp), + created_at: datetime_to_timestamp(session.created_at), + } + } +} + +fn timestamp_to_datetime(ts: i64) -> DateTime { + Utc.timestamp_opt(ts, 0).unwrap() +} + +fn datetime_to_timestamp(dt: DateTime) -> i64 { + dt.timestamp() +} diff --git a/crates/db/src/models/task.rs b/crates/db/src/models/task.rs new file mode 100644 index 0000000..8f5f152 --- /dev/null +++ b/crates/db/src/models/task.rs @@ -0,0 +1,53 @@ +use chrono::{DateTime, TimeZone, Utc}; +use opencode_core::{Task, TaskStatus}; +use uuid::Uuid; + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct TaskRow { + pub id: String, + pub title: String, + pub description: String, + pub status: String, + pub roadmap_item_id: Option, + pub workspace_path: Option, + pub created_at: i64, + pub updated_at: i64, +} + +impl TaskRow { + pub fn into_domain(self) -> Task { + Task { + id: Uuid::parse_str(&self.id).unwrap_or_default(), + title: self.title, + description: self.description, + status: TaskStatus::parse(&self.status).unwrap_or_default(), + roadmap_item_id: self.roadmap_item_id.and_then(|s| Uuid::parse_str(&s).ok()), + workspace_path: self.workspace_path, + created_at: timestamp_to_datetime(self.created_at), + updated_at: timestamp_to_datetime(self.updated_at), + } + } +} + +impl From<&Task> for TaskRow { + fn from(task: &Task) -> Self { + Self { + id: task.id.to_string(), + title: task.title.clone(), + description: task.description.clone(), + status: task.status.as_str().to_string(), + roadmap_item_id: task.roadmap_item_id.map(|id| id.to_string()), + workspace_path: task.workspace_path.clone(), + created_at: datetime_to_timestamp(task.created_at), + updated_at: datetime_to_timestamp(task.updated_at), + } + } +} + +fn timestamp_to_datetime(ts: i64) -> DateTime { + Utc.timestamp_opt(ts, 0).unwrap() +} + +fn datetime_to_timestamp(dt: DateTime) -> i64 { + dt.timestamp() +} diff --git a/crates/db/src/pool.rs b/crates/db/src/pool.rs new file mode 100644 index 0000000..17b4e19 --- /dev/null +++ b/crates/db/src/pool.rs @@ -0,0 +1,37 @@ +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use sqlx::SqlitePool; +use std::str::FromStr; +use std::time::Duration; + +pub async fn create_pool(database_url: &str) -> Result { + let options = SqliteConnectOptions::from_str(database_url)? + .create_if_missing(true) + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) + .busy_timeout(Duration::from_secs(30)) + .pragma("foreign_keys", "ON"); + + let pool = SqlitePoolOptions::new() + .max_connections(10) + .min_connections(1) + .acquire_timeout(Duration::from_secs(5)) + .idle_timeout(Duration::from_secs(600)) + .connect_with(options) + .await?; + + Ok(pool) +} + +pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::migrate::MigrateError> { + sqlx::migrate!("./migrations").run(pool).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_create_pool() { + let pool = create_pool("sqlite::memory:").await; + assert!(pool.is_ok()); + } +} diff --git a/crates/db/src/repositories/mod.rs b/crates/db/src/repositories/mod.rs new file mode 100644 index 0000000..d1d8b76 --- /dev/null +++ b/crates/db/src/repositories/mod.rs @@ -0,0 +1,5 @@ +mod session_repository; +mod task_repository; + +pub use session_repository::*; +pub use task_repository::*; diff --git a/crates/db/src/repositories/session_repository.rs b/crates/db/src/repositories/session_repository.rs new file mode 100644 index 0000000..b9bff8e --- /dev/null +++ b/crates/db/src/repositories/session_repository.rs @@ -0,0 +1,265 @@ +use crate::error::DbError; +use crate::models::SessionRow; +use opencode_core::{Session, SessionStatus}; +use sqlx::SqlitePool; +use uuid::Uuid; + +#[derive(Clone)] +pub struct SessionRepository { + pool: SqlitePool, +} + +impl SessionRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + pub async fn create(&self, session: &Session) -> Result { + let row = SessionRow::from(session); + + sqlx::query( + r#" + INSERT INTO sessions (id, task_id, opencode_session_id, phase, status, started_at, completed_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind(&row.id) + .bind(&row.task_id) + .bind(&row.opencode_session_id) + .bind(&row.phase) + .bind(&row.status) + .bind(row.started_at) + .bind(row.completed_at) + .bind(row.created_at) + .execute(&self.pool) + .await?; + + Ok(session.clone()) + } + + pub async fn find_by_id(&self, id: Uuid) -> Result, DbError> { + let row: Option = sqlx::query_as( + r#" + SELECT id, task_id, opencode_session_id, phase, status, started_at, completed_at, created_at + FROM sessions + WHERE id = ? + "#, + ) + .bind(id.to_string()) + .fetch_optional(&self.pool) + .await?; + + Ok(row.map(|r| r.into_domain())) + } + + pub async fn find_by_task_id(&self, task_id: Uuid) -> Result, DbError> { + let rows: Vec = sqlx::query_as( + r#" + SELECT id, task_id, opencode_session_id, phase, status, started_at, completed_at, created_at + FROM sessions + WHERE task_id = ? + ORDER BY created_at DESC + "#, + ) + .bind(task_id.to_string()) + .fetch_all(&self.pool) + .await?; + + Ok(rows.into_iter().map(|r| r.into_domain()).collect()) + } + + pub async fn find_by_opencode_session_id( + &self, + opencode_session_id: &str, + ) -> Result, DbError> { + let row: Option = sqlx::query_as( + r#" + SELECT id, task_id, opencode_session_id, phase, status, started_at, completed_at, created_at + FROM sessions + WHERE opencode_session_id = ? + "#, + ) + .bind(opencode_session_id) + .fetch_optional(&self.pool) + .await?; + + Ok(row.map(|r| r.into_domain())) + } + + pub async fn find_all(&self) -> Result, DbError> { + let rows: Vec = sqlx::query_as( + r#" + SELECT id, task_id, opencode_session_id, phase, status, started_at, completed_at, created_at + FROM sessions + ORDER BY created_at DESC + "#, + ) + .fetch_all(&self.pool) + .await?; + + Ok(rows.into_iter().map(|r| r.into_domain()).collect()) + } + + pub async fn find_active(&self) -> Result, DbError> { + let rows: Vec = sqlx::query_as( + r#" + SELECT id, task_id, opencode_session_id, phase, status, started_at, completed_at, created_at + FROM sessions + WHERE status IN ('pending', 'running') + ORDER BY created_at DESC + "#, + ) + .fetch_all(&self.pool) + .await?; + + Ok(rows.into_iter().map(|r| r.into_domain()).collect()) + } + + pub async fn update(&self, session: &Session) -> Result { + let row = SessionRow::from(session); + + sqlx::query( + r#" + UPDATE sessions + SET opencode_session_id = ?, phase = ?, status = ?, started_at = ?, completed_at = ? + WHERE id = ? + "#, + ) + .bind(&row.opencode_session_id) + .bind(&row.phase) + .bind(&row.status) + .bind(row.started_at) + .bind(row.completed_at) + .bind(&row.id) + .execute(&self.pool) + .await?; + + Ok(session.clone()) + } + + pub async fn update_status(&self, id: Uuid, status: SessionStatus) -> Result { + let result = sqlx::query("UPDATE sessions SET status = ? WHERE id = ?") + .bind(status.as_str()) + .bind(id.to_string()) + .execute(&self.pool) + .await?; + + Ok(result.rows_affected() > 0) + } + + pub async fn delete(&self, id: Uuid) -> Result { + let result = sqlx::query("DELETE FROM sessions WHERE id = ?") + .bind(id.to_string()) + .execute(&self.pool) + .await?; + + Ok(result.rows_affected() > 0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{create_pool, run_migrations, TaskRepository}; + use opencode_core::{SessionPhase, Task}; + + async fn setup_test_db() -> SqlitePool { + let pool = create_pool("sqlite::memory:").await.unwrap(); + run_migrations(&pool).await.unwrap(); + pool + } + + async fn create_test_task(pool: &SqlitePool) -> Task { + let task_repo = TaskRepository::new(pool.clone()); + let task = Task::new("Test Task", "Test Description"); + task_repo.create(&task).await.unwrap(); + task + } + + #[tokio::test] + async fn test_create_and_find_session() { + let pool = setup_test_db().await; + let task = create_test_task(&pool).await; + let repo = SessionRepository::new(pool); + + let session = Session::new(task.id, SessionPhase::Planning); + let created = repo.create(&session).await.unwrap(); + + assert_eq!(created.task_id, task.id); + assert_eq!(created.phase, SessionPhase::Planning); + + let found = repo.find_by_id(session.id).await.unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap().phase, SessionPhase::Planning); + } + + #[tokio::test] + async fn test_find_by_task_id() { + let pool = setup_test_db().await; + let task = create_test_task(&pool).await; + let repo = SessionRepository::new(pool); + + repo.create(&Session::new(task.id, SessionPhase::Planning)) + .await + .unwrap(); + repo.create(&Session::new(task.id, SessionPhase::Implementation)) + .await + .unwrap(); + + let sessions = repo.find_by_task_id(task.id).await.unwrap(); + assert_eq!(sessions.len(), 2); + } + + #[tokio::test] + async fn test_update_session() { + let pool = setup_test_db().await; + let task = create_test_task(&pool).await; + let repo = SessionRepository::new(pool); + + let mut session = Session::new(task.id, SessionPhase::Planning); + repo.create(&session).await.unwrap(); + + session.start("opencode-123".to_string()); + repo.update(&session).await.unwrap(); + + let found = repo.find_by_id(session.id).await.unwrap().unwrap(); + assert_eq!(found.status, SessionStatus::Running); + assert_eq!(found.opencode_session_id, Some("opencode-123".to_string())); + } + + #[tokio::test] + async fn test_find_active_sessions() { + let pool = setup_test_db().await; + let task = create_test_task(&pool).await; + let repo = SessionRepository::new(pool); + + let mut running = Session::new(task.id, SessionPhase::Planning); + running.start("opencode-1".to_string()); + repo.create(&running).await.unwrap(); + + let mut completed = Session::new(task.id, SessionPhase::Implementation); + completed.start("opencode-2".to_string()); + completed.complete(); + repo.create(&completed).await.unwrap(); + + let active = repo.find_active().await.unwrap(); + assert_eq!(active.len(), 1); + assert_eq!(active[0].status, SessionStatus::Running); + } + + #[tokio::test] + async fn test_delete_session() { + let pool = setup_test_db().await; + let task = create_test_task(&pool).await; + let repo = SessionRepository::new(pool); + + let session = Session::new(task.id, SessionPhase::Planning); + repo.create(&session).await.unwrap(); + + let deleted = repo.delete(session.id).await.unwrap(); + assert!(deleted); + + let found = repo.find_by_id(session.id).await.unwrap(); + assert!(found.is_none()); + } +} diff --git a/crates/db/src/repositories/task_repository.rs b/crates/db/src/repositories/task_repository.rs new file mode 100644 index 0000000..dff54fa --- /dev/null +++ b/crates/db/src/repositories/task_repository.rs @@ -0,0 +1,199 @@ +use crate::error::DbError; +use crate::models::TaskRow; +use chrono::Utc; +use opencode_core::{Task, UpdateTaskRequest}; +use sqlx::SqlitePool; +use uuid::Uuid; + +#[derive(Clone)] +pub struct TaskRepository { + pool: SqlitePool, +} + +impl TaskRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + pub async fn create(&self, task: &Task) -> Result { + let row = TaskRow::from(task); + + sqlx::query( + r#" + INSERT INTO tasks (id, title, description, status, roadmap_item_id, workspace_path, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind(&row.id) + .bind(&row.title) + .bind(&row.description) + .bind(&row.status) + .bind(&row.roadmap_item_id) + .bind(&row.workspace_path) + .bind(row.created_at) + .bind(row.updated_at) + .execute(&self.pool) + .await?; + + Ok(task.clone()) + } + + pub async fn find_by_id(&self, id: Uuid) -> Result, DbError> { + let row: Option = sqlx::query_as( + r#" + SELECT id, title, description, status, roadmap_item_id, workspace_path, created_at, updated_at + FROM tasks + WHERE id = ? + "#, + ) + .bind(id.to_string()) + .fetch_optional(&self.pool) + .await?; + + Ok(row.map(|r| r.into_domain())) + } + + pub async fn find_all(&self) -> Result, DbError> { + let rows: Vec = sqlx::query_as( + r#" + SELECT id, title, description, status, roadmap_item_id, workspace_path, created_at, updated_at + FROM tasks + ORDER BY created_at DESC + "#, + ) + .fetch_all(&self.pool) + .await?; + + Ok(rows.into_iter().map(|r| r.into_domain()).collect()) + } + + pub async fn update( + &self, + id: Uuid, + update: &UpdateTaskRequest, + ) -> Result, DbError> { + let existing = self.find_by_id(id).await?; + let Some(mut task) = existing else { + return Ok(None); + }; + + if let Some(title) = &update.title { + task.title = title.clone(); + } + if let Some(description) = &update.description { + task.description = description.clone(); + } + if let Some(status) = &update.status { + task.status = *status; + } + if let Some(workspace_path) = &update.workspace_path { + task.workspace_path = Some(workspace_path.clone()); + } + + task.updated_at = Utc::now(); + let row = TaskRow::from(&task); + + sqlx::query( + r#" + UPDATE tasks + SET title = ?, description = ?, status = ?, workspace_path = ?, updated_at = ? + WHERE id = ? + "#, + ) + .bind(&row.title) + .bind(&row.description) + .bind(&row.status) + .bind(&row.workspace_path) + .bind(row.updated_at) + .bind(&row.id) + .execute(&self.pool) + .await?; + + Ok(Some(task)) + } + + pub async fn delete(&self, id: Uuid) -> Result { + let result = sqlx::query("DELETE FROM tasks WHERE id = ?") + .bind(id.to_string()) + .execute(&self.pool) + .await?; + + Ok(result.rows_affected() > 0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{create_pool, run_migrations}; + use opencode_core::TaskStatus; + + async fn setup_test_db() -> SqlitePool { + let pool = create_pool("sqlite::memory:").await.unwrap(); + run_migrations(&pool).await.unwrap(); + pool + } + + #[tokio::test] + async fn test_create_and_find_task() { + let pool = setup_test_db().await; + let repo = TaskRepository::new(pool); + + let task = Task::new("Test Task", "Test Description"); + let created = repo.create(&task).await.unwrap(); + + assert_eq!(created.title, "Test Task"); + + let found = repo.find_by_id(task.id).await.unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap().title, "Test Task"); + } + + #[tokio::test] + async fn test_find_all_tasks() { + let pool = setup_test_db().await; + let repo = TaskRepository::new(pool); + + repo.create(&Task::new("Task 1", "Desc 1")).await.unwrap(); + repo.create(&Task::new("Task 2", "Desc 2")).await.unwrap(); + + let all = repo.find_all().await.unwrap(); + assert_eq!(all.len(), 2); + } + + #[tokio::test] + async fn test_update_task() { + let pool = setup_test_db().await; + let repo = TaskRepository::new(pool); + + let task = Task::new("Original", "Description"); + repo.create(&task).await.unwrap(); + + let update = UpdateTaskRequest { + title: Some("Updated".to_string()), + status: Some(TaskStatus::InProgress), + ..Default::default() + }; + + let updated = repo.update(task.id, &update).await.unwrap(); + assert!(updated.is_some()); + let updated = updated.unwrap(); + assert_eq!(updated.title, "Updated"); + assert_eq!(updated.status, TaskStatus::InProgress); + } + + #[tokio::test] + async fn test_delete_task() { + let pool = setup_test_db().await; + let repo = TaskRepository::new(pool); + + let task = Task::new("To Delete", "Description"); + repo.create(&task).await.unwrap(); + + let deleted = repo.delete(task.id).await.unwrap(); + assert!(deleted); + + let found = repo.find_by_id(task.id).await.unwrap(); + assert!(found.is_none()); + } +} diff --git a/crates/events/Cargo.toml b/crates/events/Cargo.toml new file mode 100644 index 0000000..c5c349e --- /dev/null +++ b/crates/events/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "events" +version.workspace = true +edition.workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +tokio = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["full", "test-util"] } diff --git a/crates/events/src/bus.rs b/crates/events/src/bus.rs new file mode 100644 index 0000000..55867d4 --- /dev/null +++ b/crates/events/src/bus.rs @@ -0,0 +1,180 @@ +//! Event bus implementation using tokio broadcast channels + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use tokio::sync::broadcast; + +use crate::types::EventEnvelope; + +/// Capacity for the broadcast channel +const DEFAULT_CAPACITY: usize = 1000; + +/// Event bus for publishing and subscribing to events +#[derive(Clone)] +pub struct EventBus { + sender: broadcast::Sender, + /// Number of events published (for monitoring) + event_count: Arc, +} + +impl EventBus { + /// Create a new event bus with default capacity + pub fn new() -> Self { + Self::with_capacity(DEFAULT_CAPACITY) + } + + /// Create a new event bus with specified capacity + pub fn with_capacity(capacity: usize) -> Self { + let (sender, _) = broadcast::channel(capacity); + Self { + sender, + event_count: Arc::new(AtomicUsize::new(0)), + } + } + + /// Publish an event to all subscribers + /// + /// Returns the number of subscribers that received the event. + /// If there are no subscribers, returns 0 (the event is dropped). + pub fn publish(&self, envelope: EventEnvelope) -> usize { + self.event_count.fetch_add(1, Ordering::Relaxed); + self.sender.send(envelope).unwrap_or(0) + } + + /// Subscribe to events + /// + /// Returns a receiver that will receive all published events. + /// Note: Events published before subscribing will not be received. + pub fn subscribe(&self) -> broadcast::Receiver { + self.sender.subscribe() + } + + /// Get the number of current subscribers + pub fn subscriber_count(&self) -> usize { + self.sender.receiver_count() + } + + /// Get the total number of events published + pub fn event_count(&self) -> usize { + self.event_count.load(Ordering::Relaxed) + } +} + +impl Default for EventBus { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Debug for EventBus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EventBus") + .field("subscriber_count", &self.subscriber_count()) + .field("event_count", &self.event_count()) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::Event; + use uuid::Uuid; + + #[tokio::test] + async fn test_publish_subscribe() { + let bus = EventBus::new(); + let mut rx = bus.subscribe(); + + let event = Event::TaskCreated { + task_id: Uuid::new_v4(), + title: "Test".to_string(), + }; + let envelope = EventEnvelope::new(event); + + let sent = bus.publish(envelope.clone()); + assert_eq!(sent, 1); + + let received = rx.recv().await.unwrap(); + assert_eq!(received.id, envelope.id); + } + + #[tokio::test] + async fn test_multiple_subscribers() { + let bus = EventBus::new(); + let mut rx1 = bus.subscribe(); + let mut rx2 = bus.subscribe(); + + let event = Event::TaskCreated { + task_id: Uuid::new_v4(), + title: "Test".to_string(), + }; + let envelope = EventEnvelope::new(event); + let envelope_id = envelope.id; + + let sent = bus.publish(envelope); + assert_eq!(sent, 2); + + let received1 = rx1.recv().await.unwrap(); + let received2 = rx2.recv().await.unwrap(); + + assert_eq!(received1.id, envelope_id); + assert_eq!(received2.id, envelope_id); + } + + #[tokio::test] + async fn test_no_subscribers() { + let bus = EventBus::new(); + + let event = Event::TaskCreated { + task_id: Uuid::new_v4(), + title: "Test".to_string(), + }; + let envelope = EventEnvelope::new(event); + + // No subscribers, event is dropped + let sent = bus.publish(envelope); + assert_eq!(sent, 0); + } + + #[tokio::test] + async fn test_subscriber_count() { + let bus = EventBus::new(); + assert_eq!(bus.subscriber_count(), 0); + + let _rx1 = bus.subscribe(); + assert_eq!(bus.subscriber_count(), 1); + + let _rx2 = bus.subscribe(); + assert_eq!(bus.subscriber_count(), 2); + + drop(_rx1); + // Note: broadcast channel may not immediately reflect dropped receivers + } + + #[tokio::test] + async fn test_event_count() { + let bus = EventBus::new(); + assert_eq!(bus.event_count(), 0); + + let event = Event::Error { + message: "test".to_string(), + context: None, + }; + bus.publish(EventEnvelope::new(event.clone())); + assert_eq!(bus.event_count(), 1); + + bus.publish(EventEnvelope::new(event)); + assert_eq!(bus.event_count(), 2); + } + + #[test] + fn test_clone() { + let bus1 = EventBus::new(); + let bus2 = bus1.clone(); + + let _rx = bus2.subscribe(); + assert_eq!(bus1.subscriber_count(), 1); + assert_eq!(bus2.subscriber_count(), 1); + } +} diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs new file mode 100644 index 0000000..3d7eccb --- /dev/null +++ b/crates/events/src/lib.rs @@ -0,0 +1,10 @@ +//! Event system for OpenCode Studio +//! +//! This crate provides the event bus and event types for real-time +//! communication between components. + +mod bus; +mod types; + +pub use bus::EventBus; +pub use types::*; diff --git a/crates/events/src/types.rs b/crates/events/src/types.rs new file mode 100644 index 0000000..5c81a00 --- /dev/null +++ b/crates/events/src/types.rs @@ -0,0 +1,231 @@ +//! Event types for the OpenCode Studio event system + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Envelope wrapping all events with metadata +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventEnvelope { + /// Unique event ID + pub id: Uuid, + /// When the event occurred + pub timestamp: DateTime, + /// The actual event + pub event: Event, +} + +impl EventEnvelope { + /// Create a new event envelope with auto-generated ID and timestamp + pub fn new(event: Event) -> Self { + Self { + id: Uuid::new_v4(), + timestamp: Utc::now(), + event, + } + } +} + +/// All possible events in the system +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Event { + // Task events + /// A new task was created + #[serde(rename = "task.created")] + TaskCreated { task_id: Uuid, title: String }, + + /// Task was updated (title, description, etc.) + #[serde(rename = "task.updated")] + TaskUpdated { task_id: Uuid }, + + /// Task status changed + #[serde(rename = "task.status_changed")] + TaskStatusChanged { + task_id: Uuid, + from_status: String, + to_status: String, + }, + + // Session events + /// OpenCode session started + #[serde(rename = "session.started")] + SessionStarted { session_id: Uuid, task_id: Uuid }, + + /// OpenCode session ended + #[serde(rename = "session.ended")] + SessionEnded { + session_id: Uuid, + task_id: Uuid, + success: bool, + }, + + /// Message from OpenCode agent + #[serde(rename = "agent.message")] + AgentMessage { + session_id: Uuid, + task_id: Uuid, + message: AgentMessageData, + }, + + /// Tool execution by agent + #[serde(rename = "tool.execution")] + ToolExecution { + session_id: Uuid, + task_id: Uuid, + tool: ToolExecutionData, + }, + + // Workspace events + /// Workspace created for a task + #[serde(rename = "workspace.created")] + WorkspaceCreated { task_id: Uuid, path: String }, + + /// Workspace was merged + #[serde(rename = "workspace.merged")] + WorkspaceMerged { task_id: Uuid, success: bool }, + + /// Workspace was deleted + #[serde(rename = "workspace.deleted")] + WorkspaceDeleted { task_id: Uuid }, + + // System events + /// Generic error event + #[serde(rename = "error")] + Error { + message: String, + context: Option, + }, +} + +/// Data for agent message events +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentMessageData { + /// The message content + pub content: String, + /// Message role (assistant, user, system) + pub role: String, + /// Whether this is a partial/streaming message + pub is_partial: bool, +} + +/// Data for tool execution events +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolExecutionData { + /// Tool name + pub name: String, + /// Tool input (JSON string or summary) + pub input: Option, + /// Tool output (truncated if large) + pub output: Option, + /// Whether the tool succeeded + pub success: bool, +} + +impl Event { + /// Get the task ID associated with this event, if any + pub fn task_id(&self) -> Option { + match self { + Event::TaskCreated { task_id, .. } => Some(*task_id), + Event::TaskUpdated { task_id } => Some(*task_id), + Event::TaskStatusChanged { task_id, .. } => Some(*task_id), + Event::SessionStarted { task_id, .. } => Some(*task_id), + Event::SessionEnded { task_id, .. } => Some(*task_id), + Event::AgentMessage { task_id, .. } => Some(*task_id), + Event::ToolExecution { task_id, .. } => Some(*task_id), + Event::WorkspaceCreated { task_id, .. } => Some(*task_id), + Event::WorkspaceMerged { task_id, .. } => Some(*task_id), + Event::WorkspaceDeleted { task_id } => Some(*task_id), + Event::Error { .. } => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_event_envelope_creation() { + let event = Event::TaskCreated { + task_id: Uuid::new_v4(), + title: "Test task".to_string(), + }; + let envelope = EventEnvelope::new(event); + + assert!(!envelope.id.is_nil()); + assert!(envelope.timestamp <= Utc::now()); + } + + #[test] + fn test_event_serialization() { + let event = Event::TaskStatusChanged { + task_id: Uuid::new_v4(), + from_status: "Todo".to_string(), + to_status: "Planning".to_string(), + }; + + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains("task.status_changed")); + assert!(json.contains("from_status")); + assert!(json.contains("to_status")); + } + + #[test] + fn test_event_deserialization() { + let json = r#"{"type":"task.created","task_id":"550e8400-e29b-41d4-a716-446655440000","title":"Test"}"#; + let event: Event = serde_json::from_str(json).unwrap(); + + match event { + Event::TaskCreated { task_id, title } => { + assert_eq!(title, "Test"); + assert!(!task_id.is_nil()); + } + _ => panic!("Wrong event type"), + } + } + + #[test] + fn test_event_task_id() { + let task_id = Uuid::new_v4(); + + let event = Event::TaskCreated { + task_id, + title: "Test".to_string(), + }; + assert_eq!(event.task_id(), Some(task_id)); + + let error_event = Event::Error { + message: "test".to_string(), + context: None, + }; + assert_eq!(error_event.task_id(), None); + } + + #[test] + fn test_agent_message_data() { + let data = AgentMessageData { + content: "Hello".to_string(), + role: "assistant".to_string(), + is_partial: false, + }; + + let json = serde_json::to_string(&data).unwrap(); + assert!(json.contains("Hello")); + assert!(json.contains("assistant")); + } + + #[test] + fn test_tool_execution_data() { + let data = ToolExecutionData { + name: "read_file".to_string(), + input: Some("/path/to/file".to_string()), + output: Some("file contents".to_string()), + success: true, + }; + + let json = serde_json::to_string(&data).unwrap(); + assert!(json.contains("read_file")); + assert!(json.contains("success")); + } +} diff --git a/crates/opencode/Cargo.toml b/crates/opencode/Cargo.toml new file mode 100644 index 0000000..105f5f6 --- /dev/null +++ b/crates/opencode/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "opencode" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] +opencode_core.workspace = true +tokio.workspace = true +reqwest.workspace = true +futures.workspace = true +eventsource-stream.workspace = true +bytes.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tracing.workspace = true +uuid.workspace = true +chrono.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/opencode/src/client.rs b/crates/opencode/src/client.rs new file mode 100644 index 0000000..79cd251 --- /dev/null +++ b/crates/opencode/src/client.rs @@ -0,0 +1,186 @@ +use reqwest::Client; + +use crate::error::{OpenCodeError, Result}; +use crate::types::{ + CreateSessionRequest, Message, MessageResponse, SendMessageRequest, Session, + SessionListResponse, SessionMessagesResponse, +}; + +pub struct OpenCodeClient { + base_url: String, + client: Client, +} + +impl OpenCodeClient { + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into(), + client: Client::new(), + } + } + + pub fn with_client(base_url: impl Into, client: Client) -> Self { + Self { + base_url: base_url.into(), + client, + } + } + + pub async fn create_session(&self, title: Option) -> Result { + let request = CreateSessionRequest { + title, + parent_id: None, + }; + + let response = self + .client + .post(format!("{}/session", self.base_url)) + .json(&request) + .send() + .await?; + + self.handle_response(response).await + } + + pub async fn get_session(&self, session_id: &str) -> Result { + let response = self + .client + .get(format!("{}/session/{}", self.base_url, session_id)) + .send() + .await?; + + self.handle_response(response).await + } + + pub async fn list_sessions(&self) -> Result> { + let response = self + .client + .get(format!("{}/sessions", self.base_url)) + .send() + .await?; + + let list: SessionListResponse = self.handle_response(response).await?; + Ok(list.sessions) + } + + pub async fn send_message( + &self, + session_id: &str, + prompt: &str, + model: Option<&str>, + ) -> Result { + let mut request = SendMessageRequest::new(prompt); + if let Some(m) = model { + request = request.with_model(m); + } + + let response = self + .client + .post(format!("{}/session/{}/message", self.base_url, session_id)) + .json(&request) + .send() + .await?; + + self.handle_response(response).await + } + + pub async fn send_message_async(&self, session_id: &str, prompt: &str) -> Result<()> { + let request = SendMessageRequest::new(prompt); + + let response = self + .client + .post(format!( + "{}/session/{}/prompt_async", + self.base_url, session_id + )) + .json(&request) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(OpenCodeError::InvalidResponse(format!( + "Status {}: {}", + status, body + ))); + } + + Ok(()) + } + + pub async fn abort_session(&self, session_id: &str) -> Result<()> { + let response = self + .client + .post(format!("{}/session/{}/abort", self.base_url, session_id)) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(OpenCodeError::InvalidResponse(format!( + "Status {}: {}", + status, body + ))); + } + + Ok(()) + } + + pub async fn get_messages(&self, session_id: &str) -> Result> { + let response = self + .client + .get(format!("{}/session/{}/messages", self.base_url, session_id)) + .send() + .await?; + + let messages: SessionMessagesResponse = self.handle_response(response).await?; + Ok(messages.messages) + } + + async fn handle_response( + &self, + response: reqwest::Response, + ) -> Result { + let status = response.status(); + + if status == reqwest::StatusCode::NOT_FOUND { + return Err(OpenCodeError::SessionNotFound( + "Resource not found".to_string(), + )); + } + + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(OpenCodeError::InvalidResponse(format!( + "Status {}: {}", + status, body + ))); + } + + let body = response.json().await?; + Ok(body) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_creation() { + let client = OpenCodeClient::new("http://localhost:4096"); + assert_eq!(client.base_url, "http://localhost:4096"); + } + + #[test] + fn test_send_message_request() { + let request = SendMessageRequest::new("Hello world"); + assert_eq!(request.parts.len(), 1); + assert!(request.model.is_none()); + + let request_with_model = SendMessageRequest::new("Hello").with_model("gpt-4"); + assert_eq!(request_with_model.model, Some("gpt-4".to_string())); + } +} diff --git a/crates/opencode/src/error.rs b/crates/opencode/src/error.rs new file mode 100644 index 0000000..e07c1c5 --- /dev/null +++ b/crates/opencode/src/error.rs @@ -0,0 +1,24 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum OpenCodeError { + #[error("HTTP request failed: {0}")] + Request(#[from] reqwest::Error), + + #[error("JSON serialization failed: {0}")] + Serialization(#[from] serde_json::Error), + + #[error("Session not found: {0}")] + SessionNotFound(String), + + #[error("Invalid response: {0}")] + InvalidResponse(String), + + #[error("Connection failed: {0}")] + Connection(String), + + #[error("Event stream error: {0}")] + EventStream(String), +} + +pub type Result = std::result::Result; diff --git a/crates/opencode/src/events.rs b/crates/opencode/src/events.rs new file mode 100644 index 0000000..8f21681 --- /dev/null +++ b/crates/opencode/src/events.rs @@ -0,0 +1,108 @@ +use eventsource_stream::Eventsource; +use futures::StreamExt; +use serde::Deserialize; +use tokio::sync::mpsc; + +use crate::error::{OpenCodeError, Result}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum OpenCodeEvent { + #[serde(rename = "session.message")] + SessionMessage { session_id: String, content: String }, + + #[serde(rename = "session.completed")] + SessionCompleted { session_id: String }, + + #[serde(rename = "session.error")] + SessionError { session_id: String, error: String }, + + #[serde(rename = "task.status_changed")] + TaskStatusChanged { task_id: String, status: String }, + + #[serde(other)] + Unknown, +} + +pub struct EventStream { + base_url: String, + client: reqwest::Client, +} + +impl EventStream { + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into(), + client: reqwest::Client::new(), + } + } + + pub async fn connect(&self) -> Result { + let url = format!("{}/event", self.base_url); + + let response = self + .client + .get(&url) + .header("Accept", "text/event-stream") + .send() + .await + .map_err(OpenCodeError::Request)?; + + if !response.status().is_success() { + return Err(OpenCodeError::Connection(format!( + "Failed to connect to event stream: {}", + response.status() + ))); + } + + let (tx, rx) = mpsc::channel::>(100); + + let byte_stream = response.bytes_stream(); + + tokio::spawn(async move { + let mut event_stream = byte_stream.eventsource(); + + while let Some(event_result) = event_stream.next().await { + match event_result { + Ok(event) => { + if event.data.is_empty() { + continue; + } + + match serde_json::from_str::(&event.data) { + Ok(parsed) => { + if tx.send(Ok(parsed)).await.is_err() { + break; + } + } + Err(e) => { + tracing::warn!( + "Failed to parse event: {} - data: {}", + e, + event.data + ); + } + } + } + Err(e) => { + let err_msg = format!("{}", e); + let _ = tx.send(Err(OpenCodeError::EventStream(err_msg))).await; + break; + } + } + } + }); + + Ok(EventReceiver { rx }) + } +} + +pub struct EventReceiver { + rx: mpsc::Receiver>, +} + +impl EventReceiver { + pub async fn next_event(&mut self) -> Option> { + self.rx.recv().await + } +} diff --git a/crates/opencode/src/lib.rs b/crates/opencode/src/lib.rs new file mode 100644 index 0000000..01a11b1 --- /dev/null +++ b/crates/opencode/src/lib.rs @@ -0,0 +1,9 @@ +pub mod client; +pub mod error; +pub mod events; +pub mod types; + +pub use client::OpenCodeClient; +pub use error::{OpenCodeError, Result}; +pub use events::{EventReceiver, EventStream, OpenCodeEvent}; +pub use types::*; diff --git a/crates/opencode/src/types.rs b/crates/opencode/src/types.rs new file mode 100644 index 0000000..4a51b79 --- /dev/null +++ b/crates/opencode/src/types.rs @@ -0,0 +1,75 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub id: String, + pub title: Option, + pub parent_id: Option, + pub created_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateSessionRequest { + pub title: Option, + pub parent_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Part { + Text { text: String }, +} + +impl Part { + pub fn text(content: impl Into) -> Self { + Self::Text { + text: content.into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SendMessageRequest { + pub parts: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +impl SendMessageRequest { + pub fn new(prompt: impl Into) -> Self { + Self { + parts: vec![Part::text(prompt)], + model: None, + } + } + + pub fn with_model(mut self, model: impl Into) -> Self { + self.model = Some(model.into()); + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Message { + pub id: String, + pub role: String, + pub content: String, + pub created_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageResponse { + pub session_id: String, + pub message: Message, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionListResponse { + pub sessions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionMessagesResponse { + pub messages: Vec, +} diff --git a/crates/orchestrator/Cargo.toml b/crates/orchestrator/Cargo.toml new file mode 100644 index 0000000..b54f5dd --- /dev/null +++ b/crates/orchestrator/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "orchestrator" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] +opencode_core.workspace = true +opencode.workspace = true +db.workspace = true +tokio.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tracing.workspace = true +uuid.workspace = true +chrono.workspace = true +async-trait.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/orchestrator/src/error.rs b/crates/orchestrator/src/error.rs new file mode 100644 index 0000000..a77d901 --- /dev/null +++ b/crates/orchestrator/src/error.rs @@ -0,0 +1,24 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum OrchestratorError { + #[error("Invalid state transition from {from} to {to}")] + InvalidTransition { from: String, to: String }, + + #[error("Task not found: {0}")] + TaskNotFound(String), + + #[error("OpenCode error: {0}")] + OpenCode(#[from] opencode::OpenCodeError), + + #[error("Database error: {0}")] + Database(#[from] db::DbError), + + #[error("Execution failed: {0}")] + ExecutionFailed(String), + + #[error("Session already exists for task: {0}")] + SessionExists(String), +} + +pub type Result = std::result::Result; diff --git a/crates/orchestrator/src/executor.rs b/crates/orchestrator/src/executor.rs new file mode 100644 index 0000000..93c7279 --- /dev/null +++ b/crates/orchestrator/src/executor.rs @@ -0,0 +1,200 @@ +use opencode::OpenCodeClient; +use opencode_core::{Task, TaskStatus}; +use std::sync::Arc; + +use crate::error::Result; +use crate::prompts::PhasePrompts; +use crate::state_machine::TaskStateMachine; + +pub struct ExecutorConfig { + pub require_plan_approval: bool, + pub require_human_review: bool, + pub max_review_iterations: u32, +} + +impl Default for ExecutorConfig { + fn default() -> Self { + Self { + require_plan_approval: true, + require_human_review: true, + max_review_iterations: 3, + } + } +} + +pub struct TaskExecutor { + opencode: Arc, + config: ExecutorConfig, +} + +impl TaskExecutor { + pub fn new(opencode: Arc) -> Self { + Self { + opencode, + config: ExecutorConfig::default(), + } + } + + pub fn with_config(opencode: Arc, config: ExecutorConfig) -> Self { + Self { opencode, config } + } + + pub fn transition(&self, task: &mut Task, to: TaskStatus) -> Result<()> { + TaskStateMachine::validate_transition(&task.status, &to)?; + task.status = to; + task.updated_at = chrono::Utc::now(); + Ok(()) + } + + pub async fn execute_phase(&self, task: &mut Task) -> Result { + match task.status { + TaskStatus::Todo => { + self.transition(task, TaskStatus::Planning)?; + self.run_planning_session(task).await + } + TaskStatus::Planning => self.run_planning_session(task).await, + TaskStatus::PlanningReview => { + if self.config.require_plan_approval { + Ok(PhaseResult::AwaitingApproval) + } else { + self.transition(task, TaskStatus::InProgress)?; + self.run_implementation_session(task).await + } + } + TaskStatus::InProgress => self.run_implementation_session(task).await, + TaskStatus::AiReview => self.run_ai_review(task).await, + TaskStatus::Review => { + if self.config.require_human_review { + Ok(PhaseResult::AwaitingApproval) + } else { + self.transition(task, TaskStatus::Done)?; + Ok(PhaseResult::Completed) + } + } + TaskStatus::Done => Ok(PhaseResult::Completed), + } + } + + async fn run_planning_session(&self, task: &mut Task) -> Result { + let session = self + .opencode + .create_session(Some(format!("Planning: {}", task.title))) + .await?; + + let prompt = PhasePrompts::planning(task); + self.opencode + .send_message(&session.id, &prompt, None) + .await?; + + self.transition(task, TaskStatus::PlanningReview)?; + + Ok(PhaseResult::SessionCreated { + session_id: session.id, + }) + } + + async fn run_implementation_session(&self, task: &mut Task) -> Result { + let session = self + .opencode + .create_session(Some(format!("Implementation: {}", task.title))) + .await?; + + let prompt = PhasePrompts::implementation(task); + self.opencode + .send_message(&session.id, &prompt, None) + .await?; + + self.transition(task, TaskStatus::AiReview)?; + + Ok(PhaseResult::SessionCreated { + session_id: session.id, + }) + } + + async fn run_ai_review(&self, task: &mut Task) -> Result { + let session = self + .opencode + .create_session(Some(format!("AI Review: {}", task.title))) + .await?; + + let diff = self.get_workspace_diff(task).await?; + let prompt = PhasePrompts::review(task, &diff); + + let response = self + .opencode + .send_message(&session.id, &prompt, None) + .await?; + + let review_result = self.parse_review_response(&response.message.content); + + match review_result { + ReviewResult::Approved => { + self.transition(task, TaskStatus::Review)?; + Ok(PhaseResult::ReviewPassed) + } + ReviewResult::ChangesRequested(feedback) => { + self.transition(task, TaskStatus::InProgress)?; + Ok(PhaseResult::ReviewFailed { feedback }) + } + } + } + + async fn get_workspace_diff(&self, task: &Task) -> Result { + if let Some(ref _workspace_path) = task.workspace_path { + Ok("(diff would be fetched from VCS)".to_string()) + } else { + Ok("(no workspace configured)".to_string()) + } + } + + fn parse_review_response(&self, content: &str) -> ReviewResult { + if content.contains("APPROVED") { + ReviewResult::Approved + } else if content.contains("CHANGES_REQUESTED") { + let feedback = content + .lines() + .skip_while(|line| !line.contains("CHANGES_REQUESTED")) + .skip(1) + .collect::>() + .join("\n"); + ReviewResult::ChangesRequested(feedback) + } else { + ReviewResult::ChangesRequested( + "Review response unclear. Please review manually.".to_string(), + ) + } + } + + pub async fn run_fix_iteration(&self, task: &mut Task, feedback: &str) -> Result { + let session = self + .opencode + .create_session(Some(format!("Fix: {}", task.title))) + .await?; + + let prompt = PhasePrompts::fix_issues(task, feedback); + self.opencode + .send_message(&session.id, &prompt, None) + .await?; + + self.transition(task, TaskStatus::AiReview)?; + + Ok(PhaseResult::SessionCreated { + session_id: session.id, + }) + } +} + +#[derive(Debug, Clone)] +pub enum PhaseResult { + SessionCreated { session_id: String }, + AwaitingApproval, + ReviewPassed, + ReviewFailed { feedback: String }, + Completed, +} + +#[derive(Debug, Clone)] +pub enum ReviewResult { + Approved, + ChangesRequested(String), +} diff --git a/crates/orchestrator/src/lib.rs b/crates/orchestrator/src/lib.rs new file mode 100644 index 0000000..b6f4b7d --- /dev/null +++ b/crates/orchestrator/src/lib.rs @@ -0,0 +1,8 @@ +pub mod error; +pub mod executor; +pub mod prompts; +pub mod state_machine; + +pub use error::{OrchestratorError, Result}; +pub use executor::{ExecutorConfig, PhaseResult, TaskExecutor}; +pub use state_machine::TaskStateMachine; diff --git a/crates/orchestrator/src/prompts.rs b/crates/orchestrator/src/prompts.rs new file mode 100644 index 0000000..3c0b957 --- /dev/null +++ b/crates/orchestrator/src/prompts.rs @@ -0,0 +1,146 @@ +use opencode_core::Task; + +pub struct PhasePrompts; + +impl PhasePrompts { + pub fn planning(task: &Task) -> String { + format!( + r#"You are analyzing a development task. Create a detailed implementation plan. + +## Task +**Title:** {title} +**Description:** {description} + +## Required Output +Save your analysis to: `.opencode-studio/kanban/plans/{id}.md` + +The plan should include: +1. Technical analysis +2. Files to modify/create +3. Step-by-step implementation steps +4. Potential risks +5. Estimated complexity (S/M/L/XL) + +Do NOT implement anything yet. Only create the plan."#, + title = task.title, + description = task.description, + id = task.id + ) + } + + pub fn implementation(task: &Task) -> String { + let plan_path = format!(".opencode-studio/kanban/plans/{}.md", task.id); + + format!( + r#"Implement the following task according to the plan. + +## Task +**Title:** {title} +**Plan:** Read from `{plan_path}` + +## Instructions +1. Read the plan carefully +2. Implement each step +3. Write tests if applicable +4. Commit your changes + +Start implementation now."#, + title = task.title, + plan_path = plan_path + ) + } + + pub fn review(task: &Task, diff: &str) -> String { + format!( + r#"Review the following code changes for task: {title} + +## Diff +``` +{diff} +``` + +## Review Criteria +1. Code quality and style +2. Correctness - does it solve the task? +3. Tests - are they adequate? +4. Security concerns +5. Breaking changes + +## Output +Save your review to: `.opencode-studio/kanban/reviews/{id}.md` + +If approved, respond with: APPROVED +If changes needed, respond with: CHANGES_REQUESTED and explain what needs fixing."#, + title = task.title, + diff = diff, + id = task.id + ) + } + + pub fn fix_issues(task: &Task, feedback: &str) -> String { + format!( + r#"Fix the issues identified in the code review for task: {title} + +## Review Feedback +{feedback} + +## Instructions +1. Address each issue mentioned +2. Update tests if needed +3. Commit your changes + +Fix the issues now."#, + title = task.title, + feedback = feedback + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + fn sample_task() -> Task { + Task { + id: Uuid::new_v4(), + title: "Test Task".to_string(), + description: "A test description".to_string(), + status: opencode_core::TaskStatus::Todo, + roadmap_item_id: None, + workspace_path: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + } + } + + #[test] + fn test_planning_prompt_contains_task_info() { + let task = sample_task(); + let prompt = PhasePrompts::planning(&task); + + assert!(prompt.contains(&task.title)); + assert!(prompt.contains(&task.description)); + assert!(prompt.contains(&task.id.to_string())); + } + + #[test] + fn test_implementation_prompt_references_plan() { + let task = sample_task(); + let prompt = PhasePrompts::implementation(&task); + + assert!(prompt.contains(".opencode-studio/kanban/plans/")); + assert!(prompt.contains(&task.id.to_string())); + } + + #[test] + fn test_review_prompt_contains_diff() { + let task = sample_task(); + let diff = "+ added line\n- removed line"; + let prompt = PhasePrompts::review(&task, diff); + + assert!(prompt.contains(diff)); + assert!(prompt.contains("APPROVED")); + assert!(prompt.contains("CHANGES_REQUESTED")); + } +} diff --git a/crates/orchestrator/src/state_machine.rs b/crates/orchestrator/src/state_machine.rs new file mode 100644 index 0000000..62d8757 --- /dev/null +++ b/crates/orchestrator/src/state_machine.rs @@ -0,0 +1,118 @@ +use opencode_core::TaskStatus; + +use crate::error::{OrchestratorError, Result}; + +pub struct TaskStateMachine; + +impl TaskStateMachine { + pub fn validate_transition(from: &TaskStatus, to: &TaskStatus) -> Result<()> { + let allowed = Self::allowed_transitions(from); + + if allowed.contains(to) { + Ok(()) + } else { + Err(OrchestratorError::InvalidTransition { + from: from.as_str().to_string(), + to: to.as_str().to_string(), + }) + } + } + + fn allowed_transitions(from: &TaskStatus) -> Vec { + match from { + TaskStatus::Todo => vec![TaskStatus::Planning], + TaskStatus::Planning => vec![TaskStatus::PlanningReview, TaskStatus::Todo], + TaskStatus::PlanningReview => vec![TaskStatus::InProgress, TaskStatus::Planning], + TaskStatus::InProgress => vec![TaskStatus::AiReview, TaskStatus::PlanningReview], + TaskStatus::AiReview => vec![TaskStatus::Review, TaskStatus::InProgress], + TaskStatus::Review => vec![TaskStatus::Done, TaskStatus::InProgress], + TaskStatus::Done => vec![], + } + } + + pub fn can_transition(from: &TaskStatus, to: &TaskStatus) -> bool { + Self::validate_transition(from, to).is_ok() + } + + pub fn next_status(current: &TaskStatus) -> Option { + match current { + TaskStatus::Todo => Some(TaskStatus::Planning), + TaskStatus::Planning => Some(TaskStatus::PlanningReview), + TaskStatus::PlanningReview => Some(TaskStatus::InProgress), + TaskStatus::InProgress => Some(TaskStatus::AiReview), + TaskStatus::AiReview => Some(TaskStatus::Review), + TaskStatus::Review => Some(TaskStatus::Done), + TaskStatus::Done => None, + } + } + + pub fn previous_status(current: &TaskStatus) -> Option { + match current { + TaskStatus::Todo => None, + TaskStatus::Planning => Some(TaskStatus::Todo), + TaskStatus::PlanningReview => Some(TaskStatus::Planning), + TaskStatus::InProgress => Some(TaskStatus::PlanningReview), + TaskStatus::AiReview => Some(TaskStatus::InProgress), + TaskStatus::Review => Some(TaskStatus::AiReview), + TaskStatus::Done => Some(TaskStatus::Review), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_transitions() { + assert!(TaskStateMachine::can_transition( + &TaskStatus::Todo, + &TaskStatus::Planning + )); + assert!(TaskStateMachine::can_transition( + &TaskStatus::Planning, + &TaskStatus::PlanningReview + )); + assert!(TaskStateMachine::can_transition( + &TaskStatus::InProgress, + &TaskStatus::AiReview + )); + } + + #[test] + fn test_invalid_transitions() { + assert!(!TaskStateMachine::can_transition( + &TaskStatus::Todo, + &TaskStatus::Done + )); + assert!(!TaskStateMachine::can_transition( + &TaskStatus::Planning, + &TaskStatus::Done + )); + assert!(!TaskStateMachine::can_transition( + &TaskStatus::Done, + &TaskStatus::Todo + )); + } + + #[test] + fn test_backward_transitions() { + assert!(TaskStateMachine::can_transition( + &TaskStatus::Planning, + &TaskStatus::Todo + )); + assert!(TaskStateMachine::can_transition( + &TaskStatus::InProgress, + &TaskStatus::PlanningReview + )); + } + + #[test] + fn test_next_status() { + assert_eq!( + TaskStateMachine::next_status(&TaskStatus::Todo), + Some(TaskStatus::Planning) + ); + assert_eq!(TaskStateMachine::next_status(&TaskStatus::Done), None); + } +} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index ab2d40f..26fca10 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -1,14 +1,28 @@ [package] name = "server" -version = "0.1.0" -edition = "2024" +version.workspace = true +edition.workspace = true [dependencies] +opencode_core = { workspace = true } +opencode = { workspace = true } +orchestrator = { workspace = true } +db = { workspace = true } +vcs = { workspace = true } +events = { workspace = true } +websocket = { workspace = true } +sqlx = { workspace = true } tokio = { workspace = true } axum = { workspace = true } tower-http = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +uuid = { workspace = true } anyhow = { workspace = true } +thiserror = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } +axum-test = "16" diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs new file mode 100644 index 0000000..7d65040 --- /dev/null +++ b/crates/server/src/error.rs @@ -0,0 +1,94 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Serialize; + +#[derive(Debug)] +#[allow(dead_code)] +pub enum AppError { + NotFound(String), + BadRequest(String), + Conflict(String), + Internal(String), + Database(db::DbError), + Vcs(vcs::VcsError), +} + +#[derive(Serialize)] +struct ErrorResponse { + error: String, + message: String, +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, error_type, message) = match self { + AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg), + AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg), + AppError::Conflict(msg) => (StatusCode::CONFLICT, "conflict", msg), + AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error", msg), + AppError::Database(err) => { + tracing::error!("Database error: {:?}", err); + match err { + db::DbError::TaskNotFound(id) => ( + StatusCode::NOT_FOUND, + "not_found", + format!("Task not found: {}", id), + ), + db::DbError::SessionNotFound(id) => ( + StatusCode::NOT_FOUND, + "not_found", + format!("Session not found: {}", id), + ), + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + "database_error", + "Database error occurred".to_string(), + ), + } + } + AppError::Vcs(err) => { + tracing::error!("VCS error: {:?}", err); + match err { + vcs::VcsError::WorkspaceNotFound(id) => ( + StatusCode::NOT_FOUND, + "not_found", + format!("Workspace not found: {}", id), + ), + vcs::VcsError::WorkspaceAlreadyExists(id) => ( + StatusCode::CONFLICT, + "conflict", + format!("Workspace already exists: {}", id), + ), + vcs::VcsError::MergeConflict(msg) => { + (StatusCode::CONFLICT, "merge_conflict", msg) + } + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + "vcs_error", + err.to_string(), + ), + } + } + }; + + let body = Json(ErrorResponse { + error: error_type.to_string(), + message, + }); + + (status, body).into_response() + } +} + +impl From for AppError { + fn from(err: db::DbError) -> Self { + AppError::Database(err) + } +} + +impl From for AppError { + fn from(err: vcs::VcsError) -> Self { + AppError::Vcs(err) + } +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 8602214..67b706f 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -1,21 +1,87 @@ -use axum::{routing::get, Router}; +mod error; +mod routes; +mod state; + +use axum::routing::{get, post}; +use axum::Router; use tower_http::cors::CorsLayer; +use tower_http::trace::TraceLayer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use state::AppState; + #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::registry() .with(tracing_subscriber::fmt::layer()) - .with(tracing_subscriber::EnvFilter::from_default_env()) + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "server=debug,tower_http=debug".into()), + ) .init(); + let database_url = + std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:./studio.db".to_string()); + let opencode_url = + std::env::var("OPENCODE_URL").unwrap_or_else(|_| "http://localhost:4096".to_string()); + + tracing::info!("Connecting to database: {}", database_url); + tracing::info!("OpenCode server URL: {}", opencode_url); + + let pool = db::create_pool(&database_url).await?; + db::run_migrations(&pool).await?; + + tracing::info!("Database migrations completed"); + + let state = AppState::new(pool, &opencode_url); + let app = Router::new() - .route("/", get(|| async { "Hello from Rust backend!" })) - .route("/health", get(|| async { "OK" })) - .layer(CorsLayer::permissive()); + .route("/health", get(routes::health_check)) + .route( + "/api/tasks", + get(routes::list_tasks).post(routes::create_task), + ) + .route( + "/api/tasks/{id}", + get(routes::get_task) + .patch(routes::update_task) + .delete(routes::delete_task), + ) + .route("/api/tasks/{id}/transition", post(routes::transition_task)) + .route("/api/tasks/{id}/execute", post(routes::execute_task)) + .route( + "/api/tasks/{id}/sessions", + get(routes::list_sessions_for_task), + ) + .route( + "/api/tasks/{id}/workspace", + post(routes::create_workspace_for_task), + ) + .route("/api/sessions", get(routes::list_sessions)) + .route( + "/api/sessions/{id}", + get(routes::get_session).delete(routes::delete_session), + ) + .route("/api/workspaces", get(routes::list_workspaces)) + .route( + "/api/workspaces/{id}", + get(routes::get_workspace_status).delete(routes::delete_workspace), + ) + .route("/api/workspaces/{id}/diff", get(routes::get_workspace_diff)) + .route("/api/workspaces/{id}/merge", post(routes::merge_workspace)) + .route("/ws", get(routes::websocket_handler)) + .layer(TraceLayer::new_for_http()) + .layer(CorsLayer::permissive()) + .with_state(state); + + let port = std::env::var("PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(3001); + + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)).await?; + tracing::info!("Server listening on http://0.0.0.0:{}", port); - let listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await?; - tracing::info!("Server listening on {}", listener.local_addr()?); axum::serve(listener, app).await?; Ok(()) diff --git a/crates/server/src/routes/health.rs b/crates/server/src/routes/health.rs new file mode 100644 index 0000000..c12f6d8 --- /dev/null +++ b/crates/server/src/routes/health.rs @@ -0,0 +1,15 @@ +use axum::Json; +use serde::Serialize; + +#[derive(Serialize)] +pub struct HealthResponse { + status: String, + version: String, +} + +pub async fn health_check() -> Json { + Json(HealthResponse { + status: "ok".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }) +} diff --git a/crates/server/src/routes/mod.rs b/crates/server/src/routes/mod.rs new file mode 100644 index 0000000..e941f77 --- /dev/null +++ b/crates/server/src/routes/mod.rs @@ -0,0 +1,11 @@ +mod health; +mod sessions; +mod tasks; +mod workspaces; +mod ws; + +pub use health::*; +pub use sessions::*; +pub use tasks::*; +pub use workspaces::*; +pub use ws::*; diff --git a/crates/server/src/routes/sessions.rs b/crates/server/src/routes/sessions.rs new file mode 100644 index 0000000..08ad75f --- /dev/null +++ b/crates/server/src/routes/sessions.rs @@ -0,0 +1,46 @@ +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::Json; +use opencode_core::Session; +use uuid::Uuid; + +use crate::error::AppError; +use crate::state::AppState; + +pub async fn list_sessions(State(state): State) -> Result>, AppError> { + let sessions = state.session_repository.find_all().await?; + Ok(Json(sessions)) +} + +pub async fn get_session( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let session = state.session_repository.find_by_id(id).await?; + + match session { + Some(s) => Ok(Json(s)), + None => Err(AppError::NotFound(format!("Session not found: {}", id))), + } +} + +pub async fn list_sessions_for_task( + State(state): State, + Path(task_id): Path, +) -> Result>, AppError> { + let sessions = state.session_repository.find_by_task_id(task_id).await?; + Ok(Json(sessions)) +} + +pub async fn delete_session( + State(state): State, + Path(id): Path, +) -> Result { + let deleted = state.session_repository.delete(id).await?; + + if deleted { + Ok(StatusCode::NO_CONTENT) + } else { + Err(AppError::NotFound(format!("Session not found: {}", id))) + } +} diff --git a/crates/server/src/routes/tasks.rs b/crates/server/src/routes/tasks.rs new file mode 100644 index 0000000..e23952e --- /dev/null +++ b/crates/server/src/routes/tasks.rs @@ -0,0 +1,178 @@ +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::Json; +use events::{Event, EventEnvelope}; +use opencode_core::{CreateTaskRequest, Task, TaskStatus, UpdateTaskRequest}; +use orchestrator::PhaseResult; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::state::AppState; + +pub async fn list_tasks(State(state): State) -> Result>, AppError> { + let tasks = state.task_repository.find_all().await?; + Ok(Json(tasks)) +} + +pub async fn create_task( + State(state): State, + Json(payload): Json, +) -> Result<(StatusCode, Json), AppError> { + if payload.title.trim().is_empty() { + return Err(AppError::BadRequest("Title cannot be empty".to_string())); + } + + let task = Task::new(payload.title.clone(), payload.description); + let created = state.task_repository.create(&task).await?; + + state + .event_bus + .publish(EventEnvelope::new(Event::TaskCreated { + task_id: created.id, + title: payload.title, + })); + + Ok((StatusCode::CREATED, Json(created))) +} + +pub async fn get_task( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let task = state.task_repository.find_by_id(id).await?; + + match task { + Some(t) => Ok(Json(t)), + None => Err(AppError::NotFound(format!("Task not found: {}", id))), + } +} + +pub async fn update_task( + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> Result, AppError> { + let updated = state.task_repository.update(id, &payload).await?; + + match updated { + Some(t) => Ok(Json(t)), + None => Err(AppError::NotFound(format!("Task not found: {}", id))), + } +} + +pub async fn delete_task( + State(state): State, + Path(id): Path, +) -> Result { + let deleted = state.task_repository.delete(id).await?; + + if deleted { + Ok(StatusCode::NO_CONTENT) + } else { + Err(AppError::NotFound(format!("Task not found: {}", id))) + } +} + +#[derive(Debug, Deserialize)] +pub struct TransitionRequest { + pub status: TaskStatus, +} + +#[derive(Debug, Serialize)] +pub struct TransitionResponse { + pub task: Task, + pub previous_status: TaskStatus, +} + +pub async fn transition_task( + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> Result, AppError> { + let task = state.task_repository.find_by_id(id).await?; + let Some(mut task) = task else { + return Err(AppError::NotFound(format!("Task not found: {}", id))); + }; + + let previous_status = task.status; + + state + .task_executor + .transition(&mut task, payload.status) + .map_err(|e| AppError::BadRequest(e.to_string()))?; + + let update = UpdateTaskRequest { + status: Some(task.status), + ..Default::default() + }; + state.task_repository.update(id, &update).await?; + + state + .event_bus + .publish(EventEnvelope::new(Event::TaskStatusChanged { + task_id: id, + from_status: format!("{:?}", previous_status), + to_status: format!("{:?}", task.status), + })); + + Ok(Json(TransitionResponse { + task, + previous_status, + })) +} + +#[derive(Debug, Serialize)] +pub struct ExecuteResponse { + pub task: Task, + pub result: PhaseResultDto, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PhaseResultDto { + SessionCreated { session_id: String }, + AwaitingApproval, + ReviewPassed, + ReviewFailed { feedback: String }, + Completed, +} + +impl From for PhaseResultDto { + fn from(result: PhaseResult) -> Self { + match result { + PhaseResult::SessionCreated { session_id } => Self::SessionCreated { session_id }, + PhaseResult::AwaitingApproval => Self::AwaitingApproval, + PhaseResult::ReviewPassed => Self::ReviewPassed, + PhaseResult::ReviewFailed { feedback } => Self::ReviewFailed { feedback }, + PhaseResult::Completed => Self::Completed, + } + } +} + +pub async fn execute_task( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let task = state.task_repository.find_by_id(id).await?; + let Some(mut task) = task else { + return Err(AppError::NotFound(format!("Task not found: {}", id))); + }; + + let result = state + .task_executor + .execute_phase(&mut task) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + let update = UpdateTaskRequest { + status: Some(task.status), + ..Default::default() + }; + state.task_repository.update(id, &update).await?; + + Ok(Json(ExecuteResponse { + task, + result: result.into(), + })) +} diff --git a/crates/server/src/routes/workspaces.rs b/crates/server/src/routes/workspaces.rs new file mode 100644 index 0000000..cc5d814 --- /dev/null +++ b/crates/server/src/routes/workspaces.rs @@ -0,0 +1,164 @@ +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::Json; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use vcs::{MergeResult, Workspace}; + +use crate::error::AppError; +use crate::state::AppState; + +#[derive(Debug, Serialize)] +pub struct WorkspaceResponse { + pub task_id: String, + pub path: String, + pub branch_name: String, + pub status: String, + pub created_at: String, +} + +impl From for WorkspaceResponse { + fn from(ws: Workspace) -> Self { + Self { + task_id: ws.task_id, + path: ws.path.display().to_string(), + branch_name: ws.branch_name, + status: format!("{:?}", ws.status).to_lowercase(), + created_at: ws.created_at.to_rfc3339(), + } + } +} + +pub async fn create_workspace_for_task( + State(state): State, + Path(task_id): Path, +) -> Result<(StatusCode, Json), AppError> { + let task = state.task_repository.find_by_id(task_id).await?; + + let Some(_task) = task else { + return Err(AppError::NotFound(format!("Task not found: {}", task_id))); + }; + + let workspace = state + .workspace_manager + .setup_workspace(&task_id.to_string()) + .await?; + + Ok((StatusCode::CREATED, Json(workspace.into()))) +} + +pub async fn list_workspaces( + State(state): State, +) -> Result>, AppError> { + let workspaces = state.workspace_manager.list_workspaces().await?; + Ok(Json(workspaces.into_iter().map(Into::into).collect())) +} + +#[derive(Debug, Serialize)] +pub struct DiffResponse { + pub task_id: String, + pub diff: String, +} + +pub async fn get_workspace_diff( + State(state): State, + Path(task_id): Path, +) -> Result, AppError> { + let workspaces = state.workspace_manager.list_workspaces().await?; + + let workspace = workspaces + .into_iter() + .find(|ws| ws.task_id == task_id) + .ok_or_else(|| AppError::NotFound(format!("Workspace not found: {}", task_id)))?; + + let diff = state.workspace_manager.get_diff(&workspace).await?; + + Ok(Json(DiffResponse { + task_id: workspace.task_id, + diff, + })) +} + +pub async fn get_workspace_status( + State(state): State, + Path(task_id): Path, +) -> Result, AppError> { + let workspaces = state.workspace_manager.list_workspaces().await?; + + let workspace = workspaces + .into_iter() + .find(|ws| ws.task_id == task_id) + .ok_or_else(|| AppError::NotFound(format!("Workspace not found: {}", task_id)))?; + + let status = state.workspace_manager.get_status(&workspace).await?; + + Ok(Json(serde_json::json!({ + "task_id": workspace.task_id, + "status": status + }))) +} + +#[derive(Debug, Deserialize)] +pub struct MergeRequest { + pub message: String, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "result", rename_all = "snake_case")] +pub enum MergeResponse { + Success, + Conflicts { files: Vec }, +} + +impl From for MergeResponse { + fn from(result: MergeResult) -> Self { + match result { + MergeResult::Success => MergeResponse::Success, + MergeResult::Conflicts { files } => MergeResponse::Conflicts { + files: files + .into_iter() + .map(|f| f.path.display().to_string()) + .collect(), + }, + } + } +} + +pub async fn merge_workspace( + State(state): State, + Path(task_id): Path, + Json(payload): Json, +) -> Result, AppError> { + let workspaces = state.workspace_manager.list_workspaces().await?; + + let workspace = workspaces + .into_iter() + .find(|ws| ws.task_id == task_id) + .ok_or_else(|| AppError::NotFound(format!("Workspace not found: {}", task_id)))?; + + let result = state + .workspace_manager + .merge_workspace(&workspace, &payload.message) + .await?; + + Ok(Json(result.into())) +} + +pub async fn delete_workspace( + State(state): State, + Path(task_id): Path, +) -> Result { + let workspaces = state.workspace_manager.list_workspaces().await?; + + let workspace = workspaces + .into_iter() + .find(|ws| ws.task_id == task_id) + .ok_or_else(|| AppError::NotFound(format!("Workspace not found: {}", task_id)))?; + + state + .workspace_manager + .cleanup_workspace(&workspace) + .await?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/server/src/routes/ws.rs b/crates/server/src/routes/ws.rs new file mode 100644 index 0000000..7aae006 --- /dev/null +++ b/crates/server/src/routes/ws.rs @@ -0,0 +1,17 @@ +use std::sync::Arc; + +use axum::extract::ws::WebSocketUpgrade; +use axum::extract::State; +use axum::response::IntoResponse; + +use websocket::WsState; + +use crate::state::AppState; + +pub async fn websocket_handler( + ws: WebSocketUpgrade, + State(state): State, +) -> impl IntoResponse { + let ws_state = Arc::new(WsState::new(state.event_bus.clone())); + websocket::ws_handler(ws, State(ws_state)).await +} diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs new file mode 100644 index 0000000..50d0e55 --- /dev/null +++ b/crates/server/src/state.rs @@ -0,0 +1,57 @@ +use db::{SessionRepository, TaskRepository}; +use events::EventBus; +use opencode::OpenCodeClient; +use orchestrator::TaskExecutor; +use sqlx::SqlitePool; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use vcs::{GitVcs, JujutsuVcs, VersionControl, WorkspaceConfig, WorkspaceManager}; + +#[derive(Clone)] +pub struct AppState { + pub task_repository: TaskRepository, + pub session_repository: SessionRepository, + pub task_executor: Arc, + pub workspace_manager: Arc, + pub event_bus: EventBus, +} + +impl AppState { + pub fn new(pool: SqlitePool, opencode_url: &str) -> Self { + let opencode_client = Arc::new(OpenCodeClient::new(opencode_url)); + + let repo_path = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let workspace_base = repo_path + .parent() + .map(|p| p.join(".workspaces")) + .unwrap_or_else(|| PathBuf::from("../.workspaces")); + + let vcs = Self::detect_vcs(&repo_path, &workspace_base); + let config = WorkspaceConfig::new(workspace_base.clone()); + let workspace_manager = Arc::new(WorkspaceManager::new(vcs, config, repo_path)); + + Self { + task_repository: TaskRepository::new(pool.clone()), + session_repository: SessionRepository::new(pool), + task_executor: Arc::new(TaskExecutor::new(opencode_client)), + workspace_manager, + event_bus: EventBus::new(), + } + } + + fn detect_vcs(repo_path: &Path, workspace_base: &Path) -> Arc { + if repo_path.join(".jj").exists() { + tracing::info!("Detected Jujutsu repository"); + Arc::new(JujutsuVcs::new( + repo_path.to_path_buf(), + workspace_base.to_path_buf(), + )) + } else { + tracing::info!("Using Git as VCS backend"); + Arc::new(GitVcs::new( + repo_path.to_path_buf(), + workspace_base.to_path_buf(), + )) + } + } +} diff --git a/crates/vcs/Cargo.toml b/crates/vcs/Cargo.toml new file mode 100644 index 0000000..742cba6 --- /dev/null +++ b/crates/vcs/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "vcs" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] +opencode_core = { workspace = true } + +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +chrono = { workspace = true } +async-trait = { workspace = true } + +[dev-dependencies] +tempfile = "3.14" diff --git a/crates/vcs/src/error.rs b/crates/vcs/src/error.rs new file mode 100644 index 0000000..9b6edf7 --- /dev/null +++ b/crates/vcs/src/error.rs @@ -0,0 +1,33 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum VcsError { + #[error("Command execution failed: {0}")] + CommandFailed(String), + + #[error("Command not found: {0}")] + CommandNotFound(String), + + #[error("Workspace not found: {0}")] + WorkspaceNotFound(String), + + #[error("Workspace already exists: {0}")] + WorkspaceAlreadyExists(String), + + #[error("Invalid workspace path: {0}")] + InvalidPath(String), + + #[error("VCS not initialized in repository: {0}")] + NotInitialized(String), + + #[error("Merge conflict: {0}")] + MergeConflict(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Parse error: {0}")] + Parse(String), +} + +pub type Result = std::result::Result; diff --git a/crates/vcs/src/git.rs b/crates/vcs/src/git.rs new file mode 100644 index 0000000..7219ccc --- /dev/null +++ b/crates/vcs/src/git.rs @@ -0,0 +1,325 @@ +use async_trait::async_trait; +use std::path::PathBuf; +use tokio::process::Command; +use tracing::{debug, warn}; + +use crate::error::{Result, VcsError}; +use crate::traits::{ConflictFile, ConflictType, MergeResult, VersionControl, Workspace}; + +pub struct GitVcs { + repo_path: PathBuf, + workspace_base: PathBuf, + main_branch: String, +} + +impl GitVcs { + pub fn new(repo_path: PathBuf, workspace_base: PathBuf) -> Self { + Self { + repo_path, + workspace_base, + main_branch: "main".to_string(), + } + } + + pub fn with_main_branch(mut self, branch: impl Into) -> Self { + self.main_branch = branch.into(); + self + } + + async fn run_git(&self, args: &[&str], cwd: &PathBuf) -> Result { + debug!("Running git {:?} in {:?}", args, cwd); + + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(VcsError::CommandFailed(format!( + "git {} failed: {}", + args.join(" "), + stderr + ))); + } + + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } + + fn workspace_path(&self, task_id: &str) -> PathBuf { + self.workspace_base.join(format!("task-{}", task_id)) + } + + fn branch_name(&self, task_id: &str) -> String { + format!("task-{}", task_id) + } + + async fn get_repo_conflicts(&self) -> Result> { + let output = self + .run_git(&["diff", "--name-only", "--diff-filter=U"], &self.repo_path) + .await; + + match output { + Ok(text) => { + let conflicts: Vec = text + .lines() + .filter(|line| !line.is_empty()) + .map(|path| ConflictFile { + path: PathBuf::from(path), + conflict_type: ConflictType::Content, + }) + .collect(); + Ok(conflicts) + } + Err(_) => Ok(Vec::new()), + } + } +} + +#[async_trait] +impl VersionControl for GitVcs { + fn name(&self) -> &'static str { + "git" + } + + async fn is_available(&self) -> bool { + Command::new("git") + .arg("--version") + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false) + } + + async fn is_initialized(&self) -> Result { + let git_dir = self.repo_path.join(".git"); + Ok(git_dir.exists()) + } + + async fn create_workspace(&self, task_id: &str) -> Result { + let workspace_path = self.workspace_path(task_id); + let branch = self.branch_name(task_id); + + if workspace_path.exists() { + return Err(VcsError::WorkspaceAlreadyExists(task_id.to_string())); + } + + self.run_git( + &[ + "worktree", + "add", + "-b", + &branch, + workspace_path + .to_str() + .ok_or_else(|| VcsError::InvalidPath(workspace_path.display().to_string()))?, + &self.main_branch, + ], + &self.repo_path, + ) + .await?; + + Ok(Workspace::new(task_id, workspace_path, branch)) + } + + async fn get_diff(&self, workspace: &Workspace) -> Result { + if !workspace.path.exists() { + return Err(VcsError::WorkspaceNotFound(workspace.task_id.clone())); + } + + let staged = self.run_git(&["diff", "--cached"], &workspace.path).await?; + let unstaged = self.run_git(&["diff"], &workspace.path).await?; + + Ok(format!("{}{}", staged, unstaged)) + } + + async fn get_status(&self, workspace: &Workspace) -> Result { + if !workspace.path.exists() { + return Err(VcsError::WorkspaceNotFound(workspace.task_id.clone())); + } + + self.run_git(&["status", "--porcelain"], &workspace.path) + .await + } + + async fn merge_workspace(&self, workspace: &Workspace, message: &str) -> Result { + if !workspace.path.exists() { + return Err(VcsError::WorkspaceNotFound(workspace.task_id.clone())); + } + + let status = self.get_status(workspace).await?; + if !status.is_empty() { + self.run_git(&["add", "-A"], &workspace.path).await?; + self.run_git(&["commit", "-m", message], &workspace.path) + .await?; + } + + self.run_git(&["checkout", &self.main_branch], &self.repo_path) + .await?; + + let merge_result = self + .run_git( + &["merge", "--no-ff", &workspace.branch_name, "-m", message], + &self.repo_path, + ) + .await; + + match merge_result { + Ok(_) => Ok(MergeResult::Success), + Err(e) => { + warn!("Merge failed: {}", e); + let conflicts = self.get_repo_conflicts().await?; + if conflicts.is_empty() { + let _ = self.run_git(&["merge", "--abort"], &self.repo_path).await; + Err(e) + } else { + let _ = self.run_git(&["merge", "--abort"], &self.repo_path).await; + Ok(MergeResult::Conflicts { files: conflicts }) + } + } + } + } + + async fn cleanup_workspace(&self, workspace: &Workspace) -> Result<()> { + let _ = self + .run_git( + &[ + "worktree", + "remove", + "--force", + workspace.path.to_str().unwrap_or(""), + ], + &self.repo_path, + ) + .await; + + let _ = self + .run_git(&["branch", "-D", &workspace.branch_name], &self.repo_path) + .await; + + if workspace.path.exists() { + tokio::fs::remove_dir_all(&workspace.path).await?; + } + + Ok(()) + } + + async fn list_workspaces(&self) -> Result> { + let output = self + .run_git(&["worktree", "list", "--porcelain"], &self.repo_path) + .await?; + + let mut workspaces = Vec::new(); + let mut current_path: Option = None; + let mut current_branch: Option = None; + + for line in output.lines() { + if let Some(path) = line.strip_prefix("worktree ") { + current_path = Some(PathBuf::from(path)); + } else if let Some(branch) = line.strip_prefix("branch refs/heads/") { + current_branch = Some(branch.to_string()); + } else if line.is_empty() { + if let (Some(path), Some(branch)) = (current_path.take(), current_branch.take()) { + if let Some(task_id) = branch.strip_prefix("task-") { + let task_id = task_id.to_string(); + workspaces.push(Workspace::new(task_id, path, branch)); + } + } + } + } + + if let (Some(path), Some(branch)) = (current_path, current_branch) { + if let Some(task_id) = branch.strip_prefix("task-") { + let task_id = task_id.to_string(); + workspaces.push(Workspace::new(task_id, path, branch)); + } + } + + Ok(workspaces) + } + + async fn get_conflicts(&self, workspace: &Workspace) -> Result> { + if !workspace.path.exists() { + return Err(VcsError::WorkspaceNotFound(workspace.task_id.clone())); + } + + let output = self + .run_git(&["diff", "--name-only", "--diff-filter=U"], &workspace.path) + .await; + + match output { + Ok(text) => { + let conflicts: Vec = text + .lines() + .filter(|line| !line.is_empty()) + .map(|path| ConflictFile { + path: PathBuf::from(path), + conflict_type: ConflictType::Content, + }) + .collect(); + Ok(conflicts) + } + Err(_) => Ok(Vec::new()), + } + } + + async fn commit(&self, workspace: &Workspace, message: &str) -> Result { + if !workspace.path.exists() { + return Err(VcsError::WorkspaceNotFound(workspace.task_id.clone())); + } + + self.run_git(&["add", "-A"], &workspace.path).await?; + self.run_git(&["commit", "-m", message], &workspace.path) + .await?; + + let output = self + .run_git(&["rev-parse", "HEAD"], &workspace.path) + .await?; + + Ok(output.trim().to_string()) + } + + async fn push(&self, workspace: &Workspace, remote: &str) -> Result<()> { + if !workspace.path.exists() { + return Err(VcsError::WorkspaceNotFound(workspace.task_id.clone())); + } + + self.run_git( + &["push", "-u", remote, &workspace.branch_name], + &workspace.path, + ) + .await?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_workspace_path() { + let vcs = GitVcs::new(PathBuf::from("/repo"), PathBuf::from("/workspaces")); + + let path = vcs.workspace_path("123"); + assert_eq!(path, PathBuf::from("/workspaces/task-123")); + } + + #[test] + fn test_branch_name() { + let vcs = GitVcs::new(PathBuf::from("/repo"), PathBuf::from("/workspaces")); + + let name = vcs.branch_name("abc-456"); + assert_eq!(name, "task-abc-456"); + } + + #[test] + fn test_with_main_branch() { + let vcs = GitVcs::new(PathBuf::from("/repo"), PathBuf::from("/workspaces")) + .with_main_branch("master"); + + assert_eq!(vcs.main_branch, "master"); + } +} diff --git a/crates/vcs/src/jj.rs b/crates/vcs/src/jj.rs new file mode 100644 index 0000000..0ad2f9e --- /dev/null +++ b/crates/vcs/src/jj.rs @@ -0,0 +1,290 @@ +use async_trait::async_trait; +use std::path::PathBuf; +use tokio::process::Command; +use tracing::{debug, warn}; + +use crate::error::{Result, VcsError}; +use crate::traits::{ConflictFile, ConflictType, MergeResult, VersionControl, Workspace}; + +pub struct JujutsuVcs { + repo_path: PathBuf, + workspace_base: PathBuf, +} + +impl JujutsuVcs { + pub fn new(repo_path: PathBuf, workspace_base: PathBuf) -> Self { + Self { + repo_path, + workspace_base, + } + } + + async fn run_jj(&self, args: &[&str], cwd: &PathBuf) -> Result { + debug!("Running jj {:?} in {:?}", args, cwd); + + let output = Command::new("jj") + .args(args) + .current_dir(cwd) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(VcsError::CommandFailed(format!( + "jj {} failed: {}", + args.join(" "), + stderr + ))); + } + + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } + + fn workspace_path(&self, task_id: &str) -> PathBuf { + self.workspace_base.join(format!("task-{}", task_id)) + } + + fn workspace_name(&self, task_id: &str) -> String { + format!("task-{}", task_id) + } +} + +#[async_trait] +impl VersionControl for JujutsuVcs { + fn name(&self) -> &'static str { + "jujutsu" + } + + async fn is_available(&self) -> bool { + Command::new("jj") + .arg("--version") + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false) + } + + async fn is_initialized(&self) -> Result { + let jj_dir = self.repo_path.join(".jj"); + Ok(jj_dir.exists()) + } + + async fn create_workspace(&self, task_id: &str) -> Result { + let workspace_path = self.workspace_path(task_id); + let workspace_name = self.workspace_name(task_id); + + if workspace_path.exists() { + return Err(VcsError::WorkspaceAlreadyExists(task_id.to_string())); + } + + self.run_jj( + &[ + "new", + "main", + "-m", + &format!("task-{}: Start implementation", task_id), + ], + &self.repo_path, + ) + .await?; + + self.run_jj( + &[ + "workspace", + "add", + workspace_path + .to_str() + .ok_or_else(|| VcsError::InvalidPath(workspace_path.display().to_string()))?, + "--name", + &workspace_name, + ], + &self.repo_path, + ) + .await?; + + Ok(Workspace::new(task_id, workspace_path, workspace_name)) + } + + async fn get_diff(&self, workspace: &Workspace) -> Result { + if !workspace.path.exists() { + return Err(VcsError::WorkspaceNotFound(workspace.task_id.clone())); + } + + self.run_jj(&["diff"], &workspace.path).await + } + + async fn get_status(&self, workspace: &Workspace) -> Result { + if !workspace.path.exists() { + return Err(VcsError::WorkspaceNotFound(workspace.task_id.clone())); + } + + self.run_jj(&["status"], &workspace.path).await + } + + async fn merge_workspace(&self, workspace: &Workspace, message: &str) -> Result { + if !workspace.path.exists() { + return Err(VcsError::WorkspaceNotFound(workspace.task_id.clone())); + } + + self.run_jj(&["describe", "-m", message], &workspace.path) + .await?; + + let result = self + .run_jj(&["rebase", "-d", "main"], &workspace.path) + .await; + + match result { + Ok(_) => { + let conflicts = self.get_conflicts(workspace).await?; + if conflicts.is_empty() { + Ok(MergeResult::Success) + } else { + Ok(MergeResult::Conflicts { files: conflicts }) + } + } + Err(e) => { + warn!("Rebase failed: {}", e); + let conflicts = self.get_conflicts(workspace).await.unwrap_or_default(); + if conflicts.is_empty() { + Err(e) + } else { + Ok(MergeResult::Conflicts { files: conflicts }) + } + } + } + } + + async fn cleanup_workspace(&self, workspace: &Workspace) -> Result<()> { + let workspace_name = self.workspace_name(&workspace.task_id); + + let _ = self + .run_jj(&["workspace", "forget", &workspace_name], &self.repo_path) + .await; + + if workspace.path.exists() { + tokio::fs::remove_dir_all(&workspace.path).await?; + } + + Ok(()) + } + + async fn list_workspaces(&self) -> Result> { + let output = self.run_jj(&["workspace", "list"], &self.repo_path).await?; + + let mut workspaces = Vec::new(); + + for line in output.lines() { + if let Some(name) = line.split_whitespace().next() { + if name.starts_with("task-") { + let task_id = name.strip_prefix("task-").unwrap_or(name); + let path = self.workspace_path(task_id); + + if path.exists() { + workspaces.push(Workspace::new(task_id, path, name)); + } + } + } + } + + Ok(workspaces) + } + + async fn get_conflicts(&self, workspace: &Workspace) -> Result> { + if !workspace.path.exists() { + return Err(VcsError::WorkspaceNotFound(workspace.task_id.clone())); + } + + let output = self.run_jj(&["resolve", "--list"], &workspace.path).await; + + match output { + Ok(text) => { + let conflicts: Vec = text + .lines() + .filter(|line| !line.is_empty()) + .map(|line| { + let path = line.split_whitespace().next().unwrap_or(line); + ConflictFile { + path: PathBuf::from(path), + conflict_type: ConflictType::Content, + } + }) + .collect(); + Ok(conflicts) + } + Err(_) => Ok(Vec::new()), + } + } + + async fn commit(&self, workspace: &Workspace, message: &str) -> Result { + if !workspace.path.exists() { + return Err(VcsError::WorkspaceNotFound(workspace.task_id.clone())); + } + + self.run_jj(&["describe", "-m", message], &workspace.path) + .await?; + + let output = self + .run_jj( + &["log", "-r", "@", "--no-graph", "-T", "change_id"], + &workspace.path, + ) + .await?; + + Ok(output.trim().to_string()) + } + + async fn push(&self, workspace: &Workspace, remote: &str) -> Result<()> { + if !workspace.path.exists() { + return Err(VcsError::WorkspaceNotFound(workspace.task_id.clone())); + } + + self.run_jj( + &[ + "bookmark", + "create", + &workspace.branch_name, + "-r", + "@", + "--allow-backwards", + ], + &workspace.path, + ) + .await?; + + self.run_jj( + &[ + "git", + "push", + "--remote", + remote, + "--bookmark", + &workspace.branch_name, + ], + &workspace.path, + ) + .await?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_workspace_path() { + let vcs = JujutsuVcs::new(PathBuf::from("/repo"), PathBuf::from("/workspaces")); + + let path = vcs.workspace_path("123"); + assert_eq!(path, PathBuf::from("/workspaces/task-123")); + } + + #[test] + fn test_workspace_name() { + let vcs = JujutsuVcs::new(PathBuf::from("/repo"), PathBuf::from("/workspaces")); + + let name = vcs.workspace_name("abc-456"); + assert_eq!(name, "task-abc-456"); + } +} diff --git a/crates/vcs/src/lib.rs b/crates/vcs/src/lib.rs new file mode 100644 index 0000000..4c7c458 --- /dev/null +++ b/crates/vcs/src/lib.rs @@ -0,0 +1,11 @@ +pub mod error; +pub mod git; +pub mod jj; +pub mod traits; +pub mod workspace; + +pub use error::{Result, VcsError}; +pub use git::GitVcs; +pub use jj::JujutsuVcs; +pub use traits::{ConflictFile, MergeResult, VersionControl, Workspace, WorkspaceStatus}; +pub use workspace::{WorkspaceConfig, WorkspaceManager}; diff --git a/crates/vcs/src/traits.rs b/crates/vcs/src/traits.rs new file mode 100644 index 0000000..b2ecf09 --- /dev/null +++ b/crates/vcs/src/traits.rs @@ -0,0 +1,171 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +use crate::error::Result; + +/// Represents an isolated workspace for a task +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Workspace { + pub task_id: String, + pub path: PathBuf, + pub branch_name: String, + pub status: WorkspaceStatus, + pub created_at: DateTime, +} + +impl Workspace { + pub fn new(task_id: impl Into, path: PathBuf, branch_name: impl Into) -> Self { + Self { + task_id: task_id.into(), + path, + branch_name: branch_name.into(), + status: WorkspaceStatus::Active, + created_at: Utc::now(), + } + } +} + +/// Status of a workspace +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceStatus { + Active, + Merged, + Abandoned, +} + +/// Result of a merge operation +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum MergeResult { + Success, + Conflicts { files: Vec }, +} + +impl MergeResult { + pub fn is_success(&self) -> bool { + matches!(self, MergeResult::Success) + } + + pub fn conflicts(&self) -> Option<&[ConflictFile]> { + match self { + MergeResult::Conflicts { files } => Some(files), + MergeResult::Success => None, + } + } +} + +/// Represents a file with merge conflicts +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConflictFile { + pub path: PathBuf, + pub conflict_type: ConflictType, +} + +/// Type of conflict in a file +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConflictType { + Content, + AddAdd, + ModifyDelete, + DeleteModify, + Rename, +} + +/// Trait for version control system operations +#[async_trait] +pub trait VersionControl: Send + Sync { + /// Get the name of the VCS backend + fn name(&self) -> &'static str; + + /// Check if the VCS is available (command exists) + async fn is_available(&self) -> bool; + + /// Check if the repository is initialized with this VCS + async fn is_initialized(&self) -> Result; + + /// Create an isolated workspace for a task + async fn create_workspace(&self, task_id: &str) -> Result; + + /// Get diff of changes in a workspace + async fn get_diff(&self, workspace: &Workspace) -> Result; + + /// Get the status of changes in a workspace + async fn get_status(&self, workspace: &Workspace) -> Result; + + /// Merge workspace changes back to main branch + async fn merge_workspace(&self, workspace: &Workspace, message: &str) -> Result; + + /// Clean up and remove a workspace + async fn cleanup_workspace(&self, workspace: &Workspace) -> Result<()>; + + /// List all active workspaces + async fn list_workspaces(&self) -> Result>; + + /// Get conflicts in a workspace (if any) + async fn get_conflicts(&self, workspace: &Workspace) -> Result>; + + /// Commit changes in a workspace + async fn commit(&self, workspace: &Workspace, message: &str) -> Result; + + /// Push changes to remote (if applicable) + async fn push(&self, workspace: &Workspace, remote: &str) -> Result<()>; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_workspace_new() { + let ws = Workspace::new("task-123", PathBuf::from("/tmp/ws"), "branch-123"); + + assert_eq!(ws.task_id, "task-123"); + assert_eq!(ws.path, PathBuf::from("/tmp/ws")); + assert_eq!(ws.branch_name, "branch-123"); + assert_eq!(ws.status, WorkspaceStatus::Active); + } + + #[test] + fn test_merge_result_success() { + let result = MergeResult::Success; + + assert!(result.is_success()); + assert!(result.conflicts().is_none()); + } + + #[test] + fn test_merge_result_conflicts() { + let conflict = ConflictFile { + path: PathBuf::from("src/main.rs"), + conflict_type: ConflictType::Content, + }; + let result = MergeResult::Conflicts { + files: vec![conflict], + }; + + assert!(!result.is_success()); + assert_eq!(result.conflicts().unwrap().len(), 1); + } + + #[test] + fn test_workspace_status_serialization() { + let status = WorkspaceStatus::Active; + let json = serde_json::to_string(&status).unwrap(); + assert_eq!(json, "\"active\""); + + let status = WorkspaceStatus::Merged; + let json = serde_json::to_string(&status).unwrap(); + assert_eq!(json, "\"merged\""); + } + + #[test] + fn test_conflict_type_serialization() { + let ct = ConflictType::ModifyDelete; + let json = serde_json::to_string(&ct).unwrap(); + assert_eq!(json, "\"modify_delete\""); + } +} diff --git a/crates/vcs/src/workspace.rs b/crates/vcs/src/workspace.rs new file mode 100644 index 0000000..084f137 --- /dev/null +++ b/crates/vcs/src/workspace.rs @@ -0,0 +1,241 @@ +use std::path::PathBuf; +use std::sync::Arc; +use tokio::process::Command; +use tracing::{debug, info, warn}; + +use crate::error::{Result, VcsError}; +use crate::traits::{MergeResult, VersionControl, Workspace}; + +#[derive(Debug, Clone)] +pub struct WorkspaceConfig { + pub workspace_base: PathBuf, + pub init_scripts: Vec, + pub cleanup_scripts: Vec, + pub copy_files: Vec, + pub symlink_dirs: Vec, +} + +impl Default for WorkspaceConfig { + fn default() -> Self { + Self { + workspace_base: PathBuf::from("../.workspaces"), + init_scripts: Vec::new(), + cleanup_scripts: Vec::new(), + copy_files: vec![".env".to_string(), ".env.local".to_string()], + symlink_dirs: vec![ + "node_modules".to_string(), + "target".to_string(), + ".venv".to_string(), + ], + } + } +} + +impl WorkspaceConfig { + pub fn new(workspace_base: PathBuf) -> Self { + Self { + workspace_base, + ..Default::default() + } + } + + pub fn with_init_scripts(mut self, scripts: Vec) -> Self { + self.init_scripts = scripts; + self + } + + pub fn with_cleanup_scripts(mut self, scripts: Vec) -> Self { + self.cleanup_scripts = scripts; + self + } +} + +pub struct WorkspaceManager { + vcs: Arc, + config: WorkspaceConfig, + repo_path: PathBuf, +} + +impl WorkspaceManager { + pub fn new(vcs: Arc, config: WorkspaceConfig, repo_path: PathBuf) -> Self { + Self { + vcs, + config, + repo_path, + } + } + + pub async fn setup_workspace(&self, task_id: &str) -> Result { + info!("Setting up workspace for task {}", task_id); + + let workspace = self.vcs.create_workspace(task_id).await?; + + if let Err(e) = self.run_init_scripts(&workspace).await { + warn!("Init scripts failed: {}, cleaning up workspace", e); + let _ = self.cleanup_workspace(&workspace).await; + return Err(e); + } + + if let Err(e) = self.setup_files(&workspace).await { + warn!("File setup failed: {}, cleaning up workspace", e); + let _ = self.cleanup_workspace(&workspace).await; + return Err(e); + } + + info!("Workspace created at {:?}", workspace.path); + Ok(workspace) + } + + async fn run_init_scripts(&self, workspace: &Workspace) -> Result<()> { + for script in &self.config.init_scripts { + if !script.exists() { + warn!("Init script not found: {:?}", script); + continue; + } + + debug!("Running init script: {:?}", script); + + let output = Command::new("bash") + .arg(script) + .arg(&workspace.path) + .arg(&workspace.task_id) + .arg(&self.repo_path) + .current_dir(&self.repo_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(VcsError::CommandFailed(format!( + "Init script {:?} failed: {}", + script, stderr + ))); + } + } + + Ok(()) + } + + async fn setup_files(&self, workspace: &Workspace) -> Result<()> { + for file in &self.config.copy_files { + let src = self.repo_path.join(file); + let dst = workspace.path.join(file); + + if src.exists() { + debug!("Copying {} to workspace", file); + if let Some(parent) = dst.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::copy(&src, &dst).await?; + } + } + + for dir in &self.config.symlink_dirs { + let src = self.repo_path.join(dir); + let dst = workspace.path.join(dir); + + if src.exists() && !dst.exists() { + debug!("Symlinking {} to workspace", dir); + #[cfg(unix)] + tokio::fs::symlink(&src, &dst).await?; + #[cfg(windows)] + tokio::fs::symlink_dir(&src, &dst).await?; + } + } + + Ok(()) + } + + pub async fn cleanup_workspace(&self, workspace: &Workspace) -> Result<()> { + info!("Cleaning up workspace for task {}", workspace.task_id); + + for script in &self.config.cleanup_scripts { + if !script.exists() { + warn!("Cleanup script not found: {:?}", script); + continue; + } + + debug!("Running cleanup script: {:?}", script); + + match Command::new("bash") + .arg(script) + .arg(&workspace.path) + .arg(&workspace.task_id) + .current_dir(&self.repo_path) + .output() + .await + { + Ok(output) => { + if !output.status.success() { + warn!( + "Cleanup script {:?} failed with status {:?}: {}", + script, + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + } + } + Err(e) => { + warn!("Failed to execute cleanup script {:?}: {}", script, e); + } + } + } + + self.vcs.cleanup_workspace(workspace).await?; + + info!("Workspace cleaned up: {}", workspace.task_id); + Ok(()) + } + + pub async fn get_diff(&self, workspace: &Workspace) -> Result { + self.vcs.get_diff(workspace).await + } + + pub async fn get_status(&self, workspace: &Workspace) -> Result { + self.vcs.get_status(workspace).await + } + + pub async fn merge_workspace( + &self, + workspace: &Workspace, + message: &str, + ) -> Result { + self.vcs.merge_workspace(workspace, message).await + } + + pub async fn list_workspaces(&self) -> Result> { + self.vcs.list_workspaces().await + } + + pub async fn commit(&self, workspace: &Workspace, message: &str) -> Result { + self.vcs.commit(workspace, message).await + } + + pub async fn push(&self, workspace: &Workspace, remote: &str) -> Result<()> { + self.vcs.push(workspace, remote).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_workspace_config_default() { + let config = WorkspaceConfig::default(); + assert_eq!(config.workspace_base, PathBuf::from("../.workspaces")); + assert!(config.init_scripts.is_empty()); + assert!(config.cleanup_scripts.is_empty()); + } + + #[test] + fn test_workspace_config_builder() { + let config = WorkspaceConfig::new(PathBuf::from("/custom/path")) + .with_init_scripts(vec![PathBuf::from("init.sh")]) + .with_cleanup_scripts(vec![PathBuf::from("cleanup.sh")]); + + assert_eq!(config.workspace_base, PathBuf::from("/custom/path")); + assert_eq!(config.init_scripts.len(), 1); + assert_eq!(config.cleanup_scripts.len(), 1); + } +} diff --git a/crates/websocket/Cargo.toml b/crates/websocket/Cargo.toml new file mode 100644 index 0000000..cb484dc --- /dev/null +++ b/crates/websocket/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "websocket" +version.workspace = true +edition.workspace = true + +[dependencies] +events = { path = "../events" } + +axum = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +tracing = { workspace = true } +futures-util = "0.3" + +[dev-dependencies] +tokio = { workspace = true, features = ["full", "test-util"] } diff --git a/crates/websocket/src/handler.rs b/crates/websocket/src/handler.rs new file mode 100644 index 0000000..1b69fee --- /dev/null +++ b/crates/websocket/src/handler.rs @@ -0,0 +1,165 @@ +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::State; +use axum::response::IntoResponse; +use futures_util::{SinkExt, StreamExt}; +use tokio::sync::broadcast; +use tokio::time::interval; + +use events::EventBus; + +use crate::messages::{ClientMessage, ServerMessage, SubscriptionFilter}; + +const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); +const CLIENT_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Clone)] +pub struct WsState { + pub event_bus: EventBus, +} + +impl WsState { + pub fn new(event_bus: EventBus) -> Self { + Self { event_bus } + } +} + +pub async fn ws_handler( + ws: WebSocketUpgrade, + State(state): State>, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_socket(socket, state)) +} + +async fn handle_socket(socket: WebSocket, state: Arc) { + let (mut sender, mut receiver) = socket.split(); + + let mut event_rx = state.event_bus.subscribe(); + let mut filter: Option = None; + let mut subscribed = false; + + let mut heartbeat = interval(HEARTBEAT_INTERVAL); + heartbeat.reset(); + + loop { + tokio::select! { + _ = heartbeat.tick() => { + let ping_msg = serde_json::to_string(&ServerMessage::Pong).unwrap(); + if sender.send(Message::Text(ping_msg.into())).await.is_err() { + break; + } + } + + event_result = event_rx.recv() => { + match event_result { + Ok(envelope) => { + if subscribed { + let should_send = filter.as_ref() + .map(|f| f.matches(&envelope)) + .unwrap_or(true); + + if should_send { + let msg = ServerMessage::Event { envelope }; + let json = serde_json::to_string(&msg).unwrap(); + if sender.send(Message::Text(json.into())).await.is_err() { + break; + } + } + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!("WebSocket client lagged, missed {} events", n); + } + Err(broadcast::error::RecvError::Closed) => { + break; + } + } + } + + msg = tokio::time::timeout(CLIENT_TIMEOUT + HEARTBEAT_INTERVAL, receiver.next()) => { + match msg { + Ok(Some(Ok(Message::Text(text)))) => { + match serde_json::from_str::(&text) { + Ok(ClientMessage::Subscribe { filter: new_filter }) => { + filter = new_filter.clone(); + subscribed = true; + let response = ServerMessage::Subscribed { filter: new_filter }; + let json = serde_json::to_string(&response).unwrap(); + if sender.send(Message::Text(json.into())).await.is_err() { + break; + } + } + Ok(ClientMessage::Unsubscribe) => { + subscribed = false; + filter = None; + let response = ServerMessage::Unsubscribed; + let json = serde_json::to_string(&response).unwrap(); + if sender.send(Message::Text(json.into())).await.is_err() { + break; + } + } + Ok(ClientMessage::Ping) => { + let response = ServerMessage::Pong; + let json = serde_json::to_string(&response).unwrap(); + if sender.send(Message::Text(json.into())).await.is_err() { + break; + } + } + Err(e) => { + let response = ServerMessage::Error { + message: format!("Invalid message: {}", e), + }; + let json = serde_json::to_string(&response).unwrap(); + let _ = sender.send(Message::Text(json.into())).await; + } + } + } + Ok(Some(Ok(Message::Close(_)))) => { + break; + } + Ok(Some(Ok(Message::Ping(data)))) => { + if sender.send(Message::Pong(data)).await.is_err() { + break; + } + } + Ok(Some(Ok(_))) => {} + Ok(Some(Err(_))) => { + break; + } + Ok(None) => { + break; + } + Err(_) => { + tracing::debug!("WebSocket client timeout, sending ping"); + } + } + } + } + } + + tracing::debug!("WebSocket connection closed"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_ws_state_creation() { + let bus = EventBus::new(); + let state = WsState::new(bus); + assert_eq!(state.event_bus.subscriber_count(), 0); + } + + #[test] + fn test_heartbeat_interval() { + assert_eq!(HEARTBEAT_INTERVAL, Duration::from_secs(30)); + } + + #[test] + fn test_client_timeout() { + assert_eq!(CLIENT_TIMEOUT, Duration::from_secs(10)); + } +} diff --git a/crates/websocket/src/lib.rs b/crates/websocket/src/lib.rs new file mode 100644 index 0000000..d931f26 --- /dev/null +++ b/crates/websocket/src/lib.rs @@ -0,0 +1,5 @@ +mod handler; +mod messages; + +pub use handler::{ws_handler, WsState}; +pub use messages::{ClientMessage, ServerMessage, SubscriptionFilter}; diff --git a/crates/websocket/src/messages.rs b/crates/websocket/src/messages.rs new file mode 100644 index 0000000..6766429 --- /dev/null +++ b/crates/websocket/src/messages.rs @@ -0,0 +1,125 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use events::EventEnvelope; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ClientMessage { + Subscribe { filter: Option }, + Unsubscribe, + Ping, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ServerMessage { + Event { envelope: EventEnvelope }, + Subscribed { filter: Option }, + Unsubscribed, + Pong, + Error { message: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubscriptionFilter { + pub task_ids: Option>, +} + +impl SubscriptionFilter { + pub fn for_task(task_id: Uuid) -> Self { + Self { + task_ids: Some(vec![task_id]), + } + } + + pub fn for_tasks(task_ids: Vec) -> Self { + Self { + task_ids: Some(task_ids), + } + } + + pub fn matches(&self, envelope: &EventEnvelope) -> bool { + match &self.task_ids { + Some(ids) => { + if let Some(event_task_id) = envelope.event.task_id() { + ids.contains(&event_task_id) + } else { + true + } + } + None => true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use events::Event; + + #[test] + fn test_client_message_serialize() { + let msg = ClientMessage::Subscribe { filter: None }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("subscribe")); + } + + #[test] + fn test_client_message_deserialize() { + let json = r#"{"type":"ping"}"#; + let msg: ClientMessage = serde_json::from_str(json).unwrap(); + assert!(matches!(msg, ClientMessage::Ping)); + } + + #[test] + fn test_server_message_serialize() { + let msg = ServerMessage::Pong; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("pong")); + } + + #[test] + fn test_subscription_filter_matches_all() { + let filter = SubscriptionFilter { task_ids: None }; + let task_id = Uuid::new_v4(); + let envelope = EventEnvelope::new(Event::TaskCreated { + task_id, + title: "Test".to_string(), + }); + + assert!(filter.matches(&envelope)); + } + + #[test] + fn test_subscription_filter_matches_specific_task() { + let task_id = Uuid::new_v4(); + let other_task_id = Uuid::new_v4(); + + let filter = SubscriptionFilter::for_task(task_id); + + let matching_envelope = EventEnvelope::new(Event::TaskCreated { + task_id, + title: "Test".to_string(), + }); + + let non_matching_envelope = EventEnvelope::new(Event::TaskCreated { + task_id: other_task_id, + title: "Other".to_string(), + }); + + assert!(filter.matches(&matching_envelope)); + assert!(!filter.matches(&non_matching_envelope)); + } + + #[test] + fn test_subscription_filter_allows_events_without_task_id() { + let filter = SubscriptionFilter::for_task(Uuid::new_v4()); + let envelope = EventEnvelope::new(Event::Error { + message: "test".to_string(), + context: None, + }); + + assert!(filter.matches(&envelope)); + } +} diff --git a/docs/architecture/backend-implementation-plan.md b/docs/architecture/backend-implementation-plan.md new file mode 100644 index 0000000..9f9a6bd --- /dev/null +++ b/docs/architecture/backend-implementation-plan.md @@ -0,0 +1,1102 @@ +# Backend Implementation Plan + +> **Dokument:** Implementační plán pro OpenCode Studio backend +> **Verze:** 1.0 +> **Datum:** 2024-12-30 +> **Status:** Draft + +--- + +## Executive Summary + +Tento dokument definuje fázový plán implementace Rust backendu pro OpenCode Studio. Architektura je navržena s důrazem na: + +- **Škálovatelnost**: Horizontální škálování, paralelní tasky +- **Modularita**: Oddělené crates pro snadnou údržbu a testování +- **Clean code**: Domain-driven design, trait-based abstractions + +--- + +## Technologický Stack + +| Vrstva | Technologie | Verze | +|--------|-------------|-------| +| Runtime | Rust + Tokio | 1.75+ | +| HTTP Server | Axum | 0.8 | +| Database | SQLite + sqlx | 0.8 | +| Serialization | serde + serde_json | 1.0 | +| OpenCode SDK | Generované z OpenAPI | - | +| Type Generation | ts-rs | latest | +| VCS | Jujutsu (jj) | latest | + +--- + +## Crates Architecture + +``` +crates/ +├── core/ # Domain models, traits, events (NO I/O) +├── db/ # SQLite persistence (sqlx) +├── opencode/ # OpenCode HTTP client (generated + wrapper) +├── vcs/ # Version control abstraction (jj, git) +├── orchestrator/ # Task lifecycle, scheduling +├── api/ # Axum HTTP server + WebSocket +└── cli/ # CLI binary (optional, Phase 4) +``` + +### Dependency Graph + +``` + ┌─────────┐ + │ cli │ + └────┬────┘ + │ + ┌────▼────┐ + │ api │ + └────┬────┘ + │ + ┌──────────┼──────────┐ + │ │ │ + ┌────▼────┐ ┌───▼───┐ ┌────▼────┐ + │orchestr.│ │ db │ │opencode │ + └────┬────┘ └───┬───┘ └────┬────┘ + │ │ │ + └──────────┼──────────┘ + │ + ┌────▼────┐ + │ vcs │ + └────┬────┘ + │ + ┌────▼────┐ + │ core │ + └─────────┘ +``` + +--- + +## Phase 1: Foundation (2-3 týdny) + +### Cíl +Základní infrastruktura - projektem strukturovaný workspace, databáze, základní API. + +### 1.1 Workspace Setup + +**Soubory k vytvoření:** + +``` +Cargo.toml # Workspace root +crates/ +├── core/ +│ ├── Cargo.toml +│ └── src/ +│ ├── lib.rs +│ ├── domain/ +│ │ ├── mod.rs +│ │ ├── task.rs # Task entity +│ │ ├── session.rs # Session entity +│ │ └── workspace.rs # Workspace entity +│ ├── events/ +│ │ ├── mod.rs +│ │ └── bus.rs # Event bus trait + impl +│ └── error.rs # Unified error types +│ +├── db/ +│ ├── Cargo.toml +│ ├── migrations/ +│ │ └── 001_initial.sql +│ └── src/ +│ ├── lib.rs +│ ├── pool.rs # Connection pool setup +│ ├── models/ +│ │ ├── mod.rs +│ │ └── task.rs # DB models +│ └── repositories/ +│ ├── mod.rs +│ └── task_repo.rs # TaskRepository impl +│ +└── api/ + ├── Cargo.toml + └── src/ + ├── main.rs + ├── routes/ + │ ├── mod.rs + │ ├── health.rs + │ └── tasks.rs + ├── state.rs # AppState + └── error.rs # HTTP error handling +``` + +**Workspace Cargo.toml:** + +```toml +[workspace] +resolver = "2" +members = [ + "crates/core", + "crates/db", + "crates/api", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.75" + +[workspace.dependencies] +# Async runtime +tokio = { version = "1.0", features = ["full"] } + +# Web framework +axum = { version = "0.8", features = ["macros", "ws"] } +tower-http = { version = "0.5", features = ["cors", "trace"] } + +# Database +sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Error handling +anyhow = "1.0" +thiserror = "2.0" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Utils +uuid = { version = "1.0", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +async-trait = "0.1" + +# Type generation +ts-rs = { version = "10", features = ["uuid-impl", "chrono-impl"] } +``` + +### 1.2 Core Domain Models + +**crates/core/src/domain/task.rs:** + +```rust +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub enum TaskStatus { + Todo, + Planning, + PlanningReview, + InProgress, + AiReview, + Review, + Done, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct Task { + pub id: Uuid, + pub title: String, + pub description: String, + pub status: TaskStatus, + pub roadmap_item_id: Option, + pub workspace_path: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Task { + pub fn new(title: String, description: String) -> Self { + let now = Utc::now(); + Self { + id: Uuid::new_v4(), + title, + description, + status: TaskStatus::Todo, + roadmap_item_id: None, + workspace_path: None, + created_at: now, + updated_at: now, + } + } +} +``` + +### 1.3 Database Schema + +**crates/db/migrations/001_initial.sql:** + +```sql +-- Tasks table +CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'Todo', + roadmap_item_id TEXT, + workspace_path TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX idx_tasks_status ON tasks(status); +CREATE INDEX idx_tasks_created_at ON tasks(created_at); + +-- Sessions table (OpenCode sessions) +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(id), + opencode_session_id TEXT, + phase TEXT NOT NULL, -- 'planning', 'implementation', 'review' + status TEXT NOT NULL DEFAULT 'pending', + started_at INTEGER, + completed_at INTEGER, + created_at INTEGER NOT NULL +); + +CREATE INDEX idx_sessions_task_id ON sessions(task_id); + +-- Events log +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + payload TEXT NOT NULL, -- JSON + created_at INTEGER NOT NULL +); + +CREATE INDEX idx_events_type ON events(event_type); +CREATE INDEX idx_events_created_at ON events(created_at); +``` + +### 1.4 Basic API Endpoints + +**Phase 1 endpoints:** + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/health` | Health check | +| GET | `/api/tasks` | List all tasks | +| POST | `/api/tasks` | Create task | +| GET | `/api/tasks/:id` | Get task detail | +| PATCH | `/api/tasks/:id` | Update task | +| DELETE | `/api/tasks/:id` | Delete task | + +### 1.5 Deliverables + +- [ ] Workspace setup s 3 crates (core, db, api) +- [ ] Domain models (Task, Session, TaskStatus) +- [ ] SQLite database s migrací +- [ ] Basic CRUD API pro tasks +- [ ] Health endpoint +- [ ] Tracing/logging setup +- [ ] `cargo test` passing + +### 1.6 Acceptance Criteria + +```bash +# Server starts +cargo run --package api + +# Health check works +curl http://localhost:3001/health +# => {"status": "ok", "version": "0.1.0"} + +# CRUD works +curl -X POST http://localhost:3001/api/tasks \ + -H "Content-Type: application/json" \ + -d '{"title": "Test", "description": "Test task"}' +# => {"id": "uuid...", "status": "Todo", ...} +``` + +--- + +## Phase 2: OpenCode Integration (2-3 týdny) + +### Cíl +Integrace s OpenCode HTTP Server API, SDK generování, session management. + +### 2.1 Nové Crates + +``` +crates/ +├── opencode/ +│ ├── Cargo.toml +│ └── src/ +│ ├── lib.rs +│ ├── client.rs # HTTP client wrapper +│ ├── types.rs # Generated types (nebo z OpenAPI) +│ ├── session.rs # Session management +│ └── events.rs # SSE event handling +│ +└── orchestrator/ + ├── Cargo.toml + └── src/ + ├── lib.rs + ├── executor.rs # Task execution logic + ├── scheduler.rs # Parallel task scheduling + └── state_machine.rs # Task status transitions +``` + +### 2.2 OpenCode SDK Generation + +**Postup:** + +```bash +# 1. Spustit OpenCode server +opencode serve --port 4096 + +# 2. Stáhnout OpenAPI spec +curl http://localhost:4096/doc -o opencode-api.json + +# 3. Generovat typy (možnosti): +# A) openapi-generator +openapi-generator generate -i opencode-api.json -g rust -o crates/opencode/src/generated + +# B) progenitor (compile-time) +# V Cargo.toml: progenitor = "0.8" +``` + +**crates/opencode/src/client.rs (wrapper):** + +```rust +use reqwest::Client; +use crate::types::*; + +pub struct OpenCodeClient { + base_url: String, + client: Client, +} + +impl OpenCodeClient { + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into(), + client: Client::new(), + } + } + + pub async fn create_session(&self, title: Option) -> Result { + let resp = self.client + .post(format!("{}/session", self.base_url)) + .json(&CreateSessionRequest { title, parent_id: None }) + .send() + .await?; + + resp.json().await.map_err(Into::into) + } + + pub async fn send_prompt( + &self, + session_id: &str, + prompt: &str, + model: Option<&str>, + ) -> Result { + let resp = self.client + .post(format!("{}/session/{}/message", self.base_url, session_id)) + .json(&SendMessageRequest { + parts: vec![Part::Text { text: prompt.into() }], + model: model.map(Into::into), + ..Default::default() + }) + .send() + .await?; + + resp.json().await.map_err(Into::into) + } + + pub async fn send_prompt_async(&self, session_id: &str, prompt: &str) -> Result<(), Error> { + self.client + .post(format!("{}/session/{}/prompt_async", self.base_url, session_id)) + .json(&SendMessageRequest { + parts: vec![Part::Text { text: prompt.into() }], + ..Default::default() + }) + .send() + .await?; + + Ok(()) + } + + pub async fn abort_session(&self, session_id: &str) -> Result<(), Error> { + self.client + .post(format!("{}/session/{}/abort", self.base_url, session_id)) + .send() + .await?; + + Ok(()) + } + + pub fn subscribe_events(&self) -> Result { + // SSE connection to /event + todo!() + } +} +``` + +### 2.3 SSE Event Handling + +```rust +// crates/opencode/src/events.rs +use eventsource_client as sse; +use futures::StreamExt; + +pub struct EventStream { + stream: sse::Client, +} + +impl EventStream { + pub async fn connect(base_url: &str) -> Result { + let client = sse::ClientBuilder::for_url(&format!("{}/event", base_url))? + .build(); + Ok(Self { stream: client }) + } + + pub async fn next_event(&mut self) -> Option { + self.stream.next().await.and_then(|result| { + result.ok().and_then(|event| { + serde_json::from_str(&event.data).ok() + }) + }) + } +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub enum OpenCodeEvent { + #[serde(rename = "session.message")] + SessionMessage { session_id: String, content: String }, + + #[serde(rename = "session.completed")] + SessionCompleted { session_id: String }, + + #[serde(rename = "task.status_changed")] + TaskStatusChanged { task_id: String, status: String }, + + // ... další eventy +} +``` + +### 2.4 Task Orchestrator + +**crates/orchestrator/src/executor.rs:** + +```rust +use crate::state_machine::TaskStateMachine; +use core::domain::{Task, TaskStatus}; +use opencode::OpenCodeClient; + +pub struct TaskExecutor { + opencode: OpenCodeClient, + state_machine: TaskStateMachine, +} + +impl TaskExecutor { + pub async fn execute_phase(&self, task: &mut Task) -> Result<(), Error> { + match task.status { + TaskStatus::Todo => { + self.transition_to_planning(task).await?; + } + TaskStatus::Planning => { + self.run_planning_session(task).await?; + } + TaskStatus::InProgress => { + self.run_implementation_session(task).await?; + } + TaskStatus::AiReview => { + self.run_review_session(task).await?; + } + _ => {} + } + Ok(()) + } + + async fn run_planning_session(&self, task: &mut Task) -> Result<(), Error> { + // 1. Create OpenCode session + let session = self.opencode.create_session(Some(task.title.clone())).await?; + + // 2. Send planning prompt + let prompt = format!( + "Analyze this task and create a technical implementation plan:\n\n\ + Title: {}\n\ + Description: {}\n\n\ + Output the plan to: .opencode-studio/kanban/plans/{}.md", + task.title, task.description, task.id + ); + + self.opencode.send_prompt(&session.id, &prompt, None).await?; + + // 3. Transition to next state + self.state_machine.transition(task, TaskStatus::PlanningReview)?; + + Ok(()) + } +} +``` + +### 2.5 API Extensions + +**Nové endpoints:** + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/tasks/:id/transition` | Změna stavu tasku | +| POST | `/api/tasks/:id/execute` | Spustit aktuální fázi | +| GET | `/api/sessions` | List OpenCode sessions | +| GET | `/api/sessions/:id` | Session detail | +| GET | `/api/sessions/:id/messages` | Session messages | + +### 2.6 Deliverables + +- [ ] OpenCode SDK (generované typy nebo ruční wrapper) +- [ ] SSE event stream handling +- [ ] Task executor s phase logic +- [ ] State machine pro task transitions +- [ ] Session tracking v DB +- [ ] API endpoints pro sessions + +### 2.7 Acceptance Criteria + +```bash +# Task transition works +curl -X POST http://localhost:3001/api/tasks/{id}/transition \ + -H "Content-Type: application/json" \ + -d '{"status": "Planning"}' + +# Execute phase triggers OpenCode +curl -X POST http://localhost:3001/api/tasks/{id}/execute +# => Creates OpenCode session, sends prompt +``` + +--- + +## Phase 3: VCS & Workspace Management (2 týdny) + +### Cíl +Integrace s Jujutsu/Git, workspace lifecycle, init/cleanup scripty. + +### 3.1 VCS Crate + +``` +crates/ +└── vcs/ + ├── Cargo.toml + └── src/ + ├── lib.rs + ├── traits.rs # VersionControl trait + ├── jj.rs # Jujutsu implementation + ├── git.rs # Git fallback + └── workspace.rs # Workspace management +``` + +**crates/vcs/src/traits.rs:** + +```rust +use async_trait::async_trait; +use std::path::PathBuf; + +#[async_trait] +pub trait VersionControl: Send + Sync { + /// Create isolated workspace for a task + async fn create_workspace(&self, task_id: &str) -> Result; + + /// Get diff of changes in workspace + async fn get_diff(&self, workspace: &Workspace) -> Result; + + /// Merge workspace changes back to main + async fn merge_workspace(&self, workspace: &Workspace) -> Result; + + /// Clean up workspace + async fn cleanup_workspace(&self, workspace: &Workspace) -> Result<(), Error>; + + /// List all active workspaces + async fn list_workspaces(&self) -> Result, Error>; + + /// Get conflicts in workspace + async fn get_conflicts(&self, workspace: &Workspace) -> Result, Error>; +} + +pub struct Workspace { + pub task_id: String, + pub path: PathBuf, + pub branch_name: String, + pub created_at: DateTime, +} + +pub enum MergeResult { + Success, + Conflicts(Vec), +} +``` + +**crates/vcs/src/jj.rs:** + +```rust +use tokio::process::Command; + +pub struct JujutsuVcs { + repo_path: PathBuf, + workspace_base: PathBuf, +} + +#[async_trait] +impl VersionControl for JujutsuVcs { + async fn create_workspace(&self, task_id: &str) -> Result { + let workspace_path = self.workspace_base.join(format!("task-{}", task_id)); + + // jj new main -m "task-{id}: Start implementation" + Command::new("jj") + .args(["new", "main", "-m", &format!("task-{}: Start", task_id)]) + .current_dir(&self.repo_path) + .output() + .await?; + + // jj workspace add --revision @ + Command::new("jj") + .args([ + "workspace", "add", + workspace_path.to_str().unwrap(), + "--revision", "@" + ]) + .current_dir(&self.repo_path) + .output() + .await?; + + Ok(Workspace { + task_id: task_id.into(), + path: workspace_path, + branch_name: format!("task-{}", task_id), + created_at: Utc::now(), + }) + } + + async fn get_diff(&self, workspace: &Workspace) -> Result { + let output = Command::new("jj") + .args(["diff"]) + .current_dir(&workspace.path) + .output() + .await?; + + Ok(String::from_utf8_lossy(&output.stdout).into()) + } + + async fn cleanup_workspace(&self, workspace: &Workspace) -> Result<(), Error> { + // jj workspace forget + Command::new("jj") + .args(["workspace", "forget", &workspace.task_id]) + .current_dir(&self.repo_path) + .output() + .await?; + + // Remove directory + tokio::fs::remove_dir_all(&workspace.path).await?; + + Ok(()) + } +} +``` + +### 3.2 Workspace Lifecycle + +```rust +// crates/orchestrator/src/workspace_manager.rs + +pub struct WorkspaceManager { + vcs: Arc, + config: WorkspaceConfig, +} + +impl WorkspaceManager { + pub async fn setup_workspace(&self, task: &Task) -> Result { + // 1. Create VCS workspace + let workspace = self.vcs.create_workspace(&task.id.to_string()).await?; + + // 2. Run init scripts + self.run_init_scripts(&workspace).await?; + + // 3. Copy/symlink files + self.setup_files(&workspace).await?; + + Ok(workspace) + } + + async fn run_init_scripts(&self, workspace: &Workspace) -> Result<(), Error> { + for script in &self.config.init_scripts { + Command::new("bash") + .args([script, workspace.path.to_str().unwrap()]) + .output() + .await?; + } + Ok(()) + } + + pub async fn cleanup_workspace(&self, workspace: &Workspace) -> Result<(), Error> { + // 1. Run cleanup scripts + for script in &self.config.cleanup_scripts { + Command::new("bash") + .args([script, workspace.path.to_str().unwrap()]) + .output() + .await?; + } + + // 2. Cleanup VCS + self.vcs.cleanup_workspace(workspace).await?; + + Ok(()) + } +} +``` + +### 3.3 Deliverables + +- [ ] VCS trait + Jujutsu implementation +- [ ] Git fallback implementation +- [ ] Workspace manager +- [ ] Init/cleanup script runner +- [ ] API endpoints pro workspaces + +### 3.4 Acceptance Criteria + +```bash +# Create workspace for task +curl -X POST http://localhost:3001/api/tasks/{id}/workspace + +# List workspaces +curl http://localhost:3001/api/workspaces + +# Get diff +curl http://localhost:3001/api/workspaces/{id}/diff +``` + +--- + +## Phase 4: WebSocket & Real-time (1-2 týdny) + +### Cíl +Real-time updates pro frontend, WebSocket integration. + +### 4.1 WebSocket Handler + +```rust +// crates/api/src/websocket/mod.rs + +use axum::extract::ws::{WebSocket, WebSocketUpgrade}; +use tokio::sync::broadcast; + +pub fn router(state: AppState) -> Router { + Router::new() + .route("/ws", get(websocket_handler)) +} + +async fn websocket_handler( + ws: WebSocketUpgrade, + State(state): State, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_socket(socket, state)) +} + +async fn handle_socket(mut socket: WebSocket, state: AppState) { + let mut rx = state.event_bus.subscribe(); + + loop { + tokio::select! { + // Broadcast events to client + Ok(event) = rx.recv() => { + let json = serde_json::to_string(&event).unwrap(); + if socket.send(Message::Text(json)).await.is_err() { + break; + } + } + + // Handle client messages + Some(Ok(msg)) = socket.recv() => { + // Handle ping/pong, commands + } + } + } +} +``` + +### 4.2 Event Types + +```rust +// crates/core/src/events/mod.rs + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum Event { + // Task events + #[serde(rename = "task.created")] + TaskCreated { task: Task }, + + #[serde(rename = "task.status_changed")] + TaskStatusChanged { task_id: Uuid, from: TaskStatus, to: TaskStatus }, + + #[serde(rename = "task.completed")] + TaskCompleted { task_id: Uuid }, + + // Session events + #[serde(rename = "session.started")] + SessionStarted { session_id: String, task_id: Uuid }, + + #[serde(rename = "session.message")] + SessionMessage { session_id: String, content: String }, + + #[serde(rename = "session.completed")] + SessionCompleted { session_id: String }, + + // Workspace events + #[serde(rename = "workspace.created")] + WorkspaceCreated { task_id: Uuid, path: String }, + + #[serde(rename = "workspace.deleted")] + WorkspaceDeleted { task_id: Uuid }, +} +``` + +### 4.3 Deliverables + +- [ ] WebSocket handler v Axum +- [ ] Event bus (tokio::sync::broadcast) +- [ ] Event types definované +- [ ] Bridge mezi OpenCode SSE a naším WS + +--- + +## Phase 5: Full Kanban Flow (2-3 týdny) + +### Cíl +Kompletní TODO → DONE flow s AI planning, implementation, review. + +### 5.1 Phase Prompts + +```rust +// crates/orchestrator/src/prompts.rs + +pub struct PhasePrompts; + +impl PhasePrompts { + pub fn planning(task: &Task) -> String { + format!(r#" +You are analyzing a development task. Create a detailed implementation plan. + +## Task +**Title:** {title} +**Description:** {description} + +## Required Output +Save your analysis to: `.opencode-studio/kanban/plans/{id}.md` + +The plan should include: +1. Technical analysis +2. Files to modify/create +3. Step-by-step implementation steps +4. Potential risks +5. Estimated complexity (S/M/L/XL) + +Do NOT implement anything yet. Only create the plan. +"#, title = task.title, description = task.description, id = task.id) + } + + pub fn implementation(task: &Task, plan_path: &str) -> String { + format!(r#" +Implement the following task according to the plan. + +## Task +**Title:** {title} +**Plan:** Read from `{plan_path}` + +## Instructions +1. Read the plan carefully +2. Implement each step +3. Write tests if applicable +4. Commit your changes + +Start implementation now. +"#, title = task.title, plan_path = plan_path) + } + + pub fn review(task: &Task, diff: &str) -> String { + format!(r#" +Review the following code changes for task: {title} + +## Diff +``` +{diff} +``` + +## Review Criteria +1. Code quality and style +2. Correctness - does it solve the task? +3. Tests - are they adequate? +4. Security concerns +5. Breaking changes + +## Output +Save your review to: `.opencode-studio/kanban/reviews/{id}.md` + +If approved, respond with: APPROVED +If changes needed, respond with: CHANGES_REQUESTED and explain what needs fixing. +"#, title = task.title, diff = diff, id = task.id) + } +} +``` + +### 5.2 Full Flow Implementation + +```rust +// crates/orchestrator/src/executor.rs + +impl TaskExecutor { + pub async fn run_full_cycle(&self, task: &mut Task) -> Result<(), Error> { + // TODO -> PLANNING + self.transition(task, TaskStatus::Planning).await?; + self.run_planning_session(task).await?; + + // PLANNING -> PLANNING_REVIEW (optional approval) + if self.config.require_plan_approval { + self.transition(task, TaskStatus::PlanningReview).await?; + // Wait for human approval... + return Ok(()); + } + + // -> IN_PROGRESS + self.transition(task, TaskStatus::InProgress).await?; + self.setup_workspace(task).await?; + self.run_implementation_session(task).await?; + + // -> AI_REVIEW + self.transition(task, TaskStatus::AiReview).await?; + let review_result = self.run_ai_review(task).await?; + + match review_result { + ReviewResult::Approved => { + self.transition(task, TaskStatus::Review).await?; + } + ReviewResult::ChangesRequested(feedback) => { + // Loop back to implementation + self.transition(task, TaskStatus::InProgress).await?; + self.run_implementation_with_feedback(task, &feedback).await?; + } + } + + Ok(()) + } +} +``` + +### 5.3 Deliverables + +- [ ] Planning phase implementation +- [ ] Implementation phase +- [ ] AI Review phase +- [ ] Human Review support +- [ ] Retry logic pro failed reviews +- [ ] Plan/Review file management + +--- + +## Phase 6: GitHub Integration (2 týdny) + +### Cíl +PR creation, CI status, issue sync. + +### 6.1 GitHub Crate + +``` +crates/ +└── github/ + ├── Cargo.toml + └── src/ + ├── lib.rs + ├── client.rs # octocrab wrapper + ├── pr.rs # PR operations + └── issues.rs # Issue sync +``` + +### 6.2 Deliverables + +- [ ] GitHub client (octocrab) +- [ ] Auto PR creation +- [ ] CI status polling +- [ ] Issue import + +--- + +## Phase 7: Frontend Integration (1-2 týdny) + +### Cíl +TypeScript types generování, frontend připojení na Rust API. + +### 7.1 Type Generation + +```rust +// crates/api/src/bin/generate_types.rs + +use ts_rs::TS; + +fn main() { + // Generate TypeScript types + Task::export_all().unwrap(); + TaskStatus::export_all().unwrap(); + Session::export_all().unwrap(); + Event::export_all().unwrap(); + // ... +} +``` + +```bash +# Generate types +cargo run --bin generate-types + +# Output: frontend/src/types/generated.ts +``` + +### 7.2 Deliverables + +- [ ] ts-rs setup pro všechny typy +- [ ] Generated types v frontend/ +- [ ] React Query hooks pro API calls +- [ ] WebSocket hook pro real-time + +--- + +## Timeline Summary + +| Phase | Trvání | Závislosti | +|-------|--------|------------| +| **Phase 1: Foundation** | 2-3 týdny | - | +| **Phase 2: OpenCode** | 2-3 týdny | Phase 1 | +| **Phase 3: VCS** | 2 týdny | Phase 1 | +| **Phase 4: WebSocket** | 1-2 týdny | Phase 1, 2 | +| **Phase 5: Full Kanban** | 2-3 týdny | Phase 2, 3, 4 | +| **Phase 6: GitHub** | 2 týdny | Phase 5 | +| **Phase 7: Frontend** | 1-2 týdny | Phase 1-5 | + +**Celkem: 12-17 týdnů** (s paralelizací některých fází možné zkrátit na 10-12) + +--- + +## Risk Mitigation + +| Risk | Pravděpodobnost | Dopad | Mitigace | +|------|-----------------|-------|----------| +| OpenCode API změny | Střední | Vysoký | Abstrakce, fallback na ACP | +| Jujutsu learning curve | Nízká | Střední | Git fallback připraven | +| SQLite scaling limits | Nízká | Střední | Architektura ready pro PostgreSQL | +| Frontend/Backend type drift | Střední | Střední | ts-rs automatické generování | + +--- + +## Next Steps + +1. **Založit crates strukturu** - Cargo workspace setup +2. **Core domain models** - Task, Session entities +3. **Basic API** - CRUD endpoints +4. **OpenCode SDK** - Stáhnout spec, generovat typy + +--- + +*Dokument bude aktualizován po každé dokončené fázi.* diff --git a/product-prd.md b/product-prd.md index 4740299..e2eac56 100644 --- a/product-prd.md +++ b/product-prd.md @@ -620,6 +620,112 @@ opencode-studio/ └── {module}_{timestamp}.log ``` +### 9.5 OpenCode Integration Strategy + +> **Rozhodnutí (2024-12-30):** Po analýze škálovatelnosti a porovnání s vibe-kanban implementací volíme **HTTP Server API** přístup místo ACP (Agent Client Protocol). + +#### 9.5.1 Přístupy k integraci + +| Přístup | Popis | Výhody | Nevýhody | +|---------|-------|--------|----------| +| **ACP (subprocess)** | `npx opencode-ai acp` | Přímá kontrola, offline | Každý task = nový Node.js proces (~100MB RAM) | +| **HTTP Server API** ✅ | `opencode serve` + REST/SSE | Stateless, škálovatelné, SDK z OpenAPI | Vyžaduje běžící server | + +#### 9.5.2 Proč HTTP Server API + +1. **Horizontální škálování**: REST je stateless, jeden OpenCode server zvládne více sessions +2. **Resource efficiency**: Jeden server proces vs N Node.js procesů pro N tasků +3. **SDK generování**: OpenCode poskytuje OpenAPI 3.1 spec na `/doc` endpoint +4. **Distributed deployment**: OpenCode server může běžet na remote machine +5. **Paralelní tasky**: PRD specifikuje `parallel_tasks_limit = 5` - HTTP to zvládne efektivněji + +#### 9.5.3 OpenCode Server API + +OpenCode server (`opencode serve --port 4096`) poskytuje: + +``` +# Sessions +POST /session # Vytvořit session +GET /session/:id # Detail session +POST /session/:id/message # Poslat zprávu (sync) +POST /session/:id/prompt_async # Poslat zprávu (async) +POST /session/:id/abort # Přerušit session +GET /session/:id/diff # Získat diff změn + +# Real-time +GET /event # SSE stream všech eventů +GET /global/event # Globální eventy + +# Files & VCS +GET /file?path= # List souborů +GET /vcs # VCS info +``` + +#### 9.5.4 Rust SDK generování + +```bash +# OpenCode poskytuje OpenAPI 3.1 spec +curl http://localhost:4096/doc > opencode-api.json + +# Generování Rust klienta +cargo install openapi-generator-cli +openapi-generator generate -i opencode-api.json -g rust -o crates/opencode-sdk +``` + +Alternativně použít `progenitor` crate pro compile-time generování. + +#### 9.5.5 Architektura integrace + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ OpenCode Studio Backend │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ crates/opencode/ │ │ +│ │ │ │ +│ │ ┌─────────────────┐ ┌─────────────────┐ │ │ +│ │ │ OpenCodeClient │ │ SessionManager │ │ │ +│ │ │ (generated SDK)│ │ (state tracking)│ │ │ +│ │ └────────┬────────┘ └────────┬────────┘ │ │ +│ │ │ │ │ │ +│ │ ▼ ▼ │ │ +│ │ ┌─────────────────────────────────────────┐ │ │ +│ │ │ EventStream (SSE) │ │ │ +│ │ │ - session.message │ │ │ +│ │ │ - task.status_changed │ │ │ +│ │ │ - workspace.created │ │ │ +│ │ └─────────────────────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ OpenCode Server │ │ +│ │ (standalone process) │ │ +│ │ opencode serve --port 4096 │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### 9.5.6 Fallback strategie + +Pro případ, kdy HTTP server není dostupný, zachováváme možnost ACP fallbacku: + +```rust +#[async_trait] +pub trait AgentExecutor: Send + Sync { + async fn create_session(&self, config: SessionConfig) -> Result; + async fn send_prompt(&self, session_id: &str, prompt: &str) -> Result<()>; + async fn subscribe_events(&self) -> Result; + async fn abort_session(&self, session_id: &str) -> Result<()>; +} + +// Implementace +pub struct HttpAgentExecutor { /* ... */ } // Primární +pub struct AcpAgentExecutor { /* ... */ } // Fallback (future) +``` + --- ## 10. Module System diff --git a/studio.db b/studio.db new file mode 100644 index 0000000..2a30a58 Binary files /dev/null and b/studio.db differ diff --git a/studio.db-shm b/studio.db-shm new file mode 100644 index 0000000..fe9ac28 Binary files /dev/null and b/studio.db-shm differ diff --git a/studio.db-wal b/studio.db-wal new file mode 100644 index 0000000..e69de29