From e08839efca994eb6e758507fd5ef69d489d0d388 Mon Sep 17 00:00:00 2001 From: Pavel Soukup Date: Tue, 30 Dec 2025 14:13:34 +0100 Subject: [PATCH] Initial project structure and core crates Add initial Rust workspace with core crates: core domain logic, database layer, event bus, orchestrator, VCS integration, websocket handler, and server with basic routes. Includes CI workflow, documentation, and initial dependencies. Sets up project foundation for further backend development. --- .github/workflows/ci.yml | 74 + Cargo.lock | 3258 +++++++++++++++-- Cargo.toml | 46 +- agents.md | 360 ++ crates/core/Cargo.toml | 14 + crates/core/src/domain/mod.rs | 5 + crates/core/src/domain/session.rs | 154 + crates/core/src/domain/task.rs | 132 + crates/core/src/error.rs | 29 + crates/core/src/lib.rs | 5 + crates/db/Cargo.toml | 16 + crates/db/migrations/001_initial.sql | 37 + crates/db/src/error.rs | 17 + crates/db/src/lib.rs | 8 + crates/db/src/models/mod.rs | 5 + crates/db/src/models/session.rs | 53 + crates/db/src/models/task.rs | 53 + crates/db/src/pool.rs | 37 + crates/db/src/repositories/mod.rs | 5 + .../db/src/repositories/session_repository.rs | 265 ++ crates/db/src/repositories/task_repository.rs | 199 + crates/events/Cargo.toml | 15 + crates/events/src/bus.rs | 180 + crates/events/src/lib.rs | 10 + crates/events/src/types.rs | 231 ++ crates/opencode/Cargo.toml | 22 + crates/opencode/src/client.rs | 186 + crates/opencode/src/error.rs | 24 + crates/opencode/src/events.rs | 108 + crates/opencode/src/lib.rs | 9 + crates/opencode/src/types.rs | 75 + crates/orchestrator/Cargo.toml | 21 + crates/orchestrator/src/error.rs | 24 + crates/orchestrator/src/executor.rs | 200 + crates/orchestrator/src/lib.rs | 8 + crates/orchestrator/src/prompts.rs | 146 + crates/orchestrator/src/state_machine.rs | 118 + crates/server/Cargo.toml | 18 +- crates/server/src/error.rs | 94 + crates/server/src/main.rs | 80 +- crates/server/src/routes/health.rs | 15 + crates/server/src/routes/mod.rs | 11 + crates/server/src/routes/sessions.rs | 46 + crates/server/src/routes/tasks.rs | 178 + crates/server/src/routes/workspaces.rs | 164 + crates/server/src/routes/ws.rs | 17 + crates/server/src/state.rs | 57 + crates/vcs/Cargo.toml | 19 + crates/vcs/src/error.rs | 33 + crates/vcs/src/git.rs | 325 ++ crates/vcs/src/jj.rs | 290 ++ crates/vcs/src/lib.rs | 11 + crates/vcs/src/traits.rs | 171 + crates/vcs/src/workspace.rs | 241 ++ crates/websocket/Cargo.toml | 18 + crates/websocket/src/handler.rs | 165 + crates/websocket/src/lib.rs | 5 + crates/websocket/src/messages.rs | 125 + .../backend-implementation-plan.md | 1102 ++++++ product-prd.md | 106 + studio.db | Bin 0 -> 61440 bytes studio.db-shm | Bin 0 -> 32768 bytes studio.db-wal | 0 63 files changed, 9081 insertions(+), 359 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 agents.md create mode 100644 crates/core/Cargo.toml create mode 100644 crates/core/src/domain/mod.rs create mode 100644 crates/core/src/domain/session.rs create mode 100644 crates/core/src/domain/task.rs create mode 100644 crates/core/src/error.rs create mode 100644 crates/core/src/lib.rs create mode 100644 crates/db/Cargo.toml create mode 100644 crates/db/migrations/001_initial.sql create mode 100644 crates/db/src/error.rs create mode 100644 crates/db/src/lib.rs create mode 100644 crates/db/src/models/mod.rs create mode 100644 crates/db/src/models/session.rs create mode 100644 crates/db/src/models/task.rs create mode 100644 crates/db/src/pool.rs create mode 100644 crates/db/src/repositories/mod.rs create mode 100644 crates/db/src/repositories/session_repository.rs create mode 100644 crates/db/src/repositories/task_repository.rs create mode 100644 crates/events/Cargo.toml create mode 100644 crates/events/src/bus.rs create mode 100644 crates/events/src/lib.rs create mode 100644 crates/events/src/types.rs create mode 100644 crates/opencode/Cargo.toml create mode 100644 crates/opencode/src/client.rs create mode 100644 crates/opencode/src/error.rs create mode 100644 crates/opencode/src/events.rs create mode 100644 crates/opencode/src/lib.rs create mode 100644 crates/opencode/src/types.rs create mode 100644 crates/orchestrator/Cargo.toml create mode 100644 crates/orchestrator/src/error.rs create mode 100644 crates/orchestrator/src/executor.rs create mode 100644 crates/orchestrator/src/lib.rs create mode 100644 crates/orchestrator/src/prompts.rs create mode 100644 crates/orchestrator/src/state_machine.rs create mode 100644 crates/server/src/error.rs create mode 100644 crates/server/src/routes/health.rs create mode 100644 crates/server/src/routes/mod.rs create mode 100644 crates/server/src/routes/sessions.rs create mode 100644 crates/server/src/routes/tasks.rs create mode 100644 crates/server/src/routes/workspaces.rs create mode 100644 crates/server/src/routes/ws.rs create mode 100644 crates/server/src/state.rs create mode 100644 crates/vcs/Cargo.toml create mode 100644 crates/vcs/src/error.rs create mode 100644 crates/vcs/src/git.rs create mode 100644 crates/vcs/src/jj.rs create mode 100644 crates/vcs/src/lib.rs create mode 100644 crates/vcs/src/traits.rs create mode 100644 crates/vcs/src/workspace.rs create mode 100644 crates/websocket/Cargo.toml create mode 100644 crates/websocket/src/handler.rs create mode 100644 crates/websocket/src/lib.rs create mode 100644 crates/websocket/src/messages.rs create mode 100644 docs/architecture/backend-implementation-plan.md create mode 100644 studio.db create mode 100644 studio.db-shm create mode 100644 studio.db-wal 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 0000000000000000000000000000000000000000..2a30a58ef94f5179095983849ccb703735b3e9e7 GIT binary patch literal 61440 zcmeI*Pi)&%9KdnAX_F>v8$g;&nJNsIHQF+ordzj3{7Ex!Dx^u*Ca^Jq=f&P?E&mof zqXTiE>Wv%PnLnpVh>atEj))^d;x=)E!>$NUJ8+UBNi-Lx96qgUIO;aa9H9{hr2eof1jHFddoM!TTy*-$MN)u8e0&eo~*$*6K< zTD~#(1n#Yt6||n(FSOTWS5UvT?iHq1Cq&RA@JHSrUc?pQZER--z&I@yh0 z&Mg%*b)fBR!>C(F#g?g$vIktQJdRt`rJEZOgb-YZ~TWRXdcurwX@C zq%I|*N_Ix>^l%-^aqMQp(H+-tJ5IQI;1S}mrrXeC4vS`kZBy^Yqss9a`PQSsUBkJq z+h({yV*lvfmVM#7WvQ*Rv6wP5BX8?L8oRq5Mjh{C4@-C8ea2ntfn@lEeaPyXc2--{ zig|5a^%^*{wwdzw>5Nv;#GuaS*7LbDS`eYx5~HYTdIQS06dm>R(#_O{;aGd+DM0J5 z5%JIFDuuFoSj05##zv?ANRVH0c> z`1s4weFAq0dqIQs)YhR$Oi>hh`=CEg{1E=bXx|9&iyk{XY`3;&`b^8Iw(XWH4xauT z_A(|8r~aCq?7B^}*-dR*9L9B{rHeCCy?^Aq*KA*RT1M5t-O}%bkYa3p-#W|yYeWZ_fu(m6G z(`tJs9Cc~=oH!HiJ_`o>{uo!gA(C9Hi5D8reR)M&FXvWPdtPS>sPw&S% zh@oc&OCsXsb=-T2ofNa2{8AEMOb8%=00IagfB*srAb{?~_Uc!cUw`_KS6;j~{>A3AzukL#?3tq{ z-g}EUKmY**5I_I{1Q0*~ z0R)CnAQFkkgZKaMO3AxJ*g?9400IagfB*srAb`_P+%_y6xp$@@Xlx0tg_000IagfB*srAb`OCTi|e{68|^9BM7elA4=j+ z^f4iT00IagfB*srAb