diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index d28c98e..1b950a1 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.98-trixie +FROM rust:1.99-trixie ARG USERNAME=dev ARG USER_UID=1000 @@ -11,10 +11,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ git \ build-essential \ - libssl-dev \ cmake \ pkg-config \ clang \ + postgresql \ && rm -rf /var/lib/apt/lists/* RUN groupadd --gid ${USER_GID:-1000} $USERNAME \ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index dff506e..2dbce3e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -19,6 +19,9 @@ }, "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/steep,type=bind", "workspaceFolder": "/workspaces/steep", + "mounts": [ + "source=/run/user/1000/podman/podman.sock,target=/var/run/docker.sock,type=bind" + ], "runArgs": ["--userns=keep-id", "--security-opt", "label=disable"], "appPort": [3000] } diff --git a/.env.example b/.env.example index 9f25ae2..1cf4a5e 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,5 @@ METRICS_BIND_ADDR= # Sandboxed tool execution # DOCKER_HOST= SANDBOX_MAX_ITERATIONS=8 + +DATABASE_URL= diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml index 812b4d8..4dcbf08 100644 --- a/.woodpecker/release.yml +++ b/.woodpecker/release.yml @@ -10,8 +10,9 @@ steps: - /data/woodpecker-builds:/data commands: - echo $DOCKER_PASSWORD | buildah login docker.io -u $DOCKER_USERNAME --password-stdin - - chmod +x scripts/build.sh + - chmod +x scripts/build.sh scripts/build-website.sh - bash scripts/build.sh + - bash scripts/build-website.sh environment: DOCKER_USERNAME: from_secret: docker_username diff --git a/.woodpecker/tests.yml b/.woodpecker/tests.yml index ec1e054..044829e 100644 --- a/.woodpecker/tests.yml +++ b/.woodpecker/tests.yml @@ -2,23 +2,45 @@ when: event: - push +services: + - name: postgres + image: postgres:18 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: steep + steps: - name: fmt - image: rust:1.98 + image: rust:1.99 commands: - rustup component add rustfmt - cargo fmt --all -- --check - name: clippy - image: rust:1.98 + image: rust:1.99 commands: - rustup component add clippy - cargo clippy --workspace --all-targets --all-features -- -D warnings - - name: test - image: rust:1.98 + - name: deny + image: rust:1.99 commands: - - cargo test --workspace --all-targets + - cargo install cargo-deny --locked --version 0.18.9 + - cargo deny check + + - name: wait-for-postgres + image: postgres:18 + commands: + - until pg_isready -h postgres -U postgres; do sleep 1; done + + - name: test + image: rust:1.99 + environment: + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/steep + commands: + - bash scripts/migrate-apply.sh + - cargo test --workspace --all-targets -- --include-ignored - name: container-build image: quay.io/buildah/stable diff --git a/Cargo.lock b/Cargo.lock index cfc72d7..c0d9d6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,11 +4,11 @@ version = 4 [[package]] name = "actix-codec" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31404e1443b7b7bcaa311c1af456775cc3f668e34573f1503d7ce4844327629e" +checksum = "4c13df95297bcf9014dc89162b0cc69431e192e34e3b419612fc124cfcd45dbf" dependencies = [ - "bitflags", + "bitflags 2.13.2", "bytes", "futures-core", "futures-sink", @@ -21,15 +21,14 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.13.3" +version = "3.18.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11004b0e9b44b4eb3d15e0c3132b96fb178c7e50a74758b2f17bb9cc9a7fb4f6" +checksum = "a3f290edc569ad10c07287eebb96629686b37f1e55283a093569151d3adde0c7" dependencies = [ "actix-codec", - "actix-rt", "actix-service", "actix-utils", - "bitflags", + "bitflags 2.13.2", "bytes", "bytestring", "derive_more", @@ -66,9 +65,9 @@ dependencies = [ [[package]] name = "actix-rt" -version = "2.13.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a16bf2f19c2ad84842bdfe6f3665620e93197d5c607889bfa3aaac45e762fd1" +checksum = "e5f794807f82bbd36430c12cd600c73bbab0f52fdde4f0ed49978df113f4807f" dependencies = [ "futures-core", "tokio", @@ -76,13 +75,12 @@ dependencies = [ [[package]] name = "actix-server" -version = "2.9.1" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d44ae8a6516f4ac7bfc7b61aabcd286104e96b4b24c747ce220832a016056d9" +checksum = "5e296908c4d6f3a191e6b9873dbdfec8023350a89129d2b470f61d17233931ee" dependencies = [ "actix-rt", "actix-service", - "actix-utils", "futures-core", "futures-util", "mio", @@ -103,9 +101,9 @@ dependencies = [ [[package]] name = "actix-utils" -version = "3.0.1" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +checksum = "0128396dd7313f697ad05b21b1a7be7d4cbb81888704f55996e4a27db196bb4d" dependencies = [ "local-waker", "pin-project-lite", @@ -113,9 +111,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.15.0" +version = "4.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbacab3593b6b4f7be815076fc52d60a83c873426824675417e2abdd229e2e36" +checksum = "824c7250d7e468feb7903e41efc5d6883cb1f3957e8db2c3d2402a9ff430b886" dependencies = [ "actix-codec", "actix-http", @@ -146,6 +144,7 @@ dependencies = [ "smallvec", "socket2", "time", + "tokio", "tracing", "url", ] @@ -165,6 +164,51 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aegis" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41585f248ed9406567ae6d61ca2a55ed3dc7348e65203a01229881253acb95e3" +dependencies = [ + "cc", + "softaes", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -174,6 +218,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "allocator-api2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c880a97d28a3681c0267bd29cff89621202715b065127cd445fa0f0fe0aa2880" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -183,18 +239,142 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "antithesis_sdk" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08410fcac93669a476c006cd6c4512ac1e2b30fd117231a5d55d8a2c76599b82" +dependencies = [ + "libc", + "libloading", + "linkme", + "once_cell", + "rand 0.8.8", + "rustc_version_runtime", + "serde", + "serde_json", +] + [[package]] name = "anyhow" version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "aristo" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcdeefa800110050103e2459a2f8dbdbd560ad046e30f1f87e24ce19c2cb8bde" +dependencies = [ + "aristo-macros", +] + +[[package]] +name = "aristo-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64a66d21a80182b35b1741997a6d2456911f54b7eb1918aa4e2382fc205268d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + +[[package]] +name = "assoc" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfdc70193dadb9d7287fa4b633f15f90c876915b31f6af17da307fc59c9859a8" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "axum" version = "0.8.9" @@ -262,6 +442,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.22.1" @@ -281,10 +467,93 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] -name = "bitflags" -version = "2.13.1" +name = "bigdecimal" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.119", + "which", +] + +[[package]] +name = "bit-set" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec", + "serde", +] + +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] [[package]] name = "block2" @@ -338,12 +607,83 @@ dependencies = [ "serde_repr", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "branches" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7436b21fd6195415058eb41c9090e156b556bc7e0377bb57c5c026a421521525" +dependencies = [ + "rustc_version", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -361,19 +701,28 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.4" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ "find-msvc-tools", - "shlex", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", ] [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -381,6 +730,152 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "cfg_block" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18758054972164c3264f7c8386f5fc6da6114cb46b619fd365d4e3b2dc3ae487" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console" +version = "0.16.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "convert_case" version = "0.10.0" @@ -407,19 +902,157 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "crossbeam-epoch" -version = "0.9.20" +name = "core_detect" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "num-traits", + "rand_core 0.10.1", + "serdect", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "crypto-primes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" +dependencies = [ + "crypto-bigint", + "rand_core 0.10.1", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + +[[package]] +name = "deadpool" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e98a7e119cd347f4201e1159b19831029e203e2d8b790547708e8157b4acf1e" +dependencies = [ + "deadpool-runtime", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae" +dependencies = [ + "tokio", +] [[package]] name = "debugid" @@ -432,20 +1065,75 @@ dependencies = [ ] [[package]] -name = "der" -version = "0.8.1" +name = "defmt" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive", + "flagset", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid 0.10.2", "pem-rfc7468", "zeroize", ] [[package]] -name = "deranged" -version = "0.5.8" +name = "der_derive" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" [[package]] name = "derive_more" @@ -488,13 +1176,37 @@ dependencies = [ "tracing", ] +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", + "tempfile", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "dispatch2" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.13.2", "objc2", ] @@ -506,7 +1218,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.9", ] [[package]] @@ -516,19 +1228,56 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] -name = "encoding_rs" -version = "0.8.35" +name = "either" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "env_filter", + "log", ] [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -540,6 +1289,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + [[package]] name = "evmap" version = "11.0.0" @@ -551,6 +1310,35 @@ dependencies = [ "smallvec", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastbloom" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" +dependencies = [ + "getrandom 0.3.4", + "libm", + "siphasher", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -569,9 +1357,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "findshlibs" @@ -585,6 +1373,23 @@ dependencies = [ "winapi", ] +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -597,21 +1402,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -621,6 +1411,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures-channel" version = "0.3.34" @@ -637,6 +1433,17 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" version = "0.3.34" @@ -651,7 +1458,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.9", ] [[package]] @@ -683,10 +1490,25 @@ dependencies = [ ] [[package]] -name = "generator" -version = "0.8.9" +name = "genawaiter" +version = "0.99.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +checksum = "c86bd0361bcbde39b13475e6e36cb24c329964aa2611be285289d1e4b751c1a0" +dependencies = [ + "genawaiter-macro", +] + +[[package]] +name = "genawaiter-macro" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b32dfe1fdfc0bbde1f22a5da25355514b5e450c33a6af6770884c8750aedfbc" + +[[package]] +name = "generator" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1be81b31c9ce4caf7f3178eeb90662bb1864d3ad363ec6bdada3201adff19c37" dependencies = [ "cc", "cfg-if", @@ -697,6 +1519,16 @@ dependencies = [ "windows-result", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -705,7 +1537,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", ] [[package]] @@ -716,10 +1548,32 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.32.3" @@ -727,10 +1581,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] -name = "h2" -version = "0.4.19" +name = "glob" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" dependencies = [ "atomic-waker", "bytes", @@ -757,6 +1617,8 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2 0.2.21", + "equivalent", "foldhash", ] @@ -765,6 +1627,41 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2 0.2.21", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -772,6 +1669,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "hostname" version = "0.4.2" @@ -827,6 +1742,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -840,10 +1761,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "hyper" -version = "1.11.1" +name = "hybrid-array" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", @@ -876,33 +1806,33 @@ dependencies = [ ] [[package]] -name = "hyper-tls" -version = "0.6.0" +name = "hyper-rustls" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ - "bytes", - "http-body-util", + "http 1.5.0", "hyper", "hyper-util", - "native-tls", + "rustls", "tokio", - "tokio-native-tls", + "tokio-rustls", "tower-service", ] [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http 1.5.0", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -929,6 +1859,55 @@ dependencies = [ "tower-service", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +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_collator" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08984ed58ac439ebf3e13d2cf26b0c46a60afcd21721c1d14087c0b240344dda" +dependencies = [ + "icu_collator_data", + "icu_collections", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_collator_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d7e54efdddeb1208c08dd5d32b53a879ac00d2d3d051b2255fd26821d16368" + [[package]] name = "icu_collections" version = "2.3.0" @@ -943,6 +1922,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_locale" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "785f595c61ef57169a467eeed7b4b6936a66f6cacbb116a96ee86da983251bf4" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_locale_data", + "icu_locale_fallback", + "icu_provider", + "tinystr", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.3.0" @@ -951,11 +1945,38 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d37d91460e362a5cf58907cd7ce871411775ee6294e49a5cc8e6cec16dfa75ea" + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + [[package]] name = "icu_normalizer" version = "2.3.0" @@ -967,6 +1988,9 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] @@ -1005,6 +2029,8 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -1035,25 +2061,93 @@ dependencies = [ [[package]] name = "impl-more" -version = "0.3.5" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "277ff51754a3f68f12f58446c5d006aa8baa4914ea273cce24a599cfaff33d4f" +checksum = "2d3a73c82a0b0747dba739b380c046a140b5ae747234bf701df3460282da7193" + +[[package]] +name = "index_vec" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44faf5bb8861a9c72e20d3fb0fdbd59233e43056e2b80475ab0aacdc2e781355" [[package]] name = "indexmap" -version = "2.14.1" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", ] [[package]] -name = "ipnet" -version = "2.12.1" +name = "inout" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "intrusive-collections" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "189d0897e4cbe8c75efedf3502c18c887b05046e59d28404d4d8e46cbc4d1e86" +dependencies = [ + "memoffset", +] + +[[package]] +name = "inventory" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6928282826c822ad91bf1c9a1cb90a30ba1c26770749929b4656cd6be829cd7c" +dependencies = [ + "rustversion", +] + +[[package]] +name = "io-uring" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3bd0ecfbb87805f538bb7b32e5239ca0763890c623e349860ecba69469f2bb" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] [[package]] name = "itoa" @@ -1062,10 +2156,113 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "js-sys" -version = "0.3.104" +name = "jiff" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -1080,9 +2277,15 @@ checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "left-right" @@ -1097,9 +2300,71 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.189" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linkme" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3045e122bd98aef8ec3ad58ce84f0791f64e70163d1a02710af4aa11a4d54cc5" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77060ebe535362c3da75682cd17b0431017b6e7c5661e714fc69a7ad017d1301" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" @@ -1147,6 +2412,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1162,12 +2436,31 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "metrics" version = "0.24.6" @@ -1210,18 +2503,56 @@ dependencies = [ "hashbrown 0.16.1", "metrics", "quanta", - "rand", + "rand 0.9.5", "rand_xoshiro", "rapidhash", "sketches-ddsketch", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[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 = "miniz_oxide" version = "0.8.9" @@ -1233,32 +2564,21 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.2" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] [[package]] -name = "native-tls" -version = "0.2.18" +name = "multiversion_no_op" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" [[package]] name = "nix" @@ -1266,12 +2586,22 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", ] +[[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" @@ -1281,6 +2611,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1288,10 +2628,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] -name = "objc2" -version = "0.6.4" +name = "num-integer" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -1302,7 +2660,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags", + "bitflags 2.13.2", "objc2", "objc2-foundation", ] @@ -1323,7 +2681,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.13.2", "dispatch2", "objc2", ] @@ -1334,7 +2692,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags", + "bitflags 2.13.2", "dispatch2", "objc2", "objc2-core-foundation", @@ -1367,7 +2725,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -1385,7 +2743,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -1398,7 +2756,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", ] @@ -1409,19 +2767,28 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", "objc2-foundation", ] +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags", + "bitflags 2.13.2", "block2", "objc2", "objc2-cloud-kit", @@ -1462,29 +2829,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "openssl" -version = "0.10.81" +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] -name = "openssl-macros" -version = "0.1.1" +name = "opaque-debug" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl-probe" @@ -1492,18 +2846,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "os_info" version = "3.15.0" @@ -1520,6 +2862,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + +[[package]] +name = "pack1" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b7bb0ecf2e447b1f20ee94ee79ef6eed1e9d4b3c36ce1903b9dea3bf205523" +dependencies = [ + "bytemuck", +] + +[[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" @@ -1543,6 +2906,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -1558,6 +2927,25 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -1584,32 +2972,131 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.8.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f485dbafe293ea6ccaa7d82bc591651a796eb8a9aec3df040b057b706073dbc3" +dependencies = [ + "der 0.8.2", + "spki 0.8.1", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.2", + "spki 0.8.1", +] + [[package]] name = "pkg-config" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "pluralizer" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3eba432a00a1f6c16f39147847a870e94e2e9b992759b503e330efec778cbe" +dependencies = [ + "once_cell", + "regex", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "hmac", + "md-5", + "memchr", + "rand 0.10.3", + "sha2", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" +dependencies = [ + "array-init", + "bytes", + "fallible-iterator 0.2.0", + "jiff", + "postgres-protocol", + "uuid", +] + [[package]] name = "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "ppv-lite86" @@ -1620,6 +3107,25 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1629,6 +3135,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "quanta" version = "0.12.6" @@ -1639,7 +3168,7 @@ dependencies = [ "libc", "once_cell", "raw-cpuid", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "web-sys", "winapi", ] @@ -1659,14 +3188,58 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[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]] @@ -1676,7 +3249,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1688,13 +3270,28 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59cad018caf63deb318e5a4586d99a24424a364f40f1e5778c29aca23f4fc73e" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "rand_xoshiro" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" dependencies = [ - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -1712,7 +3309,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags", + "bitflags 2.13.2", ] [[package]] @@ -1721,7 +3318,27 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.2", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", ] [[package]] @@ -1774,19 +3391,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", - "hyper-tls", + "hyper-rustls", "hyper-util", "js-sys", "log", - "native-tls", "percent-encoding", "pin-project-lite", + "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", - "tokio-native-tls", + "tokio-rustls", "tokio-util", "tower", "tower-http 0.6.11", @@ -1812,12 +3430,77 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roaring" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18bd8a37d17a58532776dcdf6041ce64929adca78e8489d5cacbafe99229d3e1" +dependencies = [ + "bytemuck", + "byteorder", +] + +[[package]] +name = "rsa" +version = "0.10.0-rc.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33405e321e16ef941ca66e254f6904c3aafd3c7ff001fb88bf11c73eb75c2b85" +dependencies = [ + "const-oid 0.10.2", + "crypto-bigint", + "crypto-primes", + "digest", + "pkcs1", + "pkcs8", + "rand_core 0.10.1", + "signature", + "spki 0.8.1", + "zeroize", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags 2.13.2", + "fallible-iterator 0.3.0", + "fallible-streaming-iterator", + "hashlink 0.12.2", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc-demangle" version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1828,18 +3511,77 @@ dependencies = [ ] [[package]] -name = "rustix" -version = "1.1.4" +name = "rustc_version_runtime" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "2dd18cd2bae1820af0b6ad5e54f4a51d0f3fcc53b05f845675074efcc7af071d" dependencies = [ - "bitflags", + "rustc_version", + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -1849,6 +3591,44 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -1861,6 +3641,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -1888,7 +3677,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "core-foundation", "core-foundation-sys", "libc", @@ -1913,14 +3702,14 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "sentry" -version = "0.49.2" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a88b65feb368d9dde5d531a2b59b25016e36fd6e4978432371a3ee77746ca2" +checksum = "9fa951f4e644464ebfd2347a7ab03b0828c512d2448a76262cad2607b343dff4" dependencies = [ "cfg_aliases", "httpdate", - "native-tls", "reqwest", + "rustls", "sentry-actix", "sentry-backtrace", "sentry-contexts", @@ -1936,9 +3725,9 @@ dependencies = [ [[package]] name = "sentry-actix" -version = "0.49.2" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b59b6298f8b1c621e7e711050f7ae9620b972215eb2822db2bfc4b061ed0cd2" +checksum = "909d0a93b15d1ddcde3894aa23d003cbefc39ef53fe6d7c8294357ced2969cfb" dependencies = [ "actix-http", "actix-web", @@ -1949,9 +3738,9 @@ dependencies = [ [[package]] name = "sentry-anyhow" -version = "0.49.2" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34e4c4b2e5bf7bb63f8223eb4f6d24f64e2c18b4d01fdeb3d561a951e057425b" +checksum = "5ae34326bd95495e0db3c34c8888655684630cfe7609a73e863f0a7f4be5efdf" dependencies = [ "anyhow", "sentry-backtrace", @@ -1960,9 +3749,9 @@ dependencies = [ [[package]] name = "sentry-backtrace" -version = "0.49.2" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c51511d67ed670266a93afeaa26a08019b04ad1420cb9191da30f2338d22c48" +checksum = "f4f27cf048aa63ccd5bc94c29989133eabb82e9c6bf7441b58e9d4a95837a105" dependencies = [ "backtrace", "regex", @@ -1971,9 +3760,9 @@ dependencies = [ [[package]] name = "sentry-contexts" -version = "0.49.2" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b757ac1f335856e8d7f5062a1fd9bc07a05a7eb3cc48247db79bf2618a490bd" +checksum = "c5ed680585dbdacdf9859c2b4dc53358ff02bd256766882e22e343836db36b22" dependencies = [ "hostname", "libc", @@ -1985,11 +3774,11 @@ dependencies = [ [[package]] name = "sentry-core" -version = "0.49.2" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d889520a375e5b93efb0a66def1630d21d168b0251eb55b286b03fa940ccfc84" +checksum = "c3ed398494bb8fc412a11a182c1883a10ae147587b8f6314abd222d1d626f31b" dependencies = [ - "rand", + "rand 0.10.3", "sentry-types", "serde", "serde_json", @@ -1998,9 +3787,9 @@ dependencies = [ [[package]] name = "sentry-debug-images" -version = "0.49.2" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "656487fd5f7b7c0105b2e82171982aac64489e0cb679436038febf070f2f76d6" +checksum = "55854bb40681e84ffd2a69578ad4ef9bfaf5bf2bf1380a745578b591fa24af22" dependencies = [ "findshlibs", "sentry-core", @@ -2008,20 +3797,20 @@ dependencies = [ [[package]] name = "sentry-log" -version = "0.49.2" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba6c1bee99be05938885266fe5c95e0e4a2f46722cae205519f26e4ebdddd733" +checksum = "9a3dc14009e1c24b07a8ff9ff8e1bcd0913b3e448b1565e679414776f2e58651" dependencies = [ - "bitflags", + "bitflags 2.13.2", "log", "sentry-core", ] [[package]] name = "sentry-panic" -version = "0.49.2" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e0fe456a7380e9df875a1ef4df1e468d35b19b9051599845fab9d8f0c71c4ae" +checksum = "b7153e68642b761ed3aa62c022795c11b2facc728b2db7484084492373c944f9" dependencies = [ "sentry-backtrace", "sentry-core", @@ -2029,9 +3818,9 @@ dependencies = [ [[package]] name = "sentry-tower" -version = "0.49.2" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3481bd6aabb99f331e5f32c0fba7c2c22f41282219e7011e100263cb47e335f" +checksum = "624ee2c60caf72fa2cd6be00334b3a3ce0c6df3b1f01941ea62e3d244492d334" dependencies = [ "axum", "http 1.5.0", @@ -2044,11 +3833,11 @@ dependencies = [ [[package]] name = "sentry-tracing" -version = "0.49.2" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77796d14eedbef22c2fe78e9c69fb09f14c7ad607e624d48dce92eedc17a136e" +checksum = "e7e7db1aefdf623f4e48e21ec9e4af535024187aeafb2a4cc7e29d035def5a13" dependencies = [ - "bitflags", + "bitflags 2.13.2", "sentry-backtrace", "sentry-core", "tracing-core", @@ -2057,13 +3846,13 @@ dependencies = [ [[package]] name = "sentry-types" -version = "0.49.2" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc71f5ca55942d9b2901af95d5df5c5d65c164134c22a83682cac3d0b6c7ef2d" +checksum = "1c18bf7b159bdfc7c8f97816f476b4947a1937fb6bea9d38d3479e58631614ac" dependencies = [ "debugid", "hex", - "rand", + "rand 0.10.3", "serde", "serde_json", "thiserror", @@ -2099,14 +3888,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -2134,7 +3923,16 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.9", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", ] [[package]] @@ -2149,6 +3947,44 @@ dependencies = [ "serde", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2158,12 +3994,44 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "shuttle" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab17edba38d63047f46780cf7360acf7467fec2c048928689a5c1dd1c2b4e31" +dependencies = [ + "assoc", + "bitvec", + "cfg-if", + "generator", + "hex", + "owo-colors", + "rand 0.8.8", + "rand_core 0.6.4", + "rand_pcg", + "scoped-tls", + "smallvec", + "tracing", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2174,6 +4042,47 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest", + "rand_core 0.10.1", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simsimd" +version = "6.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4fb3bc3cdce07a7d7d4caa4c54f8aa967f6be41690482b54b24100a2253fa70" +dependencies = [ + "cc", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + [[package]] name = "sketches-ddsketch" version = "0.3.1" @@ -2188,9 +4097,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" [[package]] name = "socket2" @@ -2202,6 +4111,104 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "softaes" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4fdfe13e1c42e45f497416afb8dc4e783bf0e25b7be9077c1c77c5fb14cb998" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der 0.8.2", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink 0.11.1", + "indexmap", + "log", + "memchr", + "percent-encoding", + "rustls", + "smallvec", + "thiserror", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags 2.13.2", + "byteorder", + "bytes", + "digest", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "rand 0.10.3", + "rsa", + "sha1", + "sha2", + "sqlx-core", + "thiserror", + "time", + "tracing", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2210,7 +4217,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "steep-server" -version = "1.2.0" +version = "1.3.0" dependencies = [ "anyhow", "axum", @@ -2218,17 +4225,22 @@ dependencies = [ "dotenvy", "futures-util", "hex", + "jiff", "metrics", "metrics-exporter-prometheus", "reqwest", "ring", + "rustls", "sentry", "sentry-anyhow", "serde", "serde_json", "tempfile", "thiserror", + "toasty", + "toasty-cli", "tokio", + "tokio-stream", "tokio-util", "tower", "tower-http 0.7.1", @@ -2236,6 +4248,57 @@ dependencies = [ "tracing-subscriber", ] +[[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 = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "2.0.119" @@ -2249,9 +4312,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.4" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -2269,15 +4332,21 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tar" version = "0.4.46" @@ -2296,30 +4365,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.5", "windows-sys 0.61.2", ] [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.9", ] [[package]] @@ -2368,14 +4437,217 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] [[package]] -name = "tokio" -version = "1.53.1" +name = "tinyvec" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "toasty" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82c0ad66cbf23d9aeff0d85404e3799788dcea052d835997ed7e85bb3773725c" +dependencies = [ + "async-trait", + "bit-set", + "by_address", + "deadpool", + "hashbrown 0.17.1", + "index_vec", + "indexmap", + "inventory", + "jiff", + "serde", + "serde_core", + "serde_json", + "toasty-core", + "toasty-driver-mysql", + "toasty-driver-postgresql", + "toasty-driver-sqlite", + "toasty-driver-turso", + "toasty-macros", + "tokio", + "tokio-stream", + "toml", + "toml_edit", + "tracing", + "uuid", +] + +[[package]] +name = "toasty-cli" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756d16e2809ceaad4e2e78c0f65f7241160951648574435b331ec8ec50735cbd" +dependencies = [ + "anyhow", + "clap", + "console", + "dialoguer", + "hashbrown 0.17.1", + "jiff", + "rand 0.10.3", + "serde", + "toasty", + "toasty-core", + "toml", +] + +[[package]] +name = "toasty-core" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ce722358481c0ad55b7b5edaaf9d0669ee5de6e9c737a73ca4c27621003732" +dependencies = [ + "async-trait", + "bit-set", + "fluent-uri", + "form_urlencoded", + "hashbrown 0.17.1", + "heck", + "indexmap", + "jiff", + "percent-encoding", + "serde", + "tokio-stream", + "toml", + "tracing", + "uuid", +] + +[[package]] +name = "toasty-driver-mysql" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa5622479dff74a1db2178c8475e014f32ab434ffb06c8cb69142b0abebd876" +dependencies = [ + "async-trait", + "jiff", + "sqlx-core", + "sqlx-mysql", + "time", + "toasty-core", + "toasty-sql", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "toasty-driver-postgresql" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cdb083715b23faddc3b667151cfda8b02feac02e0066acd933d8f8028fce4c" +dependencies = [ + "async-trait", + "fallible-iterator 0.2.0", + "hashbrown 0.17.1", + "jiff", + "lru", + "postgres-protocol", + "postgres-types", + "rustls", + "rustls-pemfile", + "rustls-platform-verifier", + "rustls-webpki", + "toasty-core", + "toasty-sql", + "tokio", + "tokio-postgres", + "tokio-postgres-rustls", + "tracing", + "uuid", +] + +[[package]] +name = "toasty-driver-sqlite" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffaebb64e79526666cb2628a7de5979da5ef282bb3f36a7e3b6a5acc55936794" +dependencies = [ + "async-trait", + "rusqlite", + "serde", + "toasty-core", + "toasty-sql", + "tracing", + "uuid", +] + +[[package]] +name = "toasty-driver-turso" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f3d4680ddf5e6ec2ba584ec91763420b572d0d818e10ca105a85d312d6f10a" +dependencies = [ + "async-trait", + "serde", + "toasty-core", + "toasty-sql", + "tokio", + "tracing", + "turso", + "uuid", +] + +[[package]] +name = "toasty-macros" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f5b5a87266b1cccdd184244733b26fdac9ed3997446c5aca88c2c71f5b03f61" +dependencies = [ + "heck", + "indexmap", + "pluralizer", + "proc-macro2", + "quote", + "syn 3.0.9", + "toasty-core", +] + +[[package]] +name = "toasty-sql" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29b50cee2799bc54257ba202a9497f07b59df7e446b6df27fbf801cffe4b1b8a" +dependencies = [ + "jiff", + "serde", + "serde_json", + "toasty-core", +] + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", "libc", @@ -2396,16 +4668,56 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.9", ] [[package]] -name = "tokio-native-tls" -version = "0.3.1" +name = "tokio-postgres" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" dependencies = [ - "native-tls", + "async-trait", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.3", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-postgres-rustls" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c2ad44aa0ae96db89c4742212ed41645b2f597311ff6e1945542a4d9fadc2fb" +dependencies = [ + "rustls", + "sha2", + "tokio", + "tokio-postgres", + "tokio-rustls", + "x509-cert", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", "tokio", ] @@ -2418,13 +4730,14 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] name = "tokio-util" -version = "0.7.19" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ "bytes", "futures-core", @@ -2434,6 +4747,60 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + [[package]] name = "tower" version = "0.5.3" @@ -2456,7 +4823,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.2", "bytes", "futures-util", "http 1.5.0", @@ -2474,12 +4841,21 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" dependencies = [ - "bitflags", + "bitflags 2.13.2", "bytes", + "futures-core", + "futures-util", "http 1.5.0", "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -2509,6 +4885,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -2565,6 +4954,212 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "turso" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9491d7a80312c5abe66a4409e4dce02065503a235453c94b9e4133877e39ffc" +dependencies = [ + "thiserror", + "tracing", + "tracing-subscriber", + "turso_core", + "turso_sdk_kit", + "turso_sync_sdk_kit", +] + +[[package]] +name = "turso_core" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a833cc3bf8d4e6c101c504fa470f8ab4270c2202ff2591b61b2e373b4f20d9b" +dependencies = [ + "aegis", + "aes", + "aes-gcm", + "allocator-api2 0.4.0", + "antithesis_sdk", + "arc-swap", + "aristo", + "bigdecimal", + "bitflags 2.13.2", + "branches", + "bumpalo", + "bytemuck", + "cfg_aliases", + "cfg_block", + "chrono", + "crc32c", + "crossbeam-epoch", + "crossbeam-utils", + "either", + "fallible-iterator 0.3.0", + "fastbloom", + "hex", + "icu_collator", + "icu_locale", + "intrusive-collections", + "io-uring", + "libc", + "libloading", + "libm", + "loom", + "miette", + "num-bigint", + "num-traits", + "pack1", + "parking_lot", + "pastey", + "polling", + "rand 0.9.5", + "rapidhash", + "regex", + "regex-syntax", + "roaring", + "rustc-hash 2.1.3", + "rustix 1.1.5", + "ryu", + "serde_json", + "shuttle", + "simsimd", + "smallvec", + "strum", + "strum_macros", + "tempfile", + "thiserror", + "tracing", + "tracing-subscriber", + "turso_ext", + "turso_macros", + "turso_parser", + "twox-hash", + "uncased", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "turso_ext" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7452fe676450b6840e9eb80e512d14293265221154eba66aba3845dfc0d659f" +dependencies = [ + "chrono", + "getrandom 0.4.3", + "turso_macros", +] + +[[package]] +name = "turso_macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2825eaa6b7b893693636947f988e252c1196393fa54d4b85637c70d616d92fa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "turso_parser" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0e58d029f02e442df4be0b5036ce520b49c553505d9d52ef5a98fc4056e95b7" +dependencies = [ + "bitflags 2.13.2", + "memchr", + "miette", + "strum", + "strum_macros", + "thiserror", + "turso_macros", +] + +[[package]] +name = "turso_sdk_kit" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18c1dc1c0304348c39b97bc6b27cdcb1d7292454ebd0de0f30b5ee3a4c61f9bb" +dependencies = [ + "bindgen", + "env_logger", + "parking_lot", + "tracing", + "tracing-appender", + "tracing-subscriber", + "turso_core", + "turso_ext", + "turso_sdk_kit_macros", +] + +[[package]] +name = "turso_sdk_kit_macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4708b5fe678b8730c85964e3d378f75e18c23c4409971360b2209f4e53e4956" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "turso_sync_engine" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8f03e430240d590d209ac874db52773b7382c627cd604a685affde77622b09" +dependencies = [ + "base64 0.22.1", + "bytes", + "crc32c", + "genawaiter", + "http 1.5.0", + "libc", + "prost", + "roaring", + "serde", + "serde_json", + "thiserror", + "tracing", + "turso_core", + "turso_parser", + "uuid", +] + +[[package]] +name = "turso_sync_sdk_kit" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d48cb47d056c3ec567745761fa6f832358ca30ef9eb0435fdcfb77c558e70089" +dependencies = [ + "bindgen", + "env_logger", + "genawaiter", + "parking_lot", + "tracing", + "tracing-appender", + "tracing-subscriber", + "turso_core", + "turso_sdk_kit", + "turso_sdk_kit_macros", + "turso_sync_engine", +] + +[[package]] +name = "twox-hash" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2" +dependencies = [ + "rand 0.10.3", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uname" version = "0.1.1" @@ -2575,10 +5170,46 @@ dependencies = [ ] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "uncased" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[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.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[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 = "unicode-segmentation" @@ -2586,12 +5217,34 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -2600,26 +5253,25 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.4.0" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" dependencies = [ "base64 0.23.1", - "der", "log", - "native-tls", "percent-encoding", + "rustls", "rustls-pki-types", "ureq-proto", "utf8-zero", - "webpki-root-certs", + "webpki-roots", ] [[package]] name = "ureq-proto" -version = "0.6.1" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" dependencies = [ "base64 0.23.1", "http 1.5.0", @@ -2640,6 +5292,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8-zero" version = "0.8.1" @@ -2653,13 +5311,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] -name = "uuid" -version = "1.26.0" +name = "utf8parse" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ + "getrandom 0.4.3", "js-sys", + "rand 0.10.3", "serde_core", + "sha1_smol", "wasm-bindgen", ] @@ -2676,10 +5343,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] -name = "want" -version = "0.3.1" +name = "version_check" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -2690,6 +5373,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -2700,10 +5392,19 @@ dependencies = [ ] [[package]] -name = "wasm-bindgen" -version = "0.2.127" +name = "wasite" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -2714,19 +5415,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2734,22 +5436,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -2769,9 +5471,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -2786,6 +5488,40 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2802,12 +5538,56 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +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", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -2823,6 +5603,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2832,6 +5621,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2905,18 +5703,54 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der 0.7.10", + "spki 0.7.3", + "tls_codec", +] + [[package]] name = "xattr" version = "1.6.1" @@ -2924,7 +5758,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.5", ] [[package]] @@ -2940,30 +5774,30 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -2981,21 +5815,35 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" @@ -3006,6 +5854,7 @@ dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -3014,6 +5863,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -3027,7 +5877,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.9", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index aeaa9f3..e5e4798 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,18 +6,35 @@ members = [ resolver = "3" [workspace.dependencies] -reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls", "stream"] } +# TLS is pure Rust (rustls + ring): no libssl is needed at build or run time. +# `rustls-no-provider` leaves the crypto provider to the binary, which installs +# `ring` at startup (see `main`), so that a single provider is ever compiled in. +reqwest = { version = "0.13", default-features = false, features = ["json", "rustls-no-provider", "stream"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } tokio = { version = "1.53", features = ["full"] } tokio-stream = "0.1" tokio-util = "0.7" futures-util = "0.3" serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } -sentry = { version = "0.49", features = ["tower-axum-matched-path"] } +# Sentry's default `transport` feature pulls `native-tls`; the same set of features +# is spelled out here with rustls instead. +sentry = { version = "0.49", default-features = false, features = [ + "backtrace", + "contexts", + "debug-images", + "logs", + "metrics", + "panic", + "release-health", + "reqwest", + "rustls-no-provider", + "tower-axum-matched-path", +] } sentry-anyhow = { version = "0.49", features = ["backtrace"] } dotenvy = "0.15" tower = "0.5" -tower-http = { version = "0.7", features = ["trace"] } +tower-http = { version = "0.7", features = ["trace", "fs"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } axum = "0.8" @@ -28,6 +45,12 @@ hex = "0.4" bytes = "1.1" metrics = "0.24" metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] } +toasty = { version = "0.11", features = ["postgresql", "serde", "jiff"] } +toasty-cli = "0.11" +jiff = { version = "0.2", features = ["serde"] } +tempfile = "3" +bollard = "0.21" +tar = "0.4" [profile.release] debug = 1 diff --git a/Containerfile b/Containerfile index ccb90f5..fbe0b56 100644 --- a/Containerfile +++ b/Containerfile @@ -1,4 +1,4 @@ -FROM rust:1.98-trixie as builder +FROM rust:1.99-trixie as builder WORKDIR /app @@ -10,22 +10,22 @@ RUN cargo build --release --package steep-server FROM debian:trixie-slim -# git clones the pull request. ca-certificates is what every HTTPS call needs -# (Gitea, OpenRouter, git). The shared libraries are the non-base ones the binary -# links against, as reported by `ldd target/release/steep-server`; libc, libm -# and libgcc_s come from the base image. + RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ git \ - libssl3t64 \ - libzstd1 \ - zlib1g \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=builder /app/target/release/steep-server ./steep-server -# Exec form, so the binary is PID 1 and receives the SIGTERM it handles to shut -# down gracefully. -CMD ["./steep-server"] +COPY front ./front + +COPY crates/steep-server/toasty ./toasty +ENV MIGRATIONS_DIR=/app/toasty + +COPY scripts/migrate-apply.sh ./scripts/migrate-apply.sh +RUN chmod +x ./scripts/migrate-apply.sh + +CMD ["sh", "-c", "./scripts/migrate-apply.sh && exec ./steep-server"] diff --git a/README.md b/README.md index 2a00232..aa7a657 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![Steep — Let your code steep](banniere_readme.png) +![Steep — Let your code steep](public/banniere_readme.png) # Steep @@ -38,10 +38,11 @@ Steep reads its configuration from environment variables (a `.env` file is suppo | `GITEA_URL` | Base URL of your Gitea instance | | `GITEA_TOKEN` | Gitea API token | | `GITEA_TIMEOUT` | Gitea API request timeout in seconds | +| `DATABASE_URL` | PostgreSQL connection URL used for the durable review queue (e.g. `postgresql://user:pass@localhost:5432/steep`) | | `METRICS_BIND_ADDR` | *(optional)* Bind address for the Prometheus metrics endpoint (e.g. `0.0.0.0:9100`). If unset, the metrics exporter is disabled. | | `SENTRY_DSN` | *(optional)* Sentry DSN for error tracking | | `RUST_LOG` | *(optional)* Log level, defaults to `info` | -| `SANDBOX_MAX_ITERATIONS` | *(optional)* Maximum number of tool-calling iterations per sandboxed review. Defaults to `8` | +| `SANDBOX_MAX_ITERATIONS` | *(optional)* Maximum number of tool-calling iterations per sandboxed review. Defaults to `8`; a value that is set but is not a number stops the startup | | `DOCKER_HOST` | *(optional)* Container daemon socket Steep drives, e.g. `unix:///run/user/1000/podman/podman.sock` for a rootless podman. Defaults to `unix:///var/run/docker.sock` | ## Sandboxed reviews @@ -101,6 +102,28 @@ hooks have run, the container is disconnected from the network for the rest of the review. Every container command is bounded by a timeout, and the container, network and image are removed when the review ends (including on failure). +## Migrations + +The database schema is versioned with [Toasty](https://github.com/tokio-rs/toasty) migrations, stored in `crates/steep-server/toasty/` (commit them). + +After changing a model in `crates/steep-server/src/schemas/`, generate a migration: + +```sh +./scripts/migrate-generate.sh describe_change +``` + +Apply the pending migrations **before starting the service** — the server does not migrate at boot. The tests need the same schema: run this before `cargo test` too (CI does it as well): + +```sh +./scripts/migrate-apply.sh +``` + +Both scripts load the `.env` file at the project root (so `DATABASE_URL` is picked up). Generation runs through `cargo run -p steep-server --`; applying uses the compiled `steep-server` binary when present (as in the image), falling back to `cargo run` otherwise. + +The `Containerfile` does this at startup: its entrypoint runs `scripts/migrate-apply.sh` before exec'ing the server. The CLI reads the migration files from `MIGRATIONS_DIR` (defaults to the crate's `toasty/` directory; the image sets it to `/app/toasty`). + +Other subcommands: `migration snapshot`, `migration drop`, `migration reset`. They read `DATABASE_URL`. + ## Development The easiest way to get started is with the provided [Dev Container](https://containers.dev/) (VS Code or Zed with the dev container extension). @@ -116,6 +139,24 @@ cargo run -p steep-server Copy `.env.example` to `.env` and fill in your values before running. +## Job queue + +Steep is meant to run as a **single instance**. Webhooks are stored in the `jobs` table +of PostgreSQL, and the worker is woken up as soon as a job is pushed (no polling). +A job still `processing` at startup was interrupted by the previous process: it is put +back in the queue and reviewed again. Do not run two instances on the same database, +they would both take over each other's running jobs. + +## Dependencies + +TLS is handled by [rustls](https://github.com/rustls/rustls) (with `ring`), so neither +the build nor the image needs OpenSSL. The `Containerfile` runtime image only +installs `ca-certificates` and `git`. + +`cargo deny check` audits the dependencies (security advisories, licenses, banned +crates, sources) with the policy of `deny.toml`. It runs in the CI, and it also fails +if `openssl-sys` or `native-tls` comes back into the dependency tree. + ## Metrics Steep optionally exposes a Prometheus metrics endpoint, useful for scraping with an [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/) or any Prometheus-compatible scraper. @@ -128,8 +169,9 @@ Set `METRICS_BIND_ADDR` (e.g. `0.0.0.0:9100`) to enable it. The metrics are then |---|---|---| | `steep_webhooks_received_total` | counter | Total webhooks received (label: `event_type`) | | `steep_webhooks_duplicate_total` | counter | Webhooks rejected as duplicates (label: `event_type`) | -| `steep_webhooks_channel_full_total` | counter | Webhooks dropped because the bot channel was full (label: `event_type`) | +| `steep_webhooks_enqueue_failed_total` | counter | Webhooks dropped because enqueueing to PostgreSQL failed (label: `event_type`) | | `steep_bot_tasks_active` | gauge | Bot tasks currently in progress | +| `steep_bot_tasks_skipped_total` | counter | Reviews skipped because they were already done (label: `event_type`) | | `steep_bot_tasks_completed_total` | counter | Bot tasks completed successfully (label: `event_type`) | | `steep_bot_tasks_failed_total` | counter | Bot tasks that failed (label: `event_type`) | | `steep_openrouter_cost_cents_total` | counter | Total OpenRouter cost in cents (divide by 100 for USD) | diff --git a/ROADMAP.md b/ROADMAP.md index 5cd299a..2bf276e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,15 +11,22 @@ - If a repository has no Dev Container configuration, run its review in a default container based on `debian:stable-slim`. - Use `/workspace` as the workspace path for the default container and as the fallback when `workspaceFolder` is not specified. -## 1.3 — Typed action routing — planned +## 1.3 — Durable jobs & resumption — planned + +- Persist review jobs and their progress in PostgreSQL so in-flight work survives a restart and can be resumed instead of being lost. +- Expose an API to retrieve the list of jobs by state: running, pending, and review done. +- Stream job events over Server-Sent Events (SSE) so clients see status changes in real time. + +## 1.4 — YAML configuration — planned + +- Add a YAML configuration file as the primary way to configure Steep, replacing part of the current environment variables. +- Move non-secret settings such as the model, timeouts, concurrency limit, and sandbox iterations into the file, and keep secrets (API keys, tokens, webhook secret, database URL) in environment variables. + +## 2.0 — Typed action routing and implementation requests — planned - Introduce a type-safe model output for the action Steep should take, such as `Review` or `Implementation`. - Route review requests (for example, “@steep, review this PR”) to the existing review flow. -- Recognize implementation requests (for example, “@steep, rename method `XXX` to `YYY`”) and establish their dispatch path. Execution of implementation requests is planned for 2.0. - -## 2.0 — Implementation requests — planned - -- Use the typed action from 1.3 to route explicit implementation requests to an implementation workflow. +- Recognize implementation requests (for example, “@steep, rename method `XXX` to `YYY`”) and route them to the implementation workflow. - For a request on an issue, clone the repository from its default branch and implement the requested features on a new branch. - For a request on an existing pull request, clone its branch and implement the requested modifications there. - When the agent's iteration loop ends, the orchestration layer automatically pushes the resulting changes: it opens a pull request for issue-based work, or updates the existing pull request branch. The agent does not receive or invoke a push tool. @@ -31,6 +38,11 @@ - Allow configuring a dedicated model for pull request reviews and another for implementation requests. - Route each action type to its configured model, with a clear fallback when only one model is configured. +## 2.2 — Sub-agents — planned + +- Let the agent delegate well-scoped parts of a task to sub-agents that run their own iteration loop. +- Run independent sub-agents in parallel and have the main agent consolidate their results. + ## 3.0 — Autonomous Sentry fixes — planned - Automatically discover project errors reported by Sentry, without waiting for a user comment. diff --git a/crates/devcontainer-rs/Cargo.toml b/crates/devcontainer-rs/Cargo.toml index 883b958..6b3f3bf 100644 --- a/crates/devcontainer-rs/Cargo.toml +++ b/crates/devcontainer-rs/Cargo.toml @@ -2,12 +2,13 @@ name = "devcontainer-rs" version = "0.1.0" edition = "2024" +publish = false [dependencies] -bollard = "0.21" +bollard = { workspace = true } bytes = { workspace = true } futures-util = { workspace = true } -tar = "0.4" +tar = { workspace = true } tokio = { workspace = true } tokio-stream = { workspace = true } tracing = { workspace = true } @@ -17,4 +18,4 @@ anyhow = { workspace = true } thiserror = { workspace = true } [dev-dependencies] -tempfile = "3" +tempfile = { workspace = true } diff --git a/crates/devcontainer-rs/src/consts.rs b/crates/devcontainer-rs/src/consts.rs index 9aceac6..4e95d70 100644 --- a/crates/devcontainer-rs/src/consts.rs +++ b/crates/devcontainer-rs/src/consts.rs @@ -1,25 +1,24 @@ -//! Constantes de la crate : endpoint du daemon, timeouts et découpage du -//! contexte de build. +//! Crate constants: daemon endpoint, timeouts and build context chunking. use std::time::Duration; -/// Endpoint affiché dans les logs quand `DOCKER_HOST` n'est pas défini. +/// Endpoint shown in the logs when `DOCKER_HOST` is not set. pub(crate) const DEFAULT_ENDPOINT: &str = "unix:///var/run/docker.sock"; -/// Timeout appliqué aux opérations de build/run/stop/remove. +/// Timeout applied to build/run/stop/remove operations. pub(crate) const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(600); -/// Timeout appliqué aux commandes exécutées dans un container en cours d'exécution. +/// Timeout applied to commands run inside a running container. pub(crate) const DEFAULT_EXEC_TIMEOUT: Duration = Duration::from_secs(60); -/// Taille des morceaux du contexte de build envoyés au daemon. +/// Size of the build context chunks sent to the daemon. pub(crate) const CONTEXT_CHUNK_SIZE: usize = 64 * 1024; -/// Nombre de morceaux pouvant attendre dans le canal : c'est ce qui borne la -/// mémoire occupée par le contexte, quelle que soit sa taille. +/// Number of chunks that can wait in the channel: this is what bounds the +/// memory held by the context, regardless of its size. pub(crate) const CONTEXT_CHUNKS: usize = 4; -/// Lectures successives du code de sortie d'un exec, et attente entre elles. +/// Successive reads of an exec's exit code, and the delay between them. pub(crate) const EXIT_CODE_ATTEMPTS: usize = 10; pub(crate) const EXIT_CODE_DELAY: Duration = Duration::from_millis(20); diff --git a/crates/devcontainer-rs/src/container.rs b/crates/devcontainer-rs/src/container.rs index 7236af8..cf56fdd 100644 --- a/crates/devcontainer-rs/src/container.rs +++ b/crates/devcontainer-rs/src/container.rs @@ -1,4 +1,4 @@ -//! Un devcontainer en cours d'exécution : inspection et exécution de commandes. +//! A running devcontainer: inspection and command execution. use std::time::Duration; @@ -7,7 +7,7 @@ use crate::{ runtime::ContainerRuntime, }; -/// Un devcontainer en cours d'exécution. +/// A running devcontainer. #[derive(Debug, Clone)] pub struct Container { runtime: ContainerRuntime, @@ -19,8 +19,8 @@ pub struct Container { } impl Container { - /// Assemble un container démarré, avec son réseau et, le cas échéant, l'image - /// construite pour lui et à nettoyer. + /// Assembles a started container, with its network and, if any, the image + /// built for it that must be cleaned up. pub(crate) fn new( runtime: ContainerRuntime, name: String, @@ -47,10 +47,10 @@ impl Container { &self.workspace_folder } - /// Exécute une commande dans le container et renvoie sa sortie. + /// Runs a command in the container and returns its output. /// - /// La commande est transmise sous forme de vecteur d'arguments (argv, sans shell), - /// donc aucun échappement ni interpolation n'est effectué. + /// The command is passed as an argument vector (argv, no shell), so no + /// escaping or interpolation is performed. pub async fn exec(&self, cmd: &[&str]) -> Result { self.exec_with_timeout(cmd, DEFAULT_EXEC_TIMEOUT).await } @@ -65,19 +65,19 @@ impl Container { .await } - /// Exécute un script shell dans le container via `sh -c`. + /// Runs a shell script in the container via `sh -c`. pub async fn exec_shell(&self, script: &str) -> Result { self.exec(&["sh", "-c", script]).await } - /// Arrête le container. + /// Stops the container. pub async fn stop(&self) -> Result<(), ContainerError> { self.runtime.stop_container(&self.name).await } - /// Supprime le container, son réseau et son image. + /// Removes the container, its network and its image. /// - /// La suppression du réseau et de l'image est best-effort : ils peuvent déjà être absents. + /// Removing the network and the image is best-effort: they may already be gone. pub async fn remove(&self) -> Result<(), ContainerError> { self.runtime.remove_container(&self.name).await?; diff --git a/crates/devcontainer-rs/src/context.rs b/crates/devcontainer-rs/src/context.rs index 8c80d46..f964d7c 100644 --- a/crates/devcontainer-rs/src/context.rs +++ b/crates/devcontainer-rs/src/context.rs @@ -1,4 +1,4 @@ -//! Empaquetage en flux du contexte de build envoyé au daemon. +//! Streaming packaging of the build context sent to the daemon. use std::{ io::{BufWriter, Write}, @@ -12,13 +12,13 @@ use tokio_stream::wrappers::ReceiverStream; use crate::consts::{CONTEXT_CHUNK_SIZE, CONTEXT_CHUNKS}; -/// Empaquette le contexte de build dans un tar, **en flux**. +/// Packs the build context into a tar, **as a stream**. /// -/// Le CLI `docker build` empaquette le contexte puis l'envoie ; le faire d'un coup -/// chargerait tout le dossier en mémoire, ce qui devient intenable dès que le -/// `.devcontainer` embarque des binaires. Ici l'archive est écrite par une tâche -/// bloquante et poussée morceau par morceau, avec la contre-pression du canal : -/// si le daemon lit lentement, l'empaquetage ralentit. +/// The `docker build` CLI packs the context and then sends it; doing that in one go +/// would load the whole folder into memory, which becomes untenable as soon as the +/// `.devcontainer` ships binaries. Here the archive is written by a blocking task +/// and pushed chunk by chunk, with the channel's backpressure: if the daemon reads +/// slowly, packing slows down. pub(crate) fn tar_directory_stream( dir: &Path, ) -> impl Stream> + Send + 'static { @@ -33,13 +33,13 @@ pub(crate) fn tar_directory_stream( let result = builder .append_dir_all(".", &dir) .and_then(|()| builder.finish()) - // `finish` écrit les blocs de fin d'archive ; il reste à vider le tampon - // pour que le daemon reçoive tout, y compris une archive vide. + // `finish` writes the end-of-archive blocks; the buffer still has to be + // flushed so the daemon receives everything, even an empty archive. .and_then(|()| builder.get_mut().flush()); if let Err(error) = result { - // L'échec est remonté au daemon par le flux : sinon il ne verrait qu'un - // tar tronqué et signalerait une erreur incompréhensible. + // The failure is reported to the daemon through the stream: otherwise it + // would only see a truncated tar and report a confusing error. let _ = failures.blocking_send(Err(error)); } }); @@ -47,9 +47,9 @@ pub(crate) fn tar_directory_stream( ReceiverStream::new(receiver) } -/// Écrit les morceaux du tar dans un canal, en attendant qu'il se vide. +/// Writes the tar chunks into a channel, waiting for it to drain. /// -/// Utilisé depuis une tâche bloquante, seul contexte où `blocking_send` est autorisé. +/// Used from a blocking task, the only context where `blocking_send` is allowed. struct ChannelWriter { sender: mpsc::Sender>, } @@ -80,7 +80,7 @@ mod tests { use futures_util::StreamExt; - /// Rassemble le flux du contexte en un tar complet, pour l'inspecter. + /// Collects the context stream into a complete tar, for inspection. async fn context_tar(dir: &Path) -> Vec { let mut tar = Vec::new(); let mut chunks = tar_directory_stream(dir); diff --git a/crates/devcontainer-rs/src/devcontainer.rs b/crates/devcontainer-rs/src/devcontainer.rs index 4d59d61..a543720 100644 --- a/crates/devcontainer-rs/src/devcontainer.rs +++ b/crates/devcontainer-rs/src/devcontainer.rs @@ -1,5 +1,5 @@ -//! Représentation analysée d'un `devcontainer.json` et son cycle de vie : -//! nommage, build, démarrage et hooks. +//! Parsed representation of a `devcontainer.json` and its lifecycle: naming, +//! build, startup and hooks. use std::{ collections::HashMap, @@ -29,29 +29,29 @@ pub struct DevContainer { } impl DevContainer { - /// Dossier de workspace dans le container, par défaut `/workspace`. + /// Workspace folder inside the container, `/workspace` by default. pub fn workspace_folder(&self) -> String { self.workspace_folder .clone() .unwrap_or_else(|| "/workspace".to_string()) } - /// Tag de l'image construite localement à partir du Dockerfile. + /// Tag of the image built locally from the Dockerfile. fn local_image_tag(&self, id: &str) -> String { format!("devcontainer-rs/{}:{}", self.sanitized_name(), id) } - /// Nom du container de cette sandbox. + /// Name of this sandbox's container. fn container_name(&self, id: &str) -> String { format!("devcontainer-rs-{}-{}", self.sanitized_name(), id) } - /// Nom du devcontainer nettoyé pour servir de nom d'image/container. + /// Devcontainer name, sanitized to be usable as an image/container name. fn sanitized_name(&self) -> String { sanitize(self.name.as_deref().unwrap_or("devcontainer")) } - /// Construit l'image devcontainer sous `image_tag`. + /// Builds the devcontainer image as `image_tag`. async fn build( &self, runtime: &ContainerRuntime, @@ -239,7 +239,7 @@ impl Default for DevContainer { } } -/// Nettoie une chaîne pour qu'elle puisse servir de nom d'image/container docker. +/// Sanitizes a string so it can be used as a docker image/container name. fn sanitize(input: &str) -> String { let sanitized: String = input .chars() @@ -260,7 +260,7 @@ fn sanitize(input: &str) -> String { } } -/// Identifiant unique à une exécution de sandbox, combinant l'identifiant de processus et un horodatage. +/// Identifier unique to a sandbox run, combining the process id and a timestamp. fn unique_id() -> String { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/crates/devcontainer-rs/src/errors.rs b/crates/devcontainer-rs/src/errors.rs index b6a451d..5968bb8 100644 --- a/crates/devcontainer-rs/src/errors.rs +++ b/crates/devcontainer-rs/src/errors.rs @@ -1,5 +1,4 @@ -//! Erreurs de la crate : échecs du daemon de containers et échecs d'analyse du -//! `devcontainer.json`. +//! Crate errors: container daemon failures and `devcontainer.json` parsing failures. use std::{path::PathBuf, time::Duration}; @@ -7,25 +6,25 @@ use bollard::errors::Error as BollardError; #[derive(Debug, thiserror::Error)] pub enum ContainerError { - /// Le daemon est injoignable, ou a refusé une requête. + /// The daemon is unreachable, or rejected a request. #[error("container daemon request failed: {source}")] Request { #[from] source: BollardError, }, - /// Une opération a dépassé son timeout. + /// An operation exceeded its timeout. #[error("`{operation}` timed out after {timeout:?}")] Timeout { operation: String, timeout: Duration, }, - /// La construction de l'image a échoué. + /// The image build failed. #[error("image build failed: {message}")] Build { message: String }, - /// Le daemon a répondu autre chose que ce qui était attendu. + /// The daemon replied with something other than what was expected. #[error("{0}")] Unexpected(String), } diff --git a/crates/devcontainer-rs/src/exec.rs b/crates/devcontainer-rs/src/exec.rs index 58d2615..d1fc254 100644 --- a/crates/devcontainer-rs/src/exec.rs +++ b/crates/devcontainer-rs/src/exec.rs @@ -1,9 +1,9 @@ -//! Résultat d'une commande exécutée dans un container. +//! Result of a command run in a container. -/// Résultat d'une commande exécutée dans un container. +/// Result of a command run in a container. #[derive(Debug, Clone)] pub struct ExecOutput { - /// Code de sortie, ou `-1` si le processus a été terminé par un signal. + /// Exit code, or `-1` if the process was terminated by a signal. pub status: i32, pub stdout: String, pub stderr: String, diff --git a/crates/devcontainer-rs/src/lib.rs b/crates/devcontainer-rs/src/lib.rs index b0ccb44..fe2050f 100644 --- a/crates/devcontainer-rs/src/lib.rs +++ b/crates/devcontainer-rs/src/lib.rs @@ -1,28 +1,27 @@ -//! Primitives de cycle de vie de container pour un [`DevContainer`] analysé. +//! Container lifecycle primitives for a parsed [`DevContainer`]. //! -//! Cette crate pilote l'API du daemon de containers pour préparer l'image -//! devcontainer (construite depuis un Dockerfile, ou tirée d'un registre), -//! démarrer un container et y copier le workspace, exécuter les -//! hooks `postCreateCommand` / `postStartCommand` et lancer des commandes à -//! l'intérieur du container en cours d'exécution. +//! This crate drives the container daemon API to prepare the devcontainer image +//! (built from a Dockerfile, or pulled from a registry), start a container and +//! copy the workspace into it, run the `postCreateCommand` / `postStartCommand` +//! hooks, and run commands inside the running container. //! -//! Le daemon est joint par son **socket** (celui de l'hôte, monté dans le -//! container de Steep) : [`ContainerRuntime::connect`] suit `DOCKER_HOST` comme -//! le fait le CLI `docker`, et retombe sur le socket local. Aucun binaire de -//! runtime n'est donc requis dans l'image, et `podman` fonctionne de la même -//! façon dès lors qu'il expose son socket compatible Docker. +//! The daemon is reached through its **socket** (the host's, mounted into the +//! Steep container): [`ContainerRuntime::connect`] follows `DOCKER_HOST` like the +//! `docker` CLI does, and falls back to the local socket. No runtime binary is +//! therefore required in the image, and `podman` works the same way as long as it +//! exposes its Docker-compatible socket. //! //! # Isolation //! -//! Chaque sandbox dispose de son propre tag d'image, de son propre container et de -//! son propre réseau. Le container démarre attaché à ce réseau afin que les hooks -//! `postCreateCommand` / `postStartCommand` puissent récupérer des dépendances -//! (par ex. `npm install`) ; une fois les hooks exécutés, le container est déconnecté -//! du réseau pour le reste de sa durée de vie. Chaque commande est bornée par un timeout. +//! Each sandbox has its own image tag, its own container and its own network. The +//! container starts attached to that network so that the `postCreateCommand` / +//! `postStartCommand` hooks can fetch dependencies (e.g. `npm install`); once the +//! hooks have run, the container is disconnected from the network for the rest of +//! its lifetime. Every command is bounded by a timeout. //! -//! Les `runArgs` du `devcontainer.json` sont ignorés : ils viennent du dépôt, donc -//! d'une pull request non fiable, et pourraient rattacher le container à un autre -//! réseau ou lui donner des privilèges qui annuleraient cette isolation. +//! The `devcontainer.json` `runArgs` are ignored: they come from the repository, +//! hence from an untrusted pull request, and could attach the container to another +//! network or grant it privileges that would defeat this isolation. mod consts; mod container; diff --git a/crates/devcontainer-rs/src/path.rs b/crates/devcontainer-rs/src/path.rs index 4524c0e..3292aae 100644 --- a/crates/devcontainer-rs/src/path.rs +++ b/crates/devcontainer-rs/src/path.rs @@ -1,9 +1,9 @@ -//! Normalisation lexicale de chemins, sans accès au système de fichiers. +//! Lexical path normalization, without accessing the filesystem. use std::path::{Path, PathBuf}; -/// Normalise lexicalement un chemin, en résolvant `.` et `..` sans toucher au -/// système de fichiers. Renvoie `None` si le chemin sort de sa racine. +/// Lexically normalizes a path, resolving `.` and `..` without touching the +/// filesystem. Returns `None` if the path escapes its root. pub fn normalize(path: &Path) -> Option { use std::path::Component; diff --git a/crates/devcontainer-rs/src/runtime.rs b/crates/devcontainer-rs/src/runtime.rs index 63028c8..4fb7f3c 100644 --- a/crates/devcontainer-rs/src/runtime.rs +++ b/crates/devcontainer-rs/src/runtime.rs @@ -1,4 +1,4 @@ -//! Client de l'API du daemon de containers et opérations de cycle de vie. +//! Container daemon API client and lifecycle operations. use std::{collections::HashMap, path::Path, time::Duration}; @@ -24,7 +24,7 @@ use crate::{ exec::ExecOutput, }; -/// Un client de l'API du daemon de containers. +/// A container daemon API client. #[derive(Debug, Clone)] pub struct ContainerRuntime { docker: Docker, @@ -33,7 +33,7 @@ pub struct ContainerRuntime { } impl ContainerRuntime { - /// Se connecte au daemon désigné par `DOCKER_HOST`, ou au socket local par défaut. + /// Connects to the daemon designated by `DOCKER_HOST`, or to the default local socket. pub fn connect() -> Result { let endpoint = std::env::var("DOCKER_HOST").unwrap_or_else(|_| String::from(DEFAULT_ENDPOINT)); @@ -45,13 +45,13 @@ impl ContainerRuntime { }) } - /// Remplace le timeout appliqué aux opérations de build/run/stop/remove. + /// Replaces the timeout applied to build/run/stop/remove operations. pub fn with_timeout(mut self, timeout: Duration) -> Self { self.timeout = timeout; self } - /// Endpoint du daemon, pour les logs. + /// Daemon endpoint, for logging. pub fn endpoint(&self) -> &str { &self.endpoint } @@ -60,12 +60,12 @@ impl ContainerRuntime { self.timeout } - /// Vérifie que le daemon est joignable et répond. + /// Checks that the daemon is reachable and responding. pub async fn available(&self) -> bool { self.docker.ping().await.is_ok() } - /// Exécute une requête du daemon en appliquant le timeout des commandes. + /// Runs a daemon request, applying the command timeout. async fn request( &self, operation: &str, @@ -81,7 +81,7 @@ impl ContainerRuntime { } } - /// Construit l'image `tag` depuis le contexte `context_dir`. + /// Builds the image `tag` from the `context_dir` context. pub(crate) async fn build_image( &self, context_dir: &Path, @@ -102,8 +102,8 @@ impl ContainerRuntime { .docker .build_image(options, None, Some(body_try_stream(context))); - // Le flux porte la progression du build : la dernière erreur signalée par le - // daemon fait échouer l'opération. + // The stream carries the build progress: the last error reported by the + // daemon makes the operation fail. let consume = async { let mut failure = None; @@ -162,7 +162,7 @@ impl ContainerRuntime { } } - /// Crée un container, sans le démarrer. + /// Creates a container without starting it. pub(crate) async fn create_container( &self, name: &str, @@ -217,7 +217,7 @@ impl ContainerRuntime { Ok(()) } - /// Exécute une commande dans un container et renvoie sa sortie. + /// Runs a command in a container and returns its output. pub(crate) async fn exec( &self, container: &str, @@ -254,8 +254,8 @@ impl ContainerRuntime { let mut stdout = String::new(); let mut stderr = String::new(); - // Le timeout couvre l'exécution de la commande elle-même, pas seulement sa - // mise en place. + // The timeout covers the execution of the command itself, not just its + // setup. let collect = async { while let Some(message) = output.next().await { match message? { @@ -276,10 +276,10 @@ impl ContainerRuntime { Ok(Ok(())) => {} Ok(Err(source)) => return Err(ContainerError::Request { source }), Err(_) => { - // Le processus continue de tourner dans le container : sans arrêt, il - // consommerait CPU et mémoire et pourrait encore modifier le workspace - // pendant toute la durée de vie de la sandbox. L'API n'offre pas de - // moyen de tuer un exec, on arrête donc le container qui le porte. + // The process keeps running in the container: left alone, it would + // consume CPU and memory and could still modify the workspace for the + // whole lifetime of the sandbox. The API offers no way to kill an exec, + // so we stop the container that hosts it. let _ = self.stop_container(container).await; return Err(ContainerError::Timeout { @@ -289,10 +289,10 @@ impl ContainerRuntime { } } - // Le code de sortie n'est pas forcément publié au moment où le flux se ferme : - // on laisse au daemon le temps de le renseigner, sans bloquer indéfiniment. - // Sans cela, une commande réussie peut être rapportée en échec (code -1) et - // l'outil renvoie une erreur au modèle à la place du contenu. + // The exit code is not necessarily published by the time the stream closes: + // we give the daemon time to fill it in, without blocking indefinitely. + // Without this, a successful command can be reported as failed (code -1) and + // the tool returns an error to the model instead of the content. let mut inspected = self .request("inspect exec", self.docker.inspect_exec(&created.id)) .await?; @@ -331,7 +331,7 @@ impl ContainerRuntime { Ok(()) } - /// Supprime un container, ses volumes anonymes et le processus qui y tourne. + /// Removes a container, its anonymous volumes and the process running in it. pub(crate) async fn remove_container(&self, name: &str) -> Result<(), ContainerError> { let options = RemoveContainerOptions { force: true, @@ -363,7 +363,7 @@ impl ContainerRuntime { Ok(()) } - /// Crée le réseau dédié d'une sandbox. + /// Creates a sandbox's dedicated network. pub(crate) async fn create_network(&self, name: &str) -> Result<(), ContainerError> { let request = NetworkCreateRequest { name: String::from(name), @@ -376,7 +376,7 @@ impl ContainerRuntime { Ok(()) } - /// Détache un container d'un réseau, ce qui coupe sa connectivité. + /// Detaches a container from a network, which cuts off its connectivity. pub(crate) async fn disconnect_network( &self, network: &str, diff --git a/crates/devcontainer-rs/src/schema.rs b/crates/devcontainer-rs/src/schema.rs index de7d5e1..7d60fb8 100644 --- a/crates/devcontainer-rs/src/schema.rs +++ b/crates/devcontainer-rs/src/schema.rs @@ -1,5 +1,5 @@ -//! Schéma du `devcontainer.json`, analyse du fichier et résolution des -//! références `${localEnv:…}`. +//! `devcontainer.json` schema, file parsing and resolution of `${localEnv:…}` +//! references. use std::{ collections::HashMap, @@ -23,7 +23,7 @@ struct DevContainerSchema { #[serde(default)] name: Option, - /// Image de base tirée du registre, alternative à `build`. + /// Base image pulled from the registry, alternative to `build`. #[serde(default)] image: Option, @@ -106,16 +106,16 @@ impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer { } } -/// Résout les références `${localEnv:VAR}` et `${localEnv:VAR:default}` d'un +/// Resolves the `${localEnv:VAR}` and `${localEnv:VAR:default}` references of a /// `devcontainer.json`. /// -/// La spécification veut que `${localEnv:VAR}` soit lu dans l'environnement du -/// client ; ici le `devcontainer.json` vient d'une **pull request**, donc de code -/// non fiable : lire l'environnement du processus lui permettrait de récupérer -/// `GITEA_TOKEN`, `OPEN_ROUTER_API_KEY` ou n'importe quel autre secret de Steep et -/// de l'exfiltrer depuis un `build.args`, un `containerEnv`, son Dockerfile ou un -/// hook. L'environnement n'est donc **jamais** consulté : seule la valeur par -/// défaut est utilisée, et une référence sans défaut devient une chaîne vide. +/// The specification says `${localEnv:VAR}` is read from the client's environment; +/// here the `devcontainer.json` comes from a **pull request**, hence from untrusted +/// code: reading the process environment would let it grab `GITEA_TOKEN`, +/// `OPEN_ROUTER_API_KEY` or any other Steep secret and exfiltrate it through a +/// `build.args`, a `containerEnv`, its Dockerfile or a hook. The environment is +/// therefore **never** consulted: only the default value is used, and a reference +/// without a default becomes an empty string. fn substitute_local_env(input: &str) -> String { const PREFIX: &str = "${localEnv:"; @@ -129,7 +129,7 @@ fn substitute_local_env(input: &str) -> String { match after.find('}') { Some(end) => { let inner = &after[..end]; - // La clé est lue pour délimiter la référence, pas pour la résoudre. + // The key is read to delimit the reference, not to resolve it. let (_key, default) = match inner.split_once(':') { Some((key, default)) => (key, Some(default)), None => (inner, None), @@ -239,8 +239,8 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let devcontainer_path = dir.path().join("devcontainer.json"); - // `runArgs` vient d'une pull request non fiable : il doit être accepté par - // l'analyse mais jamais repris dans le devcontainer. + // `runArgs` comes from an untrusted pull request: it must be accepted by + // parsing but never carried over into the devcontainer. fs::write( &devcontainer_path, r#"{ @@ -271,8 +271,8 @@ mod tests { fn local_env_references_are_never_read_from_the_environment() { unsafe { std::env::set_var("DEVCONTAINER_TEST_SECRET", "s3cret") }; - // Le `devcontainer.json` vient d'une pull request : résoudre la référence - // depuis l'environnement de Steep y exposerait ses jetons. + // The `devcontainer.json` comes from a pull request: resolving the reference + // from Steep's environment would expose its tokens to it. assert_eq!( substitute_local_env("${localEnv:DEVCONTAINER_TEST_SECRET}"), "" diff --git a/crates/steep-server/Cargo.toml b/crates/steep-server/Cargo.toml index 4165ef4..9d2b74a 100644 --- a/crates/steep-server/Cargo.toml +++ b/crates/steep-server/Cargo.toml @@ -1,12 +1,14 @@ [package] name = "steep-server" -version = "1.2.0" +version = "1.3.0" edition = "2024" +publish = false [dependencies] reqwest = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true } +tokio-stream = { workspace = true, features = ["sync"] } futures-util = { workspace = true } serde_json = { workspace = true } serde = { workspace = true } @@ -25,4 +27,8 @@ hex = { workspace = true } metrics = { workspace = true } metrics-exporter-prometheus = { workspace = true } devcontainer-rs = { path = "../devcontainer-rs" } -tempfile = "3" \ No newline at end of file +tempfile = { workspace = true } +rustls = { workspace = true } +toasty = { workspace = true } +toasty-cli = { workspace = true } +jiff = { workspace = true } diff --git a/crates/steep-server/src/api.rs b/crates/steep-server/src/api.rs index 64ca4f2..c99ff82 100644 --- a/crates/steep-server/src/api.rs +++ b/crates/steep-server/src/api.rs @@ -1,31 +1,48 @@ use axum::body::{Bytes, to_bytes}; -use axum::extract::{FromRef, FromRequest, State}; +use axum::extract::{FromRef, FromRequest, Path, State}; use axum::http::Request; use axum::response::IntoResponse; +use axum::response::sse::{Event, KeepAlive, Sse}; use axum::routing::{get, post}; use axum::{Json, Router}; +use futures_util::stream::{self, Stream, StreamExt}; use reqwest::StatusCode; use ring::hmac; use sentry::integrations::tower::{NewSentryLayer, SentryHttpLayer}; -use serde_json::Value; +use serde_json::{Value, json}; +use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError}; use tower::ServiceBuilder; +use tower_http::services::ServeDir; use tower_http::trace::TraceLayer; -use tracing::{info, instrument}; - -use tokio_util::sync::CancellationToken; +use tracing::{info, instrument, warn}; use crate::consts::{GITEA_EVENT_TYPE_HEADER_NAME, GITEA_SIG_HEADER_NAME, MAX_WEBHOOK_BODY_SIZE}; use crate::errors::AppError; +use crate::event_bus::JobEvent; use crate::gitea::WebhookType; use crate::metrics; +use crate::schemas::JobStatus; use crate::state::AppState; -pub async fn start(app_state: AppState, shutdown: CancellationToken) -> anyhow::Result<()> { +pub async fn start(app_state: AppState) -> anyhow::Result<()> { let http_port = app_state.config.http_port; + let shutdown = app_state.shutdown.clone(); + + // The dashboard is served from a static directory; the API routes below take + // precedence (the directory is only the fallback). + let front_dir = std::env::var("FRONT_DIR").unwrap_or_else(|_| String::from("front")); + if !std::path::Path::new(&front_dir).is_dir() { + warn!(dir = %front_dir, "front directory not found, the dashboard will 404"); + } + let dashboard = ServeDir::new(&front_dir); let app = Router::new() - .route("/", get(root)) .route("/webhook", post(webhook)) + .route("/reviews", get(reviews_list)) + .route("/jobs", get(jobs_list)) + .route("/events", get(lifecycle_events)) + .route("/jobs/{id}/events", get(job_events)) + .fallback_service(dashboard) .layer( ServiceBuilder::new() .layer(NewSentryLayer::>::new_from_top()) @@ -46,8 +63,73 @@ pub async fn start(app_state: AppState, shutdown: CancellationToken) -> anyhow:: Ok(()) } -async fn root() -> &'static str { - "Hi, I'm Steep :)" +async fn reviews_list(State(app_state): State) -> Result { + let reviews = app_state.reviews.list().await?; + + Ok(Json(json!({ + "reviews": reviews + }))) +} + +async fn jobs_list(State(app_state): State) -> Result { + let pending = app_state.queue.list(JobStatus::Pending).await?; + let processing = app_state.queue.list(JobStatus::Processing).await?; + + Ok(Json(json!({ + "pending": pending, + "processing": processing, + }))) +} + +/// SSE stream of the global lifecycle events (the job list). +/// +/// The stream is closed as soon as the graceful shutdown starts: a live SSE +/// connection would otherwise keep the server from stopping. +async fn lifecycle_events( + State(app_state): State, +) -> Sse>> { + let rx = app_state.event_bus.subscribe_lifecycle(); + let shutdown = app_state.shutdown.cancelled_owned(); + + let stream = BroadcastStream::new(rx).map(|result| match result { + Ok(event) => sse_json("lifecycle", &event), + Err(BroadcastStreamRecvError::Lagged(skipped)) => sse_gap(skipped), + }); + + Sse::new(stream.take_until(shutdown)).keep_alive(KeepAlive::default()) +} + +async fn job_events( + State(app_state): State, + Path(id): Path, +) -> Result>>, AppError> { + let subscription = app_state + .event_bus + .subscribe_job(id) + .ok_or(AppError::JobNotFoundErr(id))?; + let shutdown = app_state.shutdown.cancelled_owned(); + + let history = subscription + .history + .into_iter() + .map(|event| sse_json("progress", &event)); + + let live = BroadcastStream::new(subscription.live).map(|result| match result { + Ok(event) => sse_json("progress", &event), + Err(BroadcastStreamRecvError::Lagged(skipped)) => sse_gap(skipped), + }); + + let stream = stream::iter(history).chain(live).take_until(shutdown); + + Ok(Sse::new(stream).keep_alive(KeepAlive::default())) +} + +fn sse_json(name: &'static str, payload: &T) -> Result { + Event::default().event(name).json_data(payload) +} + +fn sse_gap(skipped: u64) -> Result { + Ok(Event::default().event("gap").data(skipped.to_string())) } #[instrument(skip(app_state), fields(webhook_type), err)] @@ -61,20 +143,41 @@ async fn webhook( metrics::webhook_received(event_type); let event_id = wb.event_id(); - if app_state.bot.check_and_mark(event_id).await { - metrics::webhook_duplicate(event_type); - return Err(AppError::AlreadyProcessedErr); + match is_known(&app_state, event_id).await { + Ok(true) => { + metrics::webhook_duplicate(event_type); + return Err(AppError::AlreadyProcessedErr); + } + Ok(false) => {} + Err(err) => { + metrics::webhook_enqueue_failed(event_type); + return Err(AppError::EnqueueErr(anyhow::Error::from(err))); + } } - if app_state.bot_tx.try_send(wb).is_err() { - app_state.bot.unmark(event_id).await; - metrics::webhook_channel_full(event_type); - return Err(AppError::ChannelFullErr); - } + let job = match app_state.queue.push(wb).await { + Ok(job) => job, + Err(err) => { + metrics::webhook_enqueue_failed(event_type); + return Err(AppError::EnqueueErr(anyhow::Error::from(err))); + } + }; + + app_state.event_bus.publish(JobEvent::Queued { + id: job.id, + repo: job.repo, + pr: job.pr, + }); Ok((StatusCode::CREATED, "Task started")) } +/// Whether an event is already queued, or already reviewed. +async fn is_known(app_state: &AppState, event_id: u64) -> toasty::Result { + Ok(app_state.queue.is_queued(event_id).await? + || app_state.reviews.is_reviewed(event_id).await?) +} + pub struct WebhookExtract(pub WebhookType); impl FromRequest for WebhookExtract @@ -109,7 +212,7 @@ where }); }); - let webhook = parse_webhook(&type_header, &app_state.bot.name(), &body_bytes)?; + let webhook = parse_webhook(&type_header, &app_state.bot_name, &body_bytes)?; Ok(WebhookExtract(webhook)) } } diff --git a/crates/steep-server/src/bot.rs b/crates/steep-server/src/bot.rs index 596e1c8..94deff8 100644 --- a/crates/steep-server/src/bot.rs +++ b/crates/steep-server/src/bot.rs @@ -1,191 +1,101 @@ -use crate::{ - gitea::{GiteaAPI, WebhookType}, - metrics, - open_router::OpenRouterClient, - sandbox::SandboxConfig, -}; -use serde::{Deserialize, Deserializer}; -use std::{collections::HashSet, sync::Arc}; -use tokio::sync::Mutex; +use std::{sync::Arc, time::Duration}; + +use tokio::{sync::Semaphore, task::JoinSet}; use tokio_util::sync::CancellationToken; use tracing::{error, info, instrument}; -#[derive(Deserialize, Debug)] -pub struct ReviewResult { - pub reviews: Vec, - pub comment: String, - pub cost: Option, -} +use crate::{ + bot_actions::review::{self, ReviewContext, ReviewOutcome}, + event_bus::{EventBus, Reporter}, + gitea::{GiteaAPI, WebhookType}, + job_queue::JobQueue, + metrics, + open_router::OpenRouterClient, + review_store::{ReviewRecord, ReviewStore}, + sandbox::{SandboxConfig, tools}, + schemas::Job, +}; -#[derive(Deserialize, Debug)] -pub struct ReviewItem { - pub filename: String, - pub line: Option, - #[serde(default, deserialize_with = "deserialize_side")] - pub side: Option, - #[serde(default, deserialize_with = "deserialize_severity")] - pub severity: Option, - pub message: String, -} +/// Delay before claiming again after the queue could not be reached. +const CLAIM_RETRY_DELAY: Duration = Duration::from_secs(1); -/// Which version of the file a review comment is anchored on. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ReviewSide { - /// The line was added by the pull request: `line` is a line number of the - /// new version of the file. - Added, - /// The line was removed by the pull request: `line` is a line number of the - /// old version of the file. - Removed, -} - -impl ReviewSide { - /// Reads the side the model asked for, tolerating casing and synonyms. - fn parse(raw: &str) -> Option { - match raw.trim().to_ascii_lowercase().as_str() { - "added" | "add" | "new" | "right" => Some(Self::Added), - "removed" | "remove" | "deleted" | "delete" | "old" | "left" => Some(Self::Removed), - _ => None, - } - } -} - -/// What kind of problem a review reports. -/// -/// The categories are the ones the prompt asks for; a review whose severity is -/// missing or unreadable falls back to [`ReviewSeverity::Maintainability`], the -/// least alarming bucket, instead of failing the whole review. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ReviewSeverity { - /// Wrong behaviour. - Bug, - /// A vulnerability. - Security, - /// A resource problem. - Performance, - /// Everything else: readability, structure, naming, tests. - Maintainability, -} - -impl ReviewSeverity { - /// Reads the severity the model asked for, tolerating casing and synonyms. - fn parse(raw: &str) -> Option { - match raw.trim().to_ascii_lowercase().as_str() { - "bug" | "bugs" | "correctness" | "error" => Some(Self::Bug), - "security" | "vulnerability" => Some(Self::Security), - "performance" | "perf" => Some(Self::Performance), - "maintainability" | "quality" | "style" => Some(Self::Maintainability), - _ => None, - } - } - - /// Lower-case label used in the review markdown. - pub fn label(self) -> &'static str { - match self { - Self::Bug => "bug", - Self::Security => "security", - Self::Performance => "performance", - Self::Maintainability => "maintainability", - } - } -} - -/// Reads the side the model asked for. An unreadable value is ignored instead of -/// failing the whole review: the side is then resolved from the changed lines. -fn deserialize_side<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let raw = Option::::deserialize(deserializer)?; - - Ok(raw - .as_ref() - .and_then(serde_json::Value::as_str) - .and_then(ReviewSide::parse)) -} - -/// Reads the severity the model asked for, ignoring an unreadable value: the -/// review is then reported as [`ReviewSeverity::Maintainability`]. -fn deserialize_severity<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let raw = Option::::deserialize(deserializer)?; - - Ok(raw - .as_ref() - .and_then(serde_json::Value::as_str) - .and_then(ReviewSeverity::parse)) +/// Everything the bot works with: the external services and the stores. +#[derive(Clone)] +pub struct BotDeps { + pub gitea: GiteaAPI, + pub open_router: OpenRouterClient, + pub sandbox: SandboxConfig, + pub reviews: ReviewStore, + pub queue: JobQueue, + pub event_bus: EventBus, } #[derive(Clone)] pub struct Bot { - bot_name: String, - gitea_api: GiteaAPI, - open_router_client: OpenRouterClient, + deps: BotDeps, max_concurrent: usize, - open_router_model: String, - sandbox: SandboxConfig, - actions_handled: Arc>>, } impl Bot { - pub fn new( - bot_name: String, - gitea_api: GiteaAPI, - open_router_client: OpenRouterClient, - max_concurrent: usize, - open_router_model: String, - sandbox: SandboxConfig, - ) -> Self { + pub fn new(deps: BotDeps, max_concurrent: usize) -> Self { Self { - bot_name, - gitea_api, - open_router_client, + deps, max_concurrent, - open_router_model, - sandbox, - actions_handled: Arc::new(Mutex::new(HashSet::new())), } } - pub fn name(&self) -> String { - self.bot_name.clone() - } - - pub async fn start( - &self, - mut rx: tokio::sync::mpsc::Receiver, - shutdown: CancellationToken, - ) -> anyhow::Result<()> { + /// Consumes the queue until `shutdown` is cancelled. + /// + /// A semaphore bounds the concurrency, and a job is only claimed once a slot is + /// free, so the jobs waiting for one stay `pending`. Running jobs are left to + /// finish; the unfinished ones stay `processing` and are requeued at the next + /// startup. + pub async fn start(&self, shutdown: CancellationToken) -> anyhow::Result<()> { info!("Bot started"); - let sem = Arc::new(tokio::sync::Semaphore::new(self.max_concurrent)); - let mut tasks = tokio::task::JoinSet::new(); + let semaphore = Arc::new(Semaphore::new(self.max_concurrent)); + let mut tasks = JoinSet::new(); - loop { - let wb = tokio::select! { + 'consume: loop { + let permit = tokio::select! { biased; - _ = shutdown.cancelled() => break, - msg = rx.recv() => match msg { - Some(wb) => wb, - None => break, - }, + () = shutdown.cancelled() => break, + permit = semaphore.clone().acquire_owned() => permit?, }; - while let Some(res) = tasks.try_join_next() { - if let Err(e) = res { - error!("Task panicked: {e}"); + let job = loop { + while let Some(res) = tasks.try_join_next() { + if let Err(err) = res { + error!("Task panicked: {err}"); + } } - } - info!(queued = rx.len(), active = tasks.len(), "Webhook received"); - let permit = sem.clone().acquire_owned().await?; - let self_clone = self.clone(); + match self.deps.queue.claim_next().await { + Ok(Some(job)) => break job, + Ok(None) => { + tokio::select! { + biased; + () = shutdown.cancelled() => break 'consume, + () = self.deps.queue.wait_for_work() => {} + } + } + Err(err) => { + error!(%err, "failed to claim a job"); + + tokio::select! { + biased; + () = shutdown.cancelled() => break 'consume, + () = tokio::time::sleep(CLAIM_RETRY_DELAY) => {} + } + } + } + }; + + let bot = self.clone(); metrics::increment_task_active(); tasks.spawn(async move { - self_clone.exec(wb).await; + bot.process(job).await; drop(permit); metrics::decrement_task_active(); }); @@ -197,124 +107,91 @@ impl Bot { Ok(()) } - #[instrument(skip(self, webhook), fields(repo, pr))] - pub async fn exec(&self, webhook: WebhookType) { - let event_type_str = webhook.event_type_str(); + /// Runs a claimed job and records its outcome. + async fn process(&self, job: Job) { + self.run_job(&job).await; - match &webhook { - WebhookType::Review(p) => { - tracing::Span::current().record("repo", &p.repository.full_name); - tracing::Span::current().record("pr", p.pull_request.number); + if let Err(err) = self.deps.queue.remove(job.id).await { + error!(%err, "failed to remove job"); + } + } + + async fn run_job(&self, job: &Job) { + let deps = &self.deps; + let event_type = job.payload.event_type_str(); + + match deps.reviews.is_reviewed(job.event_id).await { + Ok(true) => { + metrics::task_skipped(event_type); + info!("Review already done, skipping"); + deps.event_bus.skip(job.id, &job.repo, job.pr); + return; + } + Ok(false) => {} + Err(err) => error!(%err, "Review lookup failed, reviewing anyway"), + } + + let reporter = deps.event_bus.start(job.id, job.repo.clone(), job.pr); + + let started = std::time::Instant::now(); + let result = self.exec(&job.payload, &reporter).await; + let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); + + let (ok, outcome) = match result { + Ok(outcome) => { + metrics::task_completed(event_type); + info!("Task completed"); + (true, outcome) + } + Err(err) => { + metrics::task_failed(event_type); + error!(%err, "Task error"); + sentry_anyhow::capture_anyhow(&err); + (false, ReviewOutcome::empty()) } }; - let tools = crate::sandbox::tools::for_webhook(&webhook); + let record = ReviewRecord { + event_id: job.event_id, + repo: job.repo.clone(), + pr: job.pr, + ok, + model: deps.open_router.model().to_string(), + markdown: outcome.markdown, + cost: outcome.cost, + comments: outcome.comments, + duration_ms, + }; - let exec_result = match webhook { - WebhookType::Review(review_payload) => crate::bot_actions::review::exec_review( - &self.gitea_api, - &self.open_router_client, - &self.open_router_model, - &self.sandbox, - tools, - review_payload, - ), + if let Err(err) = deps.reviews.record(record).await { + error!(%err, "failed to record review"); } - .await; - match exec_result { - Ok(_) => { - metrics::task_completed(event_type_str); - info!("Task completed"); - } - Err(err) => { - metrics::task_failed(event_type_str); - error!(%err, "Task error"); - sentry_anyhow::capture_anyhow(&err); - } + deps.event_bus + .finish(job.id, &job.repo, job.pr, ok, outcome.comments, duration_ms); + } + + /// Dispatches a webhook to the action it asks for. + #[instrument( + skip_all, + fields(repo = %webhook.repository_full_name(), pr = webhook.pull_request_number()) + )] + async fn exec( + &self, + webhook: &WebhookType, + reporter: &Reporter, + ) -> anyhow::Result { + let deps = &self.deps; + let ctx = ReviewContext { + gitea: &deps.gitea, + open_router: &deps.open_router, + sandbox: &deps.sandbox, + reporter, + }; + let tools = tools::for_webhook(webhook); + + match webhook { + WebhookType::Review(payload) => review::exec_review(&ctx, tools, payload).await, } } - - pub async fn check_and_mark(&self, event_id: u64) -> bool { - let mut action_handled_lock = self.actions_handled.lock().await; - - !action_handled_lock.insert(event_id) - } - - pub async fn unmark(&self, event_id: u64) { - let mut action_handled_lock = self.actions_handled.lock().await; - - action_handled_lock.remove(&event_id); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_actions_handled() -> Arc>> { - Arc::new(Mutex::new(HashSet::new())) - } - - async fn check_and_mark(actions_handled: &Arc>>, event_id: u64) -> bool { - let mut lock = actions_handled.lock().await; - !lock.insert(event_id) - } - - async fn unmark(actions_handled: &Arc>>, event_id: u64) { - let mut lock = actions_handled.lock().await; - lock.remove(&event_id); - } - - #[tokio::test] - async fn test_check_and_mark_first_call_returns_false() { - let actions_handled = make_actions_handled(); - assert!(!check_and_mark(&actions_handled, 1).await); - } - - #[tokio::test] - async fn test_check_and_mark_second_call_returns_true() { - let actions_handled = make_actions_handled(); - check_and_mark(&actions_handled, 1).await; - assert!(check_and_mark(&actions_handled, 1).await); - } - - #[tokio::test] - async fn test_check_and_mark_different_ids_are_independent() { - let actions_handled = make_actions_handled(); - check_and_mark(&actions_handled, 1).await; - assert!(!check_and_mark(&actions_handled, 2).await); - } - - #[tokio::test] - async fn test_unmark_allows_reprocessing() { - let actions_handled = make_actions_handled(); - check_and_mark(&actions_handled, 1).await; - unmark(&actions_handled, 1).await; - assert!(!check_and_mark(&actions_handled, 1).await); - } - - #[tokio::test] - async fn test_unmark_nonexistent_id_is_noop() { - let actions_handled = make_actions_handled(); - unmark(&actions_handled, 99).await; - assert!(!check_and_mark(&actions_handled, 99).await); - } - - #[tokio::test] - async fn test_check_and_mark_concurrent_same_id() { - let actions_handled = make_actions_handled(); - let actions_handled2 = Arc::clone(&actions_handled); - - let t1 = tokio::spawn(async move { check_and_mark(&actions_handled, 42).await }); - let t2 = tokio::spawn(async move { check_and_mark(&actions_handled2, 42).await }); - - let (r1, r2) = tokio::join!(t1, t2); - let results = [r1.unwrap(), r2.unwrap()]; - - // exactement un seul des deux doit retourner false (non traité) - assert_eq!(results.iter().filter(|&&r| !r).count(), 1); - // l'autre doit retourner true (déjà traité) - assert_eq!(results.iter().filter(|&&r| r).count(), 1); - } } diff --git a/crates/steep-server/src/bot_actions/review.rs b/crates/steep-server/src/bot_actions/review.rs deleted file mode 100644 index e0ac54f..0000000 --- a/crates/steep-server/src/bot_actions/review.rs +++ /dev/null @@ -1,1161 +0,0 @@ -use std::str::FromStr; - -use tracing::{info, instrument, warn}; - -use crate::{ - bot::{ReviewItem, ReviewResult, ReviewSeverity, ReviewSide}, - consts::{BOT_PROCESS_MSG, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT}, - gitea::{GiteaAPI, PullRequestFile, ReviewPayload}, - metrics, - open_router::{OpenRouterClient, Tool}, - sandbox::{Sandbox, SandboxConfig, agent}, - text::excerpt, -}; - -#[instrument(skip(gitea_api, open_router_client, sandbox_config, tools, review_payload))] -pub async fn exec_review( - gitea_api: &GiteaAPI, - open_router_client: &OpenRouterClient, - model: &str, - sandbox_config: &SandboxConfig, - tools: Vec, - review_payload: ReviewPayload, -) -> anyhow::Result<()> { - tracing::info!( - repo = %review_payload.repository.full_name, - pr = review_payload.pull_request.number, - action = %review_payload.action, - "Starting review" - ); - - let new_comment = gitea_api - .comment( - &BOT_PROCESS_MSG.replace("{model}", model), - &review_payload.repository.full_name, - review_payload.pull_request.number, - ) - .await?; - - let bot_result: Result = async { - let full_name = &review_payload.repository.full_name; - let index = review_payload.pull_request.number; - - let git_diff = gitea_api.pull_request_diff(full_name, index).await?; - - // The file list only refines the paths and describes the changes: a - // failure is not fatal, the diff is the source of truth for the lines. - let files = match gitea_api.pull_request_files(full_name, index).await { - Ok(files) => files, - Err(err) => { - warn!(%err, "Failed to list the pull request files"); - Vec::new() - } - }; - - let mut changed_lines = parse_changed_lines(&git_diff); - drop_generated_files(&mut changed_lines); - resolve_filenames(&mut changed_lines, &files); - - let changes = format_changes(&files, &changed_lines); - - let bot_request = REVIEW_PROMPT - .replace("{subject}", &review_payload.pull_request.title) - .replace("{comment}", &review_payload.comment.body) - .replace("{changes}", &changes); - - let (mut review_result, cost) = run_sandboxed_review( - gitea_api, - open_router_client, - sandbox_config, - tools, - &review_payload, - &bot_request, - ) - .await?; - - resolve_review_sides(&mut review_result, &changed_lines); - - review_result.cost = cost; - if let Some(cost) = review_result.cost { - metrics::openrouter_cost_usd(cost); - } - - let final_review_markdown = review_result_to_markdown(&review_result); - - gitea_api - .post_pull_request_review( - &review_result, - &final_review_markdown, - &review_payload.repository.full_name, - review_payload.pull_request.number, - ) - .await?; - - Ok(review_result) - } - .await; - - match bot_result { - Ok(_) => { - gitea_api - .delete_comment(&review_payload.repository.full_name, new_comment.id) - .await - } - Err(e) => { - gitea_api - .edit_comment( - &format!("Error while reviewing: {}", e), - &review_payload.repository.full_name, - new_comment.id, - ) - .await - } - } -} - -/// Runs the review inside a sandbox container, letting the model explore the -/// repository with tools before answering. -/// -/// The answer of the model is parsed by [`ReviewResult::from_str`], which the -/// agent loop enforces: an answer that is not a review is sent back to the model -/// for correction. -async fn run_sandboxed_review( - gitea_api: &GiteaAPI, - open_router_client: &OpenRouterClient, - sandbox_config: &SandboxConfig, - tools: Vec, - review_payload: &ReviewPayload, - bot_request: &str, -) -> anyhow::Result<(ReviewResult, Option)> { - let sandbox = Sandbox::create( - &sandbox_config.runtime, - &review_payload.repository.clone_url, - gitea_api.token(), - review_payload.pull_request.number, - ) - .await?; - - let result = agent::run( - open_router_client, - &sandbox, - tools, - SANDBOX_SYSTEM_PROMPT, - bot_request, - sandbox_config.max_iterations, - ) - .await; - - if let Err(err) = sandbox.cleanup().await { - warn!(%err, "Failed to clean up sandbox container"); - } - - let result = result?; - info!( - iterations = result.iterations, - cost = ?result.cost, - "Sandboxed review finished" - ); - - Ok((result.answer, result.cost)) -} - -fn review_result_to_markdown(review_result: &ReviewResult) -> String { - if review_result.reviews.is_empty() { - return String::from("No issues found. ✅"); - } - - let mut md = String::from("## Review Feedback\n\n"); - - md.push_str(&format!( - "### {} issues found.\n\n", - review_result.reviews.len() - )); - - let breakdown = severity_breakdown(&review_result.reviews); - if !breakdown.is_empty() { - md.push_str(&breakdown); - md.push('\n'); - } - - if !review_result.comment.is_empty() { - md.push_str("\n---\n\n"); - md.push_str("### Summary\n\n"); - md.push_str(&review_result.comment); - md.push('\n'); - } - - if let Some(cost) = review_result.cost { - md.push_str("\n---\n\n"); - md.push_str(&format!("### Cost: ${}", cost)); - md.push('\n'); - } - - md -} - -/// Counts the reviews per severity, most severe first. -/// -/// A review whose severity the model omitted or wrote unreadably is counted as -/// [`ReviewSeverity::Maintainability`], the same fallback used when posting it. -fn severity_breakdown(reviews: &[ReviewItem]) -> String { - [ - ReviewSeverity::Bug, - ReviewSeverity::Security, - ReviewSeverity::Performance, - ReviewSeverity::Maintainability, - ] - .into_iter() - .filter_map(|severity| { - let count = reviews - .iter() - .filter(|review| review.severity.unwrap_or(ReviewSeverity::Maintainability) == severity) - .count(); - - (count > 0).then(|| format!("- {} {}", count, severity.label())) - }) - .collect::>() - .join("\n") -} - -/// The lines a pull request changed, per file. -/// -/// Line numbers are the ones Gitea expects to place a review comment: -/// [`ReviewSide::Added`] numbers refer to the new version of the file (sent as -/// `new_position`), [`ReviewSide::Removed`] numbers to the old version (sent as -/// `old_position`). -type ChangedLines = Vec; - -struct ChangedFile { - filename: String, - added: Vec, - removed: Vec, -} - -impl ChangedFile { - fn new(filename: &str) -> Self { - Self { - filename: String::from(filename), - added: Vec::new(), - removed: Vec::new(), - } - } - - /// Lines that can be commented on for the given side. - fn lines(&self, side: ReviewSide) -> &[u64] { - match side { - ReviewSide::Added => &self.added, - ReviewSide::Removed => &self.removed, - } - } - - /// Side a line belongs to, used when the model did not state one. - fn side_of(&self, line: u64) -> Option { - if self.added.contains(&line) { - Some(ReviewSide::Added) - } else if self.removed.contains(&line) { - Some(ReviewSide::Removed) - } else { - None - } - } -} - -/// Lists the lines changed by the diff, per file, on both sides. -/// -/// Only the line numbers are kept: the model reads the code itself through the -/// sandbox tools. -fn parse_changed_lines(git_diff: &str) -> ChangedLines { - let mut files = Vec::new(); - let mut current_file: Option = None; - let mut in_hunk = false; - let mut old_line: u64 = 0; - let mut new_line: u64 = 0; - - for line in git_diff.lines() { - if line.starts_with("diff --git ") { - current_file = None; - in_hunk = false; - continue; - } - - // `--- a/x` and `+++ b/x` only appear before the first hunk of a file. - // Inside a hunk, a line may legitimately start with them: removing - // `--x` gives `---x`, adding `++i;` gives `+++i;`. - if !in_hunk && (line.starts_with("--- ") || line.starts_with("+++ ")) { - // A header is never a content line: `+++ /dev/null` on a deleted - // file keeps the path of the other side in place. - if let Some(path) = header_file_path(line) { - current_file = Some(path); - } - continue; - } - - if line.starts_with("@@") { - if let Some((old_start, new_start)) = parse_hunk_starts(line) { - old_line = old_start; - new_line = new_start; - in_hunk = true; - } - continue; - } - - let Some(filename) = current_file.as_deref() else { - continue; - }; - - match line.as_bytes().first() { - Some(b' ') => { - old_line += 1; - new_line += 1; - } - Some(b'-') => { - changed_file(&mut files, filename).removed.push(old_line); - old_line += 1; - } - Some(b'+') => { - changed_file(&mut files, filename).added.push(new_line); - new_line += 1; - } - // `\ No newline at end of file`, and anything unexpected: a line - // that advances neither side. - _ => {} - } - } - - files -} - -/// Path of the file on one side of the diff, from a `--- a/` or -/// `+++ b/` header line. -/// -/// These lines are the only unambiguous source for the path: the `diff --git` -/// line is cut at the first space, and it names the old path of a renamed file. -/// `None` for `/dev/null`, which leaves the path of the other side in place. -fn header_file_path(line: &str) -> Option { - let (prefix, raw) = match line.strip_prefix("--- ") { - Some(raw) => ("a/", raw), - None => ("b/", line.strip_prefix("+++ ")?), - }; - - let path = decode_git_path(raw); - - Some(String::from(path.strip_prefix(prefix)?)) -} - -/// Decodes a path as git writes it in a diff header: git wraps it in quotes and -/// escapes the bytes that need it (`\303\251` for `é`) when the path contains -/// non-printable or non-ASCII characters. -fn decode_git_path(raw: &str) -> String { - let Some(quoted) = raw.strip_prefix('"').and_then(|raw| raw.strip_suffix('"')) else { - return String::from(raw); - }; - - let bytes = quoted.as_bytes(); - let mut decoded = Vec::with_capacity(bytes.len()); - let mut index = 0; - - while let Some(byte) = bytes.get(index) { - index += 1; - - if *byte != b'\\' { - decoded.push(*byte); - continue; - } - - match bytes.get(index) { - // Octal escapes are the ones that matter for a path: git uses them - // for every non-ASCII byte. - Some(digit @ b'0'..=b'7') => { - let mut value = u32::from(digit - b'0'); - let mut digits = 1; - - while digits < 3 { - match bytes.get(index + digits) { - Some(next @ b'0'..=b'7') => { - value = value * 8 + u32::from(next - b'0'); - digits += 1; - } - _ => break, - } - } - - decoded.push(u8::try_from(value).unwrap_or(b'?')); - index += digits; - } - Some(escaped) => { - decoded.push(match escaped { - b't' => b'\t', - b'n' => b'\n', - b'r' => b'\r', - other => *other, - }); - index += 1; - } - None => decoded.push(b'\\'), - } - } - - String::from_utf8_lossy(&decoded).into_owned() -} - -/// Replaces the paths parsed from the diff with the exact paths reported by the -/// API, which are the ones the model sees in the sandbox. -fn resolve_filenames(changed_lines: &mut ChangedLines, files: &[PullRequestFile]) { - for changed in changed_lines.iter_mut() { - let parsed = changed.filename.as_str(); - - let Some(file) = files.iter().find(|file| file.filename == parsed) else { - if !files.is_empty() { - warn!(path = %parsed, "Changed file is not in the pull request file list"); - } - continue; - }; - - changed.filename = file.filename.clone(); - } -} - -/// Basenames of generated files that are never reviewed. -/// -/// Their diffs are machine-written dependency churn: they run to thousands of -/// lines, flood the prompt with line numbers and drown the code the model should -/// look at. A lockfile is also never a place where a review comment belongs. -const IGNORED_FILE_NAMES: [&str; 16] = [ - "Cargo.lock", - "package-lock.json", - "npm-shrinkwrap.json", - "yarn.lock", - "pnpm-lock.yaml", - "bun.lock", - "bun.lockb", - "composer.lock", - "Gemfile.lock", - "poetry.lock", - "uv.lock", - "Pipfile.lock", - "go.sum", - "packages.lock.json", - "flake.lock", - "pubspec.lock", -]; - -/// Whether a changed path is a generated file that is never reviewed. -/// -/// The comparison is on the basename, so a lockfile nested in a workspace (for -/// example `crates/foo/Cargo.lock`) is matched too. -fn is_ignored(filename: &str) -> bool { - let basename = filename.rsplit('/').next().unwrap_or(filename); - - IGNORED_FILE_NAMES.contains(&basename) -} - -/// Drops the generated files from the changed lines. -/// -/// Removing them here keeps the prompt free of their line numbers, and also makes -/// [`resolve_review_sides`] reject any review the model anchors on one of them. -fn drop_generated_files(changed_lines: &mut ChangedLines) { - changed_lines.retain(|file| !is_ignored(&file.filename)); -} - -/// Renders the changes for the model: the files the pull request touches, then -/// the lines to review per file. -fn format_changes(files: &[PullRequestFile], changed_lines: &ChangedLines) -> String { - let mut sections = Vec::new(); - - let described = files - .iter() - .filter(|file| !is_ignored(&file.filename)) - .map(describe_file) - .collect::>() - .join(", "); - - if !described.is_empty() { - sections.push(format!("Files changed by the pull request: {described}")); - } - - sections.push(format!( - "Lines to review, per file:\n{}", - format_changed_lines(changed_lines) - )); - - sections.join("\n\n") -} - -/// Describes a changed file for the model, including how it changed. -fn describe_file(file: &PullRequestFile) -> String { - match &file.previous_filename { - Some(previous) => format!("{} ({} from {})", file.filename, file.status, previous), - None => format!("{} ({})", file.filename, file.status), - } -} - -/// Borrows the entry of `files` for a file, creating it on its first change. -fn changed_file<'a>(files: &'a mut ChangedLines, filename: &str) -> &'a mut ChangedFile { - let index = match files.iter().position(|file| file.filename == filename) { - Some(index) => index, - None => { - files.push(ChangedFile::new(filename)); - files.len() - 1 - } - }; - - &mut files[index] -} - -/// Renders the changed lines as `filename: added 1, 2 / removed 3`, one file per -/// line, keeping only the sides the file actually has. -fn format_changed_lines(changed_lines: &ChangedLines) -> String { - changed_lines - .iter() - .map(|file| { - let mut sides = Vec::new(); - - for (label, lines) in [("added", &file.added), ("removed", &file.removed)] { - if !lines.is_empty() { - sides.push(format!("{label} {}", format_line_numbers(lines))); - } - } - - format!("{}: {}", file.filename, sides.join(" / ")) - }) - .collect::>() - .join("\n") -} - -fn format_line_numbers(lines: &[u64]) -> String { - lines - .iter() - .map(u64::to_string) - .collect::>() - .join(", ") -} - -/// Number of characters of a model answer kept in the logs when it cannot be -/// parsed. -const MAX_LOGGED_ANSWER: usize = 500; - -impl FromStr for ReviewResult { - type Err = anyhow::Error; - - /// Parses the review the model answered with. - /// - /// This is the contract the agent loop enforces: a rejected answer is sent - /// back to the model, with the reason, so that it can correct itself. - /// - /// The contract is a raw JSON object, but models sometimes wrap it in a - /// markdown code fence or surround it with a sentence: the object is then - /// extracted from the answer before failing, and the answer is logged so a - /// breach of the contract can be diagnosed. - fn from_str(message: &str) -> Result { - let error = match serde_json::from_str::(message) { - Ok(review_result) => return Ok(review_result), - Err(error) => error, - }; - - // A markdown code fence or a sentence around the object is tolerated, with - // a warning: the contract asks for a raw JSON object. - if let Some(json) = json_object(message) - && let Ok(review_result) = serde_json::from_str::(json) - { - warn!( - "Model answer is not a raw JSON object, it was extracted from the surrounding text" - ); - - return Ok(review_result); - } - - // The reason of the rejection is logged along with the answer: without it, - // a broken answer is impossible to diagnose. - warn!( - answer = %excerpt(message, MAX_LOGGED_ANSWER), - reason = %error, - "Model answer is not the expected JSON" - ); - - anyhow::bail!("the answer is not valid JSON: {error}") - } -} - -/// Returns the outermost `{...}` of an answer, which ignores a markdown code -/// fence or any text around it. -fn json_object(message: &str) -> Option<&str> { - let start = message.find('{')?; - let end = message.rfind('}')?; - - (start < end).then(|| &message[start..=end]) -} - -/// Resolves the side each review is anchored on and drops the reviews that do -/// not match a line the pull request changes. -/// -/// The model is asked to pick a line and a side from the provided lists, but -/// nothing forces it to, and Gitea accepts any position: a wrong one places the -/// comment on an unrelated line of the file instead of failing. A review that -/// omits its side is resolved from the lists, and one that matches no changed -/// line is dropped. -fn resolve_review_sides(review_result: &mut ReviewResult, changed_lines: &ChangedLines) { - let mut dropped = Vec::new(); - - review_result.reviews.retain_mut(|review| { - let side = review.line.and_then(|line| { - let file = changed_lines - .iter() - .find(|file| file.filename == review.filename)?; - - let side = review.side.or_else(|| file.side_of(line))?; - - file.lines(side).contains(&line).then_some(side) - }); - - match side { - Some(side) => { - review.side = Some(side); - true - } - None => { - dropped.push(match review.line { - Some(line) => format!("{}:{line}", review.filename), - None => format!("{}:no line", review.filename), - }); - false - } - } - }); - - if !dropped.is_empty() { - warn!( - dropped = dropped.len(), - reviews = %dropped.join(", "), - "Dropped reviews that are not anchored on a changed line" - ); - } -} - -/// Extracts the old and new starting line numbers of a hunk header such as -/// `@@ -12,3 +12,5 @@`. The counts are optional and git may append a section -/// heading after the closing `@@`. -fn parse_hunk_starts(hunk_header: &str) -> Option<(u64, u64)> { - let body = hunk_header.strip_prefix("@@ ")?; - let body = body.split(" @@").next()?; - - let (old, new) = body.split_once(" +")?; - let old = old.strip_prefix('-')?.split(',').next()?; - let new = new.split(',').next()?; - - Some((old.parse().ok()?, new.parse().ok()?)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::bot::ReviewItem; - - /// Additions, a removal and a line changed on both sides. - const DIFF: &str = concat!( - "diff --git a/src/foo.rs b/src/foo.rs\n", - "--- a/src/foo.rs\n", - "+++ b/src/foo.rs\n", - "@@ -1,4 +1,6 @@\n", - " fn main() {\n", - "+ let x = 1;\n", - "- let removed = 0;\n", - " println!(\"hello\");\n", - "+ let y = 2;\n", - "+ let z = 3;\n", - " }\n", - "diff --git a/src/bar.rs b/src/bar.rs\n", - "--- a/src/bar.rs\n", - "+++ b/src/bar.rs\n", - "@@ -10,4 +10,6 @@\n", - " old context\n", - "+ let a = 10;\n", - " more context\n", - "+ let b = 20;\n", - ); - - /// A pull request that only deletes a file. - const DELETION_ONLY: &str = concat!( - "diff --git a/src/old.rs b/src/old.rs\n", - "deleted file mode 100644\n", - "--- a/src/old.rs\n", - "+++ /dev/null\n", - "@@ -1,3 +0,0 @@\n", - "-fn a() {}\n", - "-fn b() {}\n", - "-fn c() {}\n", - ); - - /// A hunk whose content starts with `+++` / `---`, which must not be taken - /// for the file headers. - const TRICKY_CONTENT: &str = concat!( - "diff --git a/src/tricky.js b/src/tricky.js\n", - "--- a/src/tricky.js\n", - "+++ b/src/tricky.js\n", - "@@ -1,4 +1,4 @@\n", - " let i = 0;\n", - "+++i;\n", - "---x;\n", - " console.log(i);\n", - ); - - /// A pull request that renames a file. - const RENAMED: &str = concat!( - "diff --git a/src/old.rs b/src/new.rs\n", - "similarity index 50%\n", - "rename from src/old.rs\n", - "rename to src/new.rs\n", - "--- a/src/old.rs\n", - "+++ b/src/new.rs\n", - "@@ -1,1 +1,1 @@\n", - "-fn old() {}\n", - "+fn new() {}\n", - ); - - /// A file whose path contains a space, which the `diff --git` line cannot - /// express without ambiguity. - const PATH_WITH_SPACE: &str = concat!( - "diff --git a/src/my file.rs b/src/my file.rs\n", - "--- a/src/my file.rs\n", - "+++ b/src/my file.rs\n", - "@@ -1,1 +1,2 @@\n", - " fn a() {}\n", - "+fn b() {}\n", - ); - - /// A file whose path git quotes and escapes (`caf\\303\\251.md` is - /// `caf\u{e9}.md`). - const QUOTED_PATH: &str = concat!( - "diff --git \"a/docs/caf\\303\\251.md\" \"b/docs/caf\\303\\251.md\"\n", - "--- \"a/docs/caf\\303\\251.md\"\n", - "+++ \"b/docs/caf\\303\\251.md\"\n", - "@@ -1,1 +1,2 @@\n", - " intro\n", - "+ajout\n", - ); - - fn review(filename: &str, line: Option, side: Option) -> ReviewItem { - ReviewItem { - filename: String::from(filename), - line, - side, - severity: None, - message: String::from("message"), - } - } - - fn review_result(reviews: Vec) -> ReviewResult { - ReviewResult { - reviews, - comment: String::new(), - cost: None, - } - } - - fn pull_request_file( - filename: &str, - previous_filename: Option<&str>, - status: &str, - ) -> PullRequestFile { - PullRequestFile { - filename: String::from(filename), - previous_filename: previous_filename.map(String::from), - status: String::from(status), - } - } - - #[test] - fn changed_lines_are_listed_per_file_and_side() { - let expected = concat!( - "src/foo.rs: added 2, 4, 5 / removed 2\n", - "src/bar.rs: added 11, 13" - ); - - assert_eq!(format_changed_lines(&parse_changed_lines(DIFF)), expected); - } - - #[test] - fn a_deletion_only_pull_request_lists_removed_lines() { - let expected = "src/old.rs: removed 1, 2, 3"; - - assert_eq!( - format_changed_lines(&parse_changed_lines(DELETION_ONLY)), - expected - ); - } - - #[test] - fn hunk_content_starting_with_plus_or_minus_is_counted() { - let expected = "src/tricky.js: added 2 / removed 2"; - - assert_eq!( - format_changed_lines(&parse_changed_lines(TRICKY_CONTENT)), - expected - ); - } - - #[test] - fn a_renamed_file_uses_its_new_path() { - let expected = "src/new.rs: added 1 / removed 1"; - - assert_eq!( - format_changed_lines(&parse_changed_lines(RENAMED)), - expected - ); - } - - #[test] - fn a_path_with_a_space_is_read_from_the_headers() { - let expected = "src/my file.rs: added 2"; - - assert_eq!( - format_changed_lines(&parse_changed_lines(PATH_WITH_SPACE)), - expected - ); - } - - #[test] - fn a_quoted_path_is_decoded() { - let expected = "docs/café.md: added 2"; - - assert_eq!( - format_changed_lines(&parse_changed_lines(QUOTED_PATH)), - expected - ); - } - - #[test] - fn filenames_are_resolved_against_the_api_list() { - let mut changed_lines = parse_changed_lines(DIFF); - let files = vec![ - pull_request_file("src/bar.rs", None, "modified"), - pull_request_file("src/foo.rs", Some("src/renamed.rs"), "renamed"), - ]; - - resolve_filenames(&mut changed_lines, &files); - - assert_eq!(changed_lines[0].filename, "src/foo.rs"); - assert_eq!(changed_lines[1].filename, "src/bar.rs"); - } - - #[test] - fn a_file_absent_from_the_api_list_keeps_the_diff_path() { - let mut changed_lines = parse_changed_lines(DIFF); - let files = vec![pull_request_file("src/bar.rs", None, "modified")]; - - resolve_filenames(&mut changed_lines, &files); - - assert_eq!(changed_lines[0].filename, "src/foo.rs"); - assert_eq!(changed_lines[1].filename, "src/bar.rs"); - } - - #[test] - fn changes_describe_the_files_then_the_lines() { - let changed_lines = parse_changed_lines(DELETION_ONLY); - let files = vec![pull_request_file("src/old.rs", None, "deleted")]; - - let expected = concat!( - "Files changed by the pull request: src/old.rs (deleted)\n", - "\n", - "Lines to review, per file:\n", - "src/old.rs: removed 1, 2, 3" - ); - - assert_eq!(format_changes(&files, &changed_lines), expected); - } - - #[test] - fn changes_without_the_api_list_only_hold_the_lines() { - let changed_lines = parse_changed_lines(DELETION_ONLY); - - let expected = "Lines to review, per file:\nsrc/old.rs: removed 1, 2, 3"; - - assert_eq!(format_changes(&[], &changed_lines), expected); - } - - #[test] - fn generated_lockfiles_are_ignored() { - assert!(is_ignored("Cargo.lock")); - assert!(is_ignored("crates/steep-server/Cargo.lock")); - assert!(is_ignored("package-lock.json")); - assert!(is_ignored("web/yarn.lock")); - assert!(is_ignored("go.sum")); - - assert!(!is_ignored("src/main.rs")); - assert!(!is_ignored("Cargo.toml")); - assert!(!is_ignored("docs/lockfiles.md")); - } - - #[test] - fn generated_files_are_dropped_from_the_changed_lines() { - const DIFF_WITH_LOCKFILE: &str = concat!( - "diff --git a/Cargo.lock b/Cargo.lock\n", - "--- a/Cargo.lock\n", - "+++ b/Cargo.lock\n", - "@@ -1,1 +1,2 @@\n", - " [[package]]\n", - "+name = \"x\"\n", - "diff --git a/src/foo.rs b/src/foo.rs\n", - "--- a/src/foo.rs\n", - "+++ b/src/foo.rs\n", - "@@ -1,1 +1,2 @@\n", - " fn a() {}\n", - "+fn b() {}\n", - ); - - let mut changed_lines = parse_changed_lines(DIFF_WITH_LOCKFILE); - drop_generated_files(&mut changed_lines); - - assert_eq!(format_changed_lines(&changed_lines), "src/foo.rs: added 2"); - } - - #[test] - fn a_generated_file_is_not_described_to_the_model() { - let files = vec![ - pull_request_file("Cargo.lock", None, "modified"), - pull_request_file("src/foo.rs", None, "modified"), - ]; - let changed_lines = parse_changed_lines(DIFF); - - let changes = format_changes(&files, &changed_lines); - - assert!(!changes.contains("Cargo.lock")); - assert!(changes.contains("src/foo.rs (modified)")); - } - - #[test] - fn a_renamed_file_is_described_with_its_previous_path() { - let file = pull_request_file("src/new.rs", Some("src/old.rs"), "renamed"); - - assert_eq!(describe_file(&file), "src/new.rs (renamed from src/old.rs)"); - } - - #[test] - fn reviews_keep_their_changed_line_and_resolve_their_side() { - let changed_lines = parse_changed_lines(DIFF); - let mut review_result = review_result(vec![ - review("src/foo.rs", Some(4), Some(ReviewSide::Added)), - review("src/foo.rs", Some(2), Some(ReviewSide::Removed)), - review("src/bar.rs", Some(13), None), - ]); - - resolve_review_sides(&mut review_result, &changed_lines); - - let sides = review_result - .reviews - .iter() - .map(|review| review.side) - .collect::>(); - - assert_eq!( - sides, - vec![ - Some(ReviewSide::Added), - Some(ReviewSide::Removed), - Some(ReviewSide::Added) - ] - ); - } - - #[test] - fn a_line_changed_on_both_sides_defaults_to_added() { - let changed_lines = parse_changed_lines(DIFF); - let mut review_result = review_result(vec![review("src/foo.rs", Some(2), None)]); - - resolve_review_sides(&mut review_result, &changed_lines); - - assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Added)); - } - - #[test] - fn reviews_outside_changed_lines_are_dropped() { - let changed_lines = parse_changed_lines(DIFF); - let mut review_result = review_result(vec![ - // valid - review("src/foo.rs", Some(2), Some(ReviewSide::Added)), - // a line that exists but is not part of the change - review("src/foo.rs", Some(3), None), - // a line beyond the change - review("src/foo.rs", Some(999), None), - // a changed line, but on the wrong side - review("src/foo.rs", Some(4), Some(ReviewSide::Removed)), - // a line changed in another file - review("src/foo.rs", Some(11), None), - // an unknown file - review("src/baz.rs", Some(1), None), - // no line at all - review("src/bar.rs", None, None), - ]); - - resolve_review_sides(&mut review_result, &changed_lines); - - assert_eq!(review_result.reviews.len(), 1); - assert_eq!(review_result.reviews[0].filename, "src/foo.rs"); - assert_eq!(review_result.reviews[0].line, Some(2)); - assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Added)); - } - - #[test] - fn a_deleted_line_is_kept_as_a_removed_anchor() { - let changed_lines = parse_changed_lines(DELETION_ONLY); - let mut review_result = review_result(vec![ - review("src/old.rs", Some(2), Some(ReviewSide::Removed)), - review("src/old.rs", Some(2), Some(ReviewSide::Added)), - ]); - - resolve_review_sides(&mut review_result, &changed_lines); - - assert_eq!(review_result.reviews.len(), 1); - assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Removed)); - } - - #[test] - fn a_raw_answer_is_parsed() { - let answer = r#"{"reviews": [], "comment": "ok"}"#; - - assert_eq!(answer.parse::().unwrap().comment, "ok"); - } - - #[test] - fn a_fenced_answer_is_extracted() { - let answer = concat!( - "Voici ma review :\n", - "```json\n", - "{\"reviews\": [], \"comment\": \"rien à signaler\"}\n", - "```\n" - ); - - assert_eq!( - answer.parse::().unwrap().comment, - "rien à signaler" - ); - } - - #[test] - fn a_sentence_around_the_object_is_ignored() { - let answer = r#"Rien à signaler. {"reviews": [], "comment": "ok"} Bonne journée !"#; - - assert_eq!(answer.parse::().unwrap().comment, "ok"); - } - - #[test] - fn an_answer_without_json_is_rejected() { - assert!("Je n'ai rien relevé.".parse::().is_err()); - } - - #[test] - fn an_answer_that_is_an_object_but_not_a_review_is_rejected() { - assert!(r#"{"message": "LGTM"}"#.parse::().is_err()); - } - - #[test] - fn odd_sides_from_the_model_are_tolerated() { - let changed_lines = parse_changed_lines(DIFF); - let mut review_result: ReviewResult = serde_json::from_str( - r#"{ - "reviews": [ - { "filename": "src/foo.rs", "line": 4, "side": "Added", "message": "a" }, - { "filename": "src/foo.rs", "line": 2, "side": "new", "message": "b" }, - { "filename": "src/foo.rs", "line": 2, "side": "REMOVED", "message": "c" }, - { "filename": "src/foo.rs", "line": 2, "side": "banana", "message": "d" }, - { "filename": "src/foo.rs", "line": 5, "message": "e" } - ], - "comment": "" - }"#, - ) - .unwrap(); - - resolve_review_sides(&mut review_result, &changed_lines); - - let sides = review_result - .reviews - .iter() - .map(|review| review.side) - .collect::>(); - - assert_eq!( - sides, - vec![ - Some(ReviewSide::Added), - Some(ReviewSide::Added), - Some(ReviewSide::Removed), - Some(ReviewSide::Added), - Some(ReviewSide::Added) - ] - ); - } - - #[test] - fn severities_are_read_from_the_model() { - let result: ReviewResult = serde_json::from_str( - r#"{ - "reviews": [ - { "filename": "a", "line": 1, "severity": "Bug", "message": "x" }, - { "filename": "b", "line": 2, "severity": "SECURITY", "message": "x" }, - { "filename": "c", "line": 3, "severity": "perf", "message": "x" }, - { "filename": "d", "line": 4, "severity": "banana", "message": "x" } - ], - "comment": "" - }"#, - ) - .unwrap(); - - let severities = result - .reviews - .iter() - .map(|review| review.severity) - .collect::>(); - - assert_eq!( - severities, - vec![ - Some(ReviewSeverity::Bug), - Some(ReviewSeverity::Security), - Some(ReviewSeverity::Performance), - None, - ] - ); - } - - #[test] - fn the_breakdown_counts_severities_most_severe_first() { - let reviews = vec![ - ReviewItem { - severity: Some(ReviewSeverity::Maintainability), - ..review("a", Some(1), None) - }, - ReviewItem { - severity: Some(ReviewSeverity::Bug), - ..review("b", Some(2), None) - }, - ReviewItem { - severity: Some(ReviewSeverity::Bug), - ..review("c", Some(3), None) - }, - // A severity the model left out counts as maintainability. - review("d", Some(4), None), - ]; - - assert_eq!(severity_breakdown(&reviews), "- 2 bug\n- 2 maintainability"); - } - - #[test] - fn the_markdown_breaks_the_issues_down_by_severity() { - let mut result = review_result(vec![ - ReviewItem { - severity: Some(ReviewSeverity::Bug), - ..review("a", Some(1), None) - }, - ReviewItem { - severity: Some(ReviewSeverity::Performance), - ..review("b", Some(2), None) - }, - ]); - result.comment = String::from("Le découpage en crates est propre."); - - let markdown = review_result_to_markdown(&result); - - assert!(markdown.contains("- 1 bug")); - assert!(markdown.contains("- 1 performance")); - assert!(markdown.contains("Le découpage en crates est propre.")); - } -} diff --git a/crates/steep-server/src/bot_actions/review/anchor.rs b/crates/steep-server/src/bot_actions/review/anchor.rs new file mode 100644 index 0000000..0691188 --- /dev/null +++ b/crates/steep-server/src/bot_actions/review/anchor.rs @@ -0,0 +1,177 @@ +//! Anchoring of the model's reviews on the lines a pull request changes. + +use tracing::warn; + +use super::diff::ChangedLines; +use crate::domain::review::ReviewResult; + +/// Resolves the side each review is anchored on and drops the reviews that do +/// not match a line the pull request changes. +/// +/// The model is asked to pick a line and a side from the provided lists, but +/// nothing forces it to, and Gitea accepts any position: a wrong one places the +/// comment on an unrelated line of the file instead of failing. A review that +/// omits its side is resolved from the lists, and one that matches no changed +/// line is dropped. +pub(super) fn resolve_review_sides(review_result: &mut ReviewResult, changed_lines: &ChangedLines) { + let mut dropped = Vec::new(); + + review_result.reviews.retain_mut(|review| { + let side = review.line.and_then(|line| { + let file = changed_lines + .iter() + .find(|file| file.filename == review.filename)?; + + let side = review.side.or_else(|| file.side_of(line))?; + + file.lines(side).contains(&line).then_some(side) + }); + + match side { + Some(side) => { + review.side = Some(side); + true + } + None => { + dropped.push(match review.line { + Some(line) => format!("{}:{line}", review.filename), + None => format!("{}:no line", review.filename), + }); + false + } + } + }); + + if !dropped.is_empty() { + warn!( + dropped = dropped.len(), + reviews = %dropped.join(", "), + "Dropped reviews that are not anchored on a changed line" + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + bot_actions::review::{diff::parse_changed_lines, fixtures::*}, + domain::review::ReviewSide, + }; + + #[test] + fn reviews_keep_their_changed_line_and_resolve_their_side() { + let changed_lines = parse_changed_lines(DIFF); + let mut review_result = review_result(vec![ + review("src/foo.rs", Some(4), Some(ReviewSide::Added)), + review("src/foo.rs", Some(2), Some(ReviewSide::Removed)), + review("src/bar.rs", Some(13), None), + ]); + + resolve_review_sides(&mut review_result, &changed_lines); + + let sides = review_result + .reviews + .iter() + .map(|review| review.side) + .collect::>(); + + assert_eq!( + sides, + vec![ + Some(ReviewSide::Added), + Some(ReviewSide::Removed), + Some(ReviewSide::Added) + ] + ); + } + + #[test] + fn a_line_changed_on_both_sides_defaults_to_added() { + let changed_lines = parse_changed_lines(DIFF); + let mut review_result = review_result(vec![review("src/foo.rs", Some(2), None)]); + + resolve_review_sides(&mut review_result, &changed_lines); + + assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Added)); + } + + #[test] + fn reviews_outside_changed_lines_are_dropped() { + let changed_lines = parse_changed_lines(DIFF); + let mut review_result = review_result(vec![ + // valid + review("src/foo.rs", Some(2), Some(ReviewSide::Added)), + // a line that exists but is not part of the change + review("src/foo.rs", Some(3), None), + // a line beyond the change + review("src/foo.rs", Some(999), None), + // a changed line, but on the wrong side + review("src/foo.rs", Some(4), Some(ReviewSide::Removed)), + // a line changed in another file + review("src/foo.rs", Some(11), None), + // an unknown file + review("src/baz.rs", Some(1), None), + // no line at all + review("src/bar.rs", None, None), + ]); + + resolve_review_sides(&mut review_result, &changed_lines); + + assert_eq!(review_result.reviews.len(), 1); + assert_eq!(review_result.reviews[0].filename, "src/foo.rs"); + assert_eq!(review_result.reviews[0].line, Some(2)); + assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Added)); + } + + #[test] + fn a_deleted_line_is_kept_as_a_removed_anchor() { + let changed_lines = parse_changed_lines(DELETION_ONLY); + let mut review_result = review_result(vec![ + review("src/old.rs", Some(2), Some(ReviewSide::Removed)), + review("src/old.rs", Some(2), Some(ReviewSide::Added)), + ]); + + resolve_review_sides(&mut review_result, &changed_lines); + + assert_eq!(review_result.reviews.len(), 1); + assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Removed)); + } + + #[test] + fn odd_sides_from_the_model_are_tolerated() { + let changed_lines = parse_changed_lines(DIFF); + let mut review_result: ReviewResult = serde_json::from_str( + r#"{ + "reviews": [ + { "filename": "src/foo.rs", "line": 4, "side": "Added", "message": "a" }, + { "filename": "src/foo.rs", "line": 2, "side": "new", "message": "b" }, + { "filename": "src/foo.rs", "line": 2, "side": "REMOVED", "message": "c" }, + { "filename": "src/foo.rs", "line": 2, "side": "banana", "message": "d" }, + { "filename": "src/foo.rs", "line": 5, "message": "e" } + ], + "comment": "" + }"#, + ) + .unwrap(); + + resolve_review_sides(&mut review_result, &changed_lines); + + let sides = review_result + .reviews + .iter() + .map(|review| review.side) + .collect::>(); + + assert_eq!( + sides, + vec![ + Some(ReviewSide::Added), + Some(ReviewSide::Added), + Some(ReviewSide::Removed), + Some(ReviewSide::Added), + Some(ReviewSide::Added) + ] + ); + } +} diff --git a/crates/steep-server/src/bot_actions/review/diff.rs b/crates/steep-server/src/bot_actions/review/diff.rs new file mode 100644 index 0000000..3a0bc5a --- /dev/null +++ b/crates/steep-server/src/bot_actions/review/diff.rs @@ -0,0 +1,389 @@ +//! Parsing of a pull request unified diff into the lines it changes. + +use tracing::warn; + +use crate::{domain::review::ReviewSide, gitea::PullRequestFile}; + +/// The lines a pull request changed, per file. +/// +/// Line numbers are the ones Gitea expects to place a review comment: +/// [`ReviewSide::Added`] numbers refer to the new version of the file (sent as +/// `new_position`), [`ReviewSide::Removed`] numbers to the old version (sent as +/// `old_position`). +pub(super) type ChangedLines = Vec; + +pub(super) struct ChangedFile { + pub(super) filename: String, + pub(super) added: Vec, + pub(super) removed: Vec, +} + +impl ChangedFile { + fn new(filename: &str) -> Self { + Self { + filename: String::from(filename), + added: Vec::new(), + removed: Vec::new(), + } + } + + /// Lines that can be commented on for the given side. + pub(super) fn lines(&self, side: ReviewSide) -> &[u64] { + match side { + ReviewSide::Added => &self.added, + ReviewSide::Removed => &self.removed, + } + } + + /// Side a line belongs to, used when the model did not state one. + pub(super) fn side_of(&self, line: u64) -> Option { + if self.added.contains(&line) { + Some(ReviewSide::Added) + } else if self.removed.contains(&line) { + Some(ReviewSide::Removed) + } else { + None + } + } +} + +/// Lists the lines changed by the diff, per file, on both sides. +/// +/// Only the line numbers are kept: the model reads the code itself through the +/// sandbox tools. +pub(super) fn parse_changed_lines(git_diff: &str) -> ChangedLines { + let mut files = Vec::new(); + let mut current_file: Option = None; + let mut in_hunk = false; + let mut old_line: u64 = 0; + let mut new_line: u64 = 0; + + for line in git_diff.lines() { + if line.starts_with("diff --git ") { + current_file = None; + in_hunk = false; + continue; + } + + // `--- a/x` and `+++ b/x` only appear before the first hunk of a file. + // Inside a hunk, a line may legitimately start with them: removing + // `--x` gives `---x`, adding `++i;` gives `+++i;`. + if !in_hunk && (line.starts_with("--- ") || line.starts_with("+++ ")) { + // A header is never a content line: `+++ /dev/null` on a deleted + // file keeps the path of the other side in place. + if let Some(path) = header_file_path(line) { + current_file = Some(path); + } + continue; + } + + if line.starts_with("@@") { + if let Some((old_start, new_start)) = parse_hunk_starts(line) { + old_line = old_start; + new_line = new_start; + in_hunk = true; + } + continue; + } + + let Some(filename) = current_file.as_deref() else { + continue; + }; + + match line.as_bytes().first() { + Some(b' ') => { + old_line += 1; + new_line += 1; + } + Some(b'-') => { + changed_file(&mut files, filename).removed.push(old_line); + old_line += 1; + } + Some(b'+') => { + changed_file(&mut files, filename).added.push(new_line); + new_line += 1; + } + // `\ No newline at end of file`, and anything unexpected: a line + // that advances neither side. + _ => {} + } + } + + files +} + +/// Path of the file on one side of the diff, from a `--- a/` or +/// `+++ b/` header line. +/// +/// These lines are the only unambiguous source for the path: the `diff --git` +/// line is cut at the first space, and it names the old path of a renamed file. +/// `None` for `/dev/null`, which leaves the path of the other side in place. +fn header_file_path(line: &str) -> Option { + let (prefix, raw) = match line.strip_prefix("--- ") { + Some(raw) => ("a/", raw), + None => ("b/", line.strip_prefix("+++ ")?), + }; + + let path = decode_git_path(raw); + + Some(String::from(path.strip_prefix(prefix)?)) +} + +/// Decodes a path as git writes it in a diff header: git wraps it in quotes and +/// escapes the bytes that need it (`\303\251` for `é`) when the path contains +/// non-printable or non-ASCII characters. +fn decode_git_path(raw: &str) -> String { + let Some(quoted) = raw.strip_prefix('"').and_then(|raw| raw.strip_suffix('"')) else { + return String::from(raw); + }; + + let bytes = quoted.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + + while let Some(byte) = bytes.get(index) { + index += 1; + + if *byte != b'\\' { + decoded.push(*byte); + continue; + } + + match bytes.get(index) { + // Octal escapes are the ones that matter for a path: git uses them + // for every non-ASCII byte. + Some(digit @ b'0'..=b'7') => { + let mut value = u32::from(digit - b'0'); + let mut digits = 1; + + while digits < 3 { + match bytes.get(index + digits) { + Some(next @ b'0'..=b'7') => { + value = value * 8 + u32::from(next - b'0'); + digits += 1; + } + _ => break, + } + } + + decoded.push(u8::try_from(value).unwrap_or(b'?')); + index += digits; + } + Some(escaped) => { + decoded.push(match escaped { + b't' => b'\t', + b'n' => b'\n', + b'r' => b'\r', + other => *other, + }); + index += 1; + } + None => decoded.push(b'\\'), + } + } + + String::from_utf8_lossy(&decoded).into_owned() +} + +/// Replaces the paths parsed from the diff with the exact paths reported by the +/// API, which are the ones the model sees in the sandbox. +pub(super) fn resolve_filenames(changed_lines: &mut ChangedLines, files: &[PullRequestFile]) { + for changed in changed_lines.iter_mut() { + let parsed = changed.filename.as_str(); + + let Some(file) = files.iter().find(|file| file.filename == parsed) else { + if !files.is_empty() { + warn!(path = %parsed, "Changed file is not in the pull request file list"); + } + continue; + }; + + changed.filename = file.filename.clone(); + } +} + +/// Basenames of generated files that are never reviewed. +/// +/// Their diffs are machine-written dependency churn: they run to thousands of +/// lines, flood the prompt with line numbers and drown the code the model should +/// look at. A lockfile is also never a place where a review comment belongs. +const IGNORED_FILE_NAMES: [&str; 16] = [ + "Cargo.lock", + "package-lock.json", + "npm-shrinkwrap.json", + "yarn.lock", + "pnpm-lock.yaml", + "bun.lock", + "bun.lockb", + "composer.lock", + "Gemfile.lock", + "poetry.lock", + "uv.lock", + "Pipfile.lock", + "go.sum", + "packages.lock.json", + "flake.lock", + "pubspec.lock", +]; + +/// Whether a changed path is a generated file that is never reviewed. +/// +/// The comparison is on the basename, so a lockfile nested in a workspace (for +/// example `crates/foo/Cargo.lock`) is matched too. +pub(super) fn is_ignored(filename: &str) -> bool { + let basename = filename.rsplit('/').next().unwrap_or(filename); + + IGNORED_FILE_NAMES.contains(&basename) +} + +/// Drops the generated files from the changed lines. +/// +/// Removing them here keeps the prompt free of their line numbers, and also makes +/// [`resolve_review_sides`] reject any review the model anchors on one of them. +pub(super) fn drop_generated_files(changed_lines: &mut ChangedLines) { + changed_lines.retain(|file| !is_ignored(&file.filename)); +} + +/// Borrows the entry of `files` for a file, creating it on its first change. +fn changed_file<'a>(files: &'a mut ChangedLines, filename: &str) -> &'a mut ChangedFile { + let index = match files.iter().position(|file| file.filename == filename) { + Some(index) => index, + None => { + files.push(ChangedFile::new(filename)); + files.len() - 1 + } + }; + + &mut files[index] +} + +/// Extracts the old and new starting line numbers of a hunk header such as +/// `@@ -12,3 +12,5 @@`. The counts are optional and git may append a section +/// heading after the closing `@@`. +fn parse_hunk_starts(hunk_header: &str) -> Option<(u64, u64)> { + let body = hunk_header.strip_prefix("@@ ")?; + let body = body.split(" @@").next()?; + + let (old, new) = body.split_once(" +")?; + let old = old.strip_prefix('-')?.split(',').next()?; + let new = new.split(',').next()?; + + Some((old.parse().ok()?, new.parse().ok()?)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bot_actions::review::{fixtures::*, prompt::format_changed_lines}; + + #[test] + fn a_deletion_only_pull_request_lists_removed_lines() { + let expected = "src/old.rs: removed 1, 2, 3"; + + assert_eq!( + format_changed_lines(&parse_changed_lines(DELETION_ONLY)), + expected + ); + } + + #[test] + fn hunk_content_starting_with_plus_or_minus_is_counted() { + let expected = "src/tricky.js: added 2 / removed 2"; + + assert_eq!( + format_changed_lines(&parse_changed_lines(TRICKY_CONTENT)), + expected + ); + } + + #[test] + fn a_renamed_file_uses_its_new_path() { + let expected = "src/new.rs: added 1 / removed 1"; + + assert_eq!( + format_changed_lines(&parse_changed_lines(RENAMED)), + expected + ); + } + + #[test] + fn a_path_with_a_space_is_read_from_the_headers() { + let expected = "src/my file.rs: added 2"; + + assert_eq!( + format_changed_lines(&parse_changed_lines(PATH_WITH_SPACE)), + expected + ); + } + + #[test] + fn a_quoted_path_is_decoded() { + let expected = "docs/café.md: added 2"; + + assert_eq!( + format_changed_lines(&parse_changed_lines(QUOTED_PATH)), + expected + ); + } + + #[test] + fn filenames_are_resolved_against_the_api_list() { + let mut changed_lines = parse_changed_lines(DIFF); + let files = vec![ + pull_request_file("src/bar.rs", None, "modified"), + pull_request_file("src/foo.rs", Some("src/renamed.rs"), "renamed"), + ]; + + resolve_filenames(&mut changed_lines, &files); + + assert_eq!(changed_lines[0].filename, "src/foo.rs"); + assert_eq!(changed_lines[1].filename, "src/bar.rs"); + } + + #[test] + fn a_file_absent_from_the_api_list_keeps_the_diff_path() { + let mut changed_lines = parse_changed_lines(DIFF); + let files = vec![pull_request_file("src/bar.rs", None, "modified")]; + + resolve_filenames(&mut changed_lines, &files); + + assert_eq!(changed_lines[0].filename, "src/foo.rs"); + assert_eq!(changed_lines[1].filename, "src/bar.rs"); + } + + #[test] + fn generated_lockfiles_are_ignored() { + assert!(is_ignored("Cargo.lock")); + assert!(is_ignored("crates/steep-server/Cargo.lock")); + assert!(is_ignored("package-lock.json")); + assert!(is_ignored("web/yarn.lock")); + assert!(is_ignored("go.sum")); + + assert!(!is_ignored("src/main.rs")); + assert!(!is_ignored("Cargo.toml")); + assert!(!is_ignored("docs/lockfiles.md")); + } + + #[test] + fn generated_files_are_dropped_from_the_changed_lines() { + const DIFF_WITH_LOCKFILE: &str = concat!( + "diff --git a/Cargo.lock b/Cargo.lock\n", + "--- a/Cargo.lock\n", + "+++ b/Cargo.lock\n", + "@@ -1,1 +1,2 @@\n", + " [[package]]\n", + "+name = \"x\"\n", + "diff --git a/src/foo.rs b/src/foo.rs\n", + "--- a/src/foo.rs\n", + "+++ b/src/foo.rs\n", + "@@ -1,1 +1,2 @@\n", + " fn a() {}\n", + "+fn b() {}\n", + ); + + let mut changed_lines = parse_changed_lines(DIFF_WITH_LOCKFILE); + drop_generated_files(&mut changed_lines); + + assert_eq!(format_changed_lines(&changed_lines), "src/foo.rs: added 2"); + } +} diff --git a/crates/steep-server/src/bot_actions/review/fixtures.rs b/crates/steep-server/src/bot_actions/review/fixtures.rs new file mode 100644 index 0000000..a90a29d --- /dev/null +++ b/crates/steep-server/src/bot_actions/review/fixtures.rs @@ -0,0 +1,119 @@ +//! Shared fixtures of the review tests. + +use crate::{ + domain::review::{ReviewItem, ReviewResult, ReviewSide}, + gitea::PullRequestFile, +}; + +/// Additions, a removal and a line changed on both sides. +pub(super) const DIFF: &str = concat!( + "diff --git a/src/foo.rs b/src/foo.rs\n", + "--- a/src/foo.rs\n", + "+++ b/src/foo.rs\n", + "@@ -1,4 +1,6 @@\n", + " fn main() {\n", + "+ let x = 1;\n", + "- let removed = 0;\n", + " println!(\"hello\");\n", + "+ let y = 2;\n", + "+ let z = 3;\n", + " }\n", + "diff --git a/src/bar.rs b/src/bar.rs\n", + "--- a/src/bar.rs\n", + "+++ b/src/bar.rs\n", + "@@ -10,4 +10,6 @@\n", + " old context\n", + "+ let a = 10;\n", + " more context\n", + "+ let b = 20;\n", +); + +/// A pull request that only deletes a file. +pub(super) const DELETION_ONLY: &str = concat!( + "diff --git a/src/old.rs b/src/old.rs\n", + "deleted file mode 100644\n", + "--- a/src/old.rs\n", + "+++ /dev/null\n", + "@@ -1,3 +0,0 @@\n", + "-fn a() {}\n", + "-fn b() {}\n", + "-fn c() {}\n", +); + +/// A hunk whose content starts with `+++` / `---`, which must not be taken +/// for the file headers. +pub(super) const TRICKY_CONTENT: &str = concat!( + "diff --git a/src/tricky.js b/src/tricky.js\n", + "--- a/src/tricky.js\n", + "+++ b/src/tricky.js\n", + "@@ -1,4 +1,4 @@\n", + " let i = 0;\n", + "+++i;\n", + "---x;\n", + " console.log(i);\n", +); + +/// A pull request that renames a file. +pub(super) const RENAMED: &str = concat!( + "diff --git a/src/old.rs b/src/new.rs\n", + "similarity index 50%\n", + "rename from src/old.rs\n", + "rename to src/new.rs\n", + "--- a/src/old.rs\n", + "+++ b/src/new.rs\n", + "@@ -1,1 +1,1 @@\n", + "-fn old() {}\n", + "+fn new() {}\n", +); + +/// A file whose path contains a space, which the `diff --git` line cannot +/// express without ambiguity. +pub(super) const PATH_WITH_SPACE: &str = concat!( + "diff --git a/src/my file.rs b/src/my file.rs\n", + "--- a/src/my file.rs\n", + "+++ b/src/my file.rs\n", + "@@ -1,1 +1,2 @@\n", + " fn a() {}\n", + "+fn b() {}\n", +); + +/// A file whose path git quotes and escapes (`caf\\303\\251.md` is +/// `caf\u{e9}.md`). +pub(super) const QUOTED_PATH: &str = concat!( + "diff --git \"a/docs/caf\\303\\251.md\" \"b/docs/caf\\303\\251.md\"\n", + "--- \"a/docs/caf\\303\\251.md\"\n", + "+++ \"b/docs/caf\\303\\251.md\"\n", + "@@ -1,1 +1,2 @@\n", + " intro\n", + "+ajout\n", +); + +pub(super) fn review(filename: &str, line: Option, side: Option) -> ReviewItem { + ReviewItem { + filename: String::from(filename), + line, + side, + severity: None, + message: String::from("message"), + } +} + +pub(super) fn review_result(reviews: Vec) -> ReviewResult { + ReviewResult { + reviews, + comment: String::new(), + cost: None, + } +} + +pub(super) fn pull_request_file( + filename: &str, + previous_filename: Option<&str>, + status: &str, +) -> PullRequestFile { + PullRequestFile { + filename: String::from(filename), + previous_filename: previous_filename.map(String::from), + status: String::from(status), + } +} diff --git a/crates/steep-server/src/bot_actions/review/mod.rs b/crates/steep-server/src/bot_actions/review/mod.rs new file mode 100644 index 0000000..46c6c8e --- /dev/null +++ b/crates/steep-server/src/bot_actions/review/mod.rs @@ -0,0 +1,210 @@ +//! The pull request review action. +//! +//! [`exec_review`] drives one review end to end. The work is split by concern: +//! - [`diff`] parses the pull request diff into the lines it changes, +//! - [`prompt`] renders those changes for the model, +//! - [`anchor`] checks that the model's reviews land on changed lines, +//! - [`render`] builds the markdown summary posted on the pull request. + +mod anchor; +mod diff; +mod prompt; +mod render; + +#[cfg(test)] +mod fixtures; + +use tracing::{info, instrument, warn}; + +use crate::{ + consts::{BOT_PROCESS_MSG, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT}, + domain::review::ReviewResult, + event_bus::Reporter, + gitea::{GiteaAPI, ReviewPayload}, + metrics, + open_router::{OpenRouterClient, Tool}, + sandbox::{Sandbox, SandboxConfig, agent}, +}; + +use self::{ + anchor::resolve_review_sides, + diff::{drop_generated_files, parse_changed_lines, resolve_filenames}, + prompt::format_changes, + render::review_result_to_markdown, +}; + +/// The services a review needs, borrowed from the bot for the duration of one job. +pub struct ReviewContext<'a> { + pub gitea: &'a GiteaAPI, + pub open_router: &'a OpenRouterClient, + pub sandbox: &'a SandboxConfig, + pub reporter: &'a Reporter, +} + +pub struct ReviewOutcome { + pub markdown: String, + pub cost: Option, + pub comments: u64, +} + +impl ReviewOutcome { + pub fn empty() -> Self { + Self { + markdown: String::new(), + cost: None, + comments: 0, + } + } +} + +#[instrument( + skip_all, + fields(repo = %payload.repository.full_name, pr = payload.pull_request.number) +)] +pub async fn exec_review( + ctx: &ReviewContext<'_>, + tools: Vec, + payload: &ReviewPayload, +) -> anyhow::Result { + info!(action = %payload.action, "Starting review"); + + let full_name = &payload.repository.full_name; + let index = payload.pull_request.number; + + let progress_comment = ctx + .gitea + .comment( + &BOT_PROCESS_MSG.replace("{model}", ctx.open_router.model()), + full_name, + index, + ) + .await?; + + let review = review_pull_request(ctx, tools, payload).await; + + // The progress comment is housekeeping: failing to update it must neither turn a + // posted review into a failure nor hide the error that made the review fail. + match review { + Ok((review_result, markdown)) => { + if let Err(err) = ctx + .gitea + .delete_comment(full_name, progress_comment.id) + .await + { + warn!(%err, "Failed to delete the progress comment"); + } + + Ok(ReviewOutcome { + markdown, + cost: review_result.cost, + comments: review_result.reviews.len() as u64, + }) + } + Err(err) => { + if let Err(edit_err) = ctx + .gitea + .edit_comment( + &format!("Error while reviewing: {err}"), + full_name, + progress_comment.id, + ) + .await + { + warn!(err = %edit_err, "Failed to report the error on the progress comment"); + } + + Err(err) + } + } +} + +/// Runs the review and posts it, returning the result and the markdown that was +/// posted. +async fn review_pull_request( + ctx: &ReviewContext<'_>, + tools: Vec, + payload: &ReviewPayload, +) -> anyhow::Result<(ReviewResult, String)> { + let full_name = &payload.repository.full_name; + let index = payload.pull_request.number; + + let git_diff = ctx.gitea.pull_request_diff(full_name, index).await?; + + // The file list only refines the paths and describes the changes: a failure is + // not fatal, the diff is the source of truth for the lines. + let files = match ctx.gitea.pull_request_files(full_name, index).await { + Ok(files) => files, + Err(err) => { + warn!(%err, "Failed to list the pull request files"); + Vec::new() + } + }; + + let mut changed_lines = parse_changed_lines(&git_diff); + drop_generated_files(&mut changed_lines); + resolve_filenames(&mut changed_lines, &files); + + let changes = format_changes(&files, &changed_lines); + + let bot_request = REVIEW_PROMPT + .replace("{subject}", &payload.pull_request.title) + .replace("{comment}", &payload.comment.body) + .replace("{changes}", &changes); + + let (mut review_result, cost) = run_sandboxed_review(ctx, tools, payload, &bot_request).await?; + + resolve_review_sides(&mut review_result, &changed_lines); + + review_result.cost = cost; + if let Some(cost) = review_result.cost { + metrics::openrouter_cost_usd(cost); + } + + let markdown = review_result_to_markdown(&review_result); + + ctx.gitea + .post_pull_request_review(&review_result, &markdown, full_name, index) + .await?; + + Ok((review_result, markdown)) +} + +async fn run_sandboxed_review( + ctx: &ReviewContext<'_>, + tools: Vec, + payload: &ReviewPayload, + bot_request: &str, +) -> anyhow::Result<(ReviewResult, Option)> { + let sandbox = Sandbox::create( + &ctx.sandbox.runtime, + &payload.repository.clone_url, + ctx.gitea.token(), + payload.pull_request.number, + ctx.reporter, + ) + .await?; + + let result = agent::run( + ctx.open_router, + &sandbox, + tools, + SANDBOX_SYSTEM_PROMPT, + bot_request, + ctx.sandbox.max_iterations, + ctx.reporter, + ) + .await; + + if let Err(err) = sandbox.cleanup().await { + warn!(%err, "Failed to clean up sandbox container"); + } + + let result = result?; + info!( + iterations = result.iterations, + cost = ?result.cost, + "Sandboxed review finished" + ); + + Ok((result.answer, result.cost)) +} diff --git a/crates/steep-server/src/bot_actions/review/prompt.rs b/crates/steep-server/src/bot_actions/review/prompt.rs new file mode 100644 index 0000000..c9b5e39 --- /dev/null +++ b/crates/steep-server/src/bot_actions/review/prompt.rs @@ -0,0 +1,125 @@ +//! Rendering of a pull request's changes for the model. + +use super::diff::{ChangedLines, is_ignored}; +use crate::gitea::PullRequestFile; + +/// Renders the changes for the model: the files the pull request touches, then +/// the lines to review per file. +pub(super) fn format_changes(files: &[PullRequestFile], changed_lines: &ChangedLines) -> String { + let mut sections = Vec::new(); + + let described = files + .iter() + .filter(|file| !is_ignored(&file.filename)) + .map(describe_file) + .collect::>() + .join(", "); + + if !described.is_empty() { + sections.push(format!("Files changed by the pull request: {described}")); + } + + sections.push(format!( + "Lines to review, per file:\n{}", + format_changed_lines(changed_lines) + )); + + sections.join("\n\n") +} + +/// Describes a changed file for the model, including how it changed. +fn describe_file(file: &PullRequestFile) -> String { + match &file.previous_filename { + Some(previous) => format!("{} ({} from {})", file.filename, file.status, previous), + None => format!("{} ({})", file.filename, file.status), + } +} + +/// Renders the changed lines as `filename: added 1, 2 / removed 3`, one file per +/// line, keeping only the sides the file actually has. +pub(super) fn format_changed_lines(changed_lines: &ChangedLines) -> String { + changed_lines + .iter() + .map(|file| { + let mut sides = Vec::new(); + + for (label, lines) in [("added", &file.added), ("removed", &file.removed)] { + if !lines.is_empty() { + sides.push(format!("{label} {}", format_line_numbers(lines))); + } + } + + format!("{}: {}", file.filename, sides.join(" / ")) + }) + .collect::>() + .join("\n") +} + +fn format_line_numbers(lines: &[u64]) -> String { + lines + .iter() + .map(u64::to_string) + .collect::>() + .join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bot_actions::review::{diff::parse_changed_lines, fixtures::*}; + + #[test] + fn changed_lines_are_listed_per_file_and_side() { + let expected = concat!( + "src/foo.rs: added 2, 4, 5 / removed 2\n", + "src/bar.rs: added 11, 13" + ); + + assert_eq!(format_changed_lines(&parse_changed_lines(DIFF)), expected); + } + + #[test] + fn changes_describe_the_files_then_the_lines() { + let changed_lines = parse_changed_lines(DELETION_ONLY); + let files = vec![pull_request_file("src/old.rs", None, "deleted")]; + + let expected = concat!( + "Files changed by the pull request: src/old.rs (deleted)\n", + "\n", + "Lines to review, per file:\n", + "src/old.rs: removed 1, 2, 3" + ); + + assert_eq!(format_changes(&files, &changed_lines), expected); + } + + #[test] + fn changes_without_the_api_list_only_hold_the_lines() { + let changed_lines = parse_changed_lines(DELETION_ONLY); + + let expected = "Lines to review, per file:\nsrc/old.rs: removed 1, 2, 3"; + + assert_eq!(format_changes(&[], &changed_lines), expected); + } + + #[test] + fn a_generated_file_is_not_described_to_the_model() { + let files = vec![ + pull_request_file("Cargo.lock", None, "modified"), + pull_request_file("src/foo.rs", None, "modified"), + ]; + let changed_lines = parse_changed_lines(DIFF); + + let changes = format_changes(&files, &changed_lines); + + assert!(!changes.contains("Cargo.lock")); + assert!(changes.contains("src/foo.rs (modified)")); + } + + #[test] + fn a_renamed_file_is_described_with_its_previous_path() { + let file = pull_request_file("src/new.rs", Some("src/old.rs"), "renamed"); + + assert_eq!(describe_file(&file), "src/new.rs (renamed from src/old.rs)"); + } +} diff --git a/crates/steep-server/src/bot_actions/review/render.rs b/crates/steep-server/src/bot_actions/review/render.rs new file mode 100644 index 0000000..ed66520 --- /dev/null +++ b/crates/steep-server/src/bot_actions/review/render.rs @@ -0,0 +1,110 @@ +//! Markdown summary of a review, as posted on the pull request. + +use crate::domain::review::{ReviewItem, ReviewResult, ReviewSeverity}; + +pub(super) fn review_result_to_markdown(review_result: &ReviewResult) -> String { + if review_result.reviews.is_empty() { + return String::from("No issues found. ✅"); + } + + let mut md = String::from("## Review Feedback\n\n"); + + md.push_str(&format!( + "### {} issues found.\n\n", + review_result.reviews.len() + )); + + let breakdown = severity_breakdown(&review_result.reviews); + if !breakdown.is_empty() { + md.push_str(&breakdown); + md.push('\n'); + } + + if !review_result.comment.is_empty() { + md.push_str("\n---\n\n"); + md.push_str("### Summary\n\n"); + md.push_str(&review_result.comment); + md.push('\n'); + } + + if let Some(cost) = review_result.cost { + md.push_str("\n---\n\n"); + md.push_str(&format!("### Cost: ${}", cost)); + md.push('\n'); + } + + md +} + +/// Counts the reviews per severity, most severe first. +/// +/// A review whose severity the model omitted or wrote unreadably is counted as +/// [`ReviewSeverity::Maintainability`], the same fallback used when posting it. +fn severity_breakdown(reviews: &[ReviewItem]) -> String { + [ + ReviewSeverity::Bug, + ReviewSeverity::Security, + ReviewSeverity::Performance, + ReviewSeverity::Maintainability, + ] + .into_iter() + .filter_map(|severity| { + let count = reviews + .iter() + .filter(|review| review.severity.unwrap_or(ReviewSeverity::Maintainability) == severity) + .count(); + + (count > 0).then(|| format!("- {} {}", count, severity.label())) + }) + .collect::>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bot_actions::review::fixtures::*; + + #[test] + fn the_breakdown_counts_severities_most_severe_first() { + let reviews = vec![ + ReviewItem { + severity: Some(ReviewSeverity::Maintainability), + ..review("a", Some(1), None) + }, + ReviewItem { + severity: Some(ReviewSeverity::Bug), + ..review("b", Some(2), None) + }, + ReviewItem { + severity: Some(ReviewSeverity::Bug), + ..review("c", Some(3), None) + }, + // A severity the model left out counts as maintainability. + review("d", Some(4), None), + ]; + + assert_eq!(severity_breakdown(&reviews), "- 2 bug\n- 2 maintainability"); + } + + #[test] + fn the_markdown_breaks_the_issues_down_by_severity() { + let mut result = review_result(vec![ + ReviewItem { + severity: Some(ReviewSeverity::Bug), + ..review("a", Some(1), None) + }, + ReviewItem { + severity: Some(ReviewSeverity::Performance), + ..review("b", Some(2), None) + }, + ]); + result.comment = String::from("Le découpage en crates est propre."); + + let markdown = review_result_to_markdown(&result); + + assert!(markdown.contains("- 1 bug")); + assert!(markdown.contains("- 1 performance")); + assert!(markdown.contains("Le découpage en crates est propre.")); + } +} diff --git a/crates/steep-server/src/db.rs b/crates/steep-server/src/db.rs new file mode 100644 index 0000000..5954319 --- /dev/null +++ b/crates/steep-server/src/db.rs @@ -0,0 +1,20 @@ +use toasty::Db; + +pub async fn open(url: &str) -> toasty::Result { + Db::builder() + .models(toasty::models!(crate::*)) + .connect(url) + .await +} + +/// A connection for a test, on the database `DATABASE_URL` points to. +/// +/// The schema must already be migrated: `scripts/migrate-apply.sh` does it, the same +/// way it does for the server. Each test opens its own connection, because the +/// connection tasks live on the runtime of the test that opened them. +#[cfg(test)] +pub(crate) async fn test_connect() -> Db { + let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + + open(&url).await.expect("connect to postgres") +} diff --git a/crates/steep-server/src/domain/mod.rs b/crates/steep-server/src/domain/mod.rs new file mode 100644 index 0000000..0755bd4 --- /dev/null +++ b/crates/steep-server/src/domain/mod.rs @@ -0,0 +1 @@ +pub mod review; diff --git a/crates/steep-server/src/domain/review.rs b/crates/steep-server/src/domain/review.rs new file mode 100644 index 0000000..1f8088c --- /dev/null +++ b/crates/steep-server/src/domain/review.rs @@ -0,0 +1,247 @@ +//! Domain types of a review: what the model answers with, and how it is read. + +use std::str::FromStr; + +use serde::{Deserialize, Deserializer}; +use tracing::warn; + +use crate::text::excerpt; + +#[derive(Deserialize, Debug)] +pub struct ReviewResult { + pub reviews: Vec, + pub comment: String, + pub cost: Option, +} + +#[derive(Deserialize, Debug)] +pub struct ReviewItem { + pub filename: String, + pub line: Option, + #[serde(default, deserialize_with = "deserialize_side")] + pub side: Option, + #[serde(default, deserialize_with = "deserialize_severity")] + pub severity: Option, + pub message: String, +} + +/// Which version of the file a review comment is anchored on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReviewSide { + /// The line was added by the pull request: `line` is a line number of the + /// new version of the file. + Added, + /// The line was removed by the pull request: `line` is a line number of the + /// old version of the file. + Removed, +} + +impl ReviewSide { + /// Reads the side the model asked for, tolerating casing and synonyms. + fn parse(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "added" | "add" | "new" | "right" => Some(Self::Added), + "removed" | "remove" | "deleted" | "delete" | "old" | "left" => Some(Self::Removed), + _ => None, + } + } +} + +/// What kind of problem a review reports. +/// +/// The categories are the ones the prompt asks for; a review whose severity is +/// missing or unreadable falls back to [`ReviewSeverity::Maintainability`], the +/// least alarming bucket, instead of failing the whole review. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReviewSeverity { + /// Wrong behaviour. + Bug, + /// A vulnerability. + Security, + /// A resource problem. + Performance, + /// Everything else: readability, structure, naming, tests. + Maintainability, +} + +impl ReviewSeverity { + /// Reads the severity the model asked for, tolerating casing and synonyms. + fn parse(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "bug" | "bugs" | "correctness" | "error" => Some(Self::Bug), + "security" | "vulnerability" => Some(Self::Security), + "performance" | "perf" => Some(Self::Performance), + "maintainability" | "quality" | "style" => Some(Self::Maintainability), + _ => None, + } + } + + /// Lower-case label used in the review markdown. + pub fn label(self) -> &'static str { + match self { + Self::Bug => "bug", + Self::Security => "security", + Self::Performance => "performance", + Self::Maintainability => "maintainability", + } + } +} + +/// Reads the side the model asked for. An unreadable value is ignored instead of +/// failing the whole review: the side is then resolved from the changed lines. +fn deserialize_side<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let raw = Option::::deserialize(deserializer)?; + + Ok(raw + .as_ref() + .and_then(serde_json::Value::as_str) + .and_then(ReviewSide::parse)) +} + +/// Reads the severity the model asked for, ignoring an unreadable value: the +/// review is then reported as [`ReviewSeverity::Maintainability`]. +fn deserialize_severity<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let raw = Option::::deserialize(deserializer)?; + + Ok(raw + .as_ref() + .and_then(serde_json::Value::as_str) + .and_then(ReviewSeverity::parse)) +} + +/// Number of characters of a model answer kept in the logs when it cannot be +/// parsed. +const MAX_LOGGED_ANSWER: usize = 500; + +impl FromStr for ReviewResult { + type Err = anyhow::Error; + + /// Parses the review the model answered with. + /// + /// This is the contract the agent loop enforces: a rejected answer is sent + /// back to the model, with the reason, so that it can correct itself. + /// + /// The contract is a raw JSON object, but models sometimes wrap it in a + /// markdown code fence or surround it with a sentence: the object is then + /// extracted from the answer before failing, and the answer is logged so a + /// breach of the contract can be diagnosed. + fn from_str(message: &str) -> Result { + let error = match serde_json::from_str::(message) { + Ok(review_result) => return Ok(review_result), + Err(error) => error, + }; + + // A markdown code fence or a sentence around the object is tolerated, with + // a warning: the contract asks for a raw JSON object. + if let Some(json) = json_object(message) + && let Ok(review_result) = serde_json::from_str::(json) + { + warn!( + "Model answer is not a raw JSON object, it was extracted from the surrounding text" + ); + + return Ok(review_result); + } + + // The reason of the rejection is logged along with the answer: without it, + // a broken answer is impossible to diagnose. + warn!( + answer = %excerpt(message, MAX_LOGGED_ANSWER), + reason = %error, + "Model answer is not the expected JSON" + ); + + anyhow::bail!("the answer is not valid JSON: {error}") + } +} + +/// Returns the outermost `{...}` of an answer, which ignores a markdown code +/// fence or any text around it. +fn json_object(message: &str) -> Option<&str> { + let start = message.find('{')?; + let end = message.rfind('}')?; + + (start < end).then(|| &message[start..=end]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_raw_answer_is_parsed() { + let answer = r#"{"reviews": [], "comment": "ok"}"#; + + assert_eq!(answer.parse::().unwrap().comment, "ok"); + } + + #[test] + fn a_fenced_answer_is_extracted() { + let answer = concat!( + "Voici ma review :\n", + "```json\n", + "{\"reviews\": [], \"comment\": \"rien à signaler\"}\n", + "```\n" + ); + + assert_eq!( + answer.parse::().unwrap().comment, + "rien à signaler" + ); + } + + #[test] + fn a_sentence_around_the_object_is_ignored() { + let answer = r#"Rien à signaler. {"reviews": [], "comment": "ok"} Bonne journée !"#; + + assert_eq!(answer.parse::().unwrap().comment, "ok"); + } + + #[test] + fn an_answer_without_json_is_rejected() { + assert!("Je n'ai rien relevé.".parse::().is_err()); + } + + #[test] + fn an_answer_that_is_an_object_but_not_a_review_is_rejected() { + assert!(r#"{"message": "LGTM"}"#.parse::().is_err()); + } + + #[test] + fn severities_are_read_from_the_model() { + let result: ReviewResult = serde_json::from_str( + r#"{ + "reviews": [ + { "filename": "a", "line": 1, "severity": "Bug", "message": "x" }, + { "filename": "b", "line": 2, "severity": "SECURITY", "message": "x" }, + { "filename": "c", "line": 3, "severity": "perf", "message": "x" }, + { "filename": "d", "line": 4, "severity": "banana", "message": "x" } + ], + "comment": "" + }"#, + ) + .unwrap(); + + let severities = result + .reviews + .iter() + .map(|review| review.severity) + .collect::>(); + + assert_eq!( + severities, + vec![ + Some(ReviewSeverity::Bug), + Some(ReviewSeverity::Security), + Some(ReviewSeverity::Performance), + None, + ] + ); + } +} diff --git a/crates/steep-server/src/env.rs b/crates/steep-server/src/env.rs index a7752de..13a1214 100644 --- a/crates/steep-server/src/env.rs +++ b/crates/steep-server/src/env.rs @@ -1,4 +1,9 @@ -use anyhow::anyhow; +use std::{str::FromStr, time::Duration}; + +use anyhow::Context; + +/// Number of tool-calling iterations a sandboxed review may take, unless configured. +const DEFAULT_SANDBOX_MAX_ITERATIONS: usize = 8; #[derive(Clone)] pub struct EnvConfig { @@ -6,43 +11,31 @@ pub struct EnvConfig { pub webhook_secret: String, pub open_router_api_key: String, pub open_router_model: String, - pub open_router_timeout: u64, + pub open_router_timeout: Duration, pub bot_max_concurrent: usize, pub gitea_url: String, pub gitea_token: String, - pub gitea_timeout: u64, + pub gitea_timeout: Duration, + pub database_url: String, pub metrics_bind_addr: Option, pub sandbox_max_iterations: usize, } pub fn load_config() -> anyhow::Result { - let http_port = try_get_env("HTTP_PORT")?.parse()?; - let webhook_secret = try_get_env("WEBHOOK_SIG_HEADER_SECRET")?; - let open_router_api_key = try_get_env("OPEN_ROUTER_API_KEY")?; - let open_router_model = try_get_env("OPEN_ROUTER_MODEL")?; - let open_router_timeout = try_get_env("OPEN_ROUTER_TIMEOUT")?.parse()?; - let bot_max_concurrent = try_get_env("BOT_MAX_CONCURRENT")?.parse()?; - let gitea_url = try_get_env("GITEA_URL")?; - let gitea_token = try_get_env("GITEA_TOKEN")?; - let gitea_timeout = try_get_env("GITEA_TIMEOUT")?.parse()?; - let metrics_bind_addr = std::env::var("METRICS_BIND_ADDR").ok(); - let sandbox_max_iterations = std::env::var("SANDBOX_MAX_ITERATIONS") - .ok() - .and_then(|value| value.parse().ok()) - .unwrap_or(8); - Ok(EnvConfig { - http_port, - webhook_secret, - open_router_api_key, - open_router_model, - open_router_timeout, - bot_max_concurrent, - gitea_url, - gitea_token, - gitea_timeout, - metrics_bind_addr, - sandbox_max_iterations, + http_port: parse_env("HTTP_PORT")?, + webhook_secret: try_get_env("WEBHOOK_SIG_HEADER_SECRET")?, + open_router_api_key: try_get_env("OPEN_ROUTER_API_KEY")?, + open_router_model: try_get_env("OPEN_ROUTER_MODEL")?, + open_router_timeout: parse_seconds_env("OPEN_ROUTER_TIMEOUT")?, + bot_max_concurrent: parse_env("BOT_MAX_CONCURRENT")?, + gitea_url: try_get_env("GITEA_URL")?, + gitea_token: try_get_env("GITEA_TOKEN")?, + gitea_timeout: parse_seconds_env("GITEA_TIMEOUT")?, + database_url: try_get_env("DATABASE_URL")?, + metrics_bind_addr: std::env::var("METRICS_BIND_ADDR").ok(), + sandbox_max_iterations: parse_optional_env("SANDBOX_MAX_ITERATIONS")? + .unwrap_or(DEFAULT_SANDBOX_MAX_ITERATIONS), }) } @@ -50,16 +43,57 @@ pub fn try_get_env(key: &str) -> anyhow::Result { let env_value = std::env::var(key).map_err(|e| anyhow::anyhow!("{}: {}", key, e))?; if env_value.trim().is_empty() { - return Err(anyhow!(format!("env var {} is empty", key))); + anyhow::bail!("env var {key} is empty"); } Ok(env_value) } +/// Reads a required variable and parses it, naming the variable when it is invalid. +fn parse_env(key: &str) -> anyhow::Result +where + T: FromStr, + T::Err: std::error::Error + Send + Sync + 'static, +{ + parse_value(key, &try_get_env(key)?) +} + +/// Reads an optional variable and parses it. A missing or empty variable is `None`; +/// one that is set but invalid is an error, like it is for the required ones. +fn parse_optional_env(key: &str) -> anyhow::Result> +where + T: FromStr, + T::Err: std::error::Error + Send + Sync + 'static, +{ + match std::env::var(key) { + Ok(value) if !value.trim().is_empty() => parse_value(key, &value).map(Some), + _ => Ok(None), + } +} + +/// Reads a required variable holding a number of seconds. +fn parse_seconds_env(key: &str) -> anyhow::Result { + parse_env::(key).map(Duration::from_secs) +} + +fn parse_value(key: &str, value: &str) -> anyhow::Result +where + T: FromStr, + T::Err: std::error::Error + Send + Sync + 'static, +{ + value + .trim() + .parse() + .with_context(|| format!("env var {key} is invalid: `{value}`")) +} + #[cfg(test)] mod tests { use super::*; + // Every test uses its own variable: the environment is shared by the tests, + // which run in parallel. + #[test] fn test_try_get_env_returns_value() { unsafe { std::env::set_var("TEST_ENV_PRESENT", "hello") }; @@ -85,4 +119,38 @@ mod tests { let err = try_get_env("TEST_ENV_WHITESPACE").unwrap_err(); assert!(err.to_string().contains("TEST_ENV_WHITESPACE")); } + + #[test] + fn a_timeout_is_read_as_seconds() { + unsafe { std::env::set_var("TEST_ENV_SECONDS", "30") }; + assert_eq!( + parse_seconds_env("TEST_ENV_SECONDS").unwrap(), + Duration::from_secs(30) + ); + } + + #[test] + fn an_invalid_number_names_its_variable() { + unsafe { std::env::set_var("TEST_ENV_NOT_A_NUMBER", "soon") }; + + let err = parse_env::("TEST_ENV_NOT_A_NUMBER").unwrap_err(); + + assert!(format!("{err:#}").contains("TEST_ENV_NOT_A_NUMBER")); + assert!(format!("{err:#}").contains("soon")); + } + + #[test] + fn an_absent_optional_variable_is_none() { + unsafe { std::env::remove_var("TEST_ENV_OPTIONAL_ABSENT") }; + assert_eq!( + parse_optional_env::("TEST_ENV_OPTIONAL_ABSENT").unwrap(), + None + ); + } + + #[test] + fn an_invalid_optional_variable_is_an_error_instead_of_the_default() { + unsafe { std::env::set_var("TEST_ENV_OPTIONAL_INVALID", "many") }; + assert!(parse_optional_env::("TEST_ENV_OPTIONAL_INVALID").is_err()); + } } diff --git a/crates/steep-server/src/errors.rs b/crates/steep-server/src/errors.rs index 589854b..2cee8af 100644 --- a/crates/steep-server/src/errors.rs +++ b/crates/steep-server/src/errors.rs @@ -1,6 +1,6 @@ -use anyhow::anyhow; use axum::response::IntoResponse; use reqwest::StatusCode; +use sentry::capture_error; #[derive(thiserror::Error, Debug)] pub enum AppError { @@ -22,15 +22,21 @@ pub enum AppError { #[error("WebHook have bad action")] InvalidActionErr, - #[error("Channel full")] - ChannelFullErr, + #[error("Could not enqueue task")] + EnqueueErr(#[source] anyhow::Error), #[error("Already processed")] AlreadyProcessedErr, + #[error("Job {0} is not running")] + JobNotFoundErr(u64), + #[error(transparent)] BadJsonStructErr(#[from] serde_json::Error), + #[error("Other toasty error")] + OtherToastyErr(#[from] toasty::Error), + #[error(transparent)] Other(#[from] anyhow::Error), } @@ -62,11 +68,14 @@ impl IntoResponse for AppError { "WebHook sig header is invalid".to_string(), ), AppError::AlreadyProcessedErr => (StatusCode::OK, "Already processed".to_string()), - AppError::ChannelFullErr => { - sentry_anyhow::capture_anyhow(&anyhow!("Max concurrent tasks reached")); + AppError::JobNotFoundErr(id) => { + (StatusCode::NOT_FOUND, format!("job {id} is not running")) + } + AppError::EnqueueErr(err) => { + sentry_anyhow::capture_anyhow(&err); ( StatusCode::SERVICE_UNAVAILABLE, - "Max concurrent tasks reached".to_string(), + "Could not enqueue task".to_string(), ) } AppError::Other(err) => { @@ -76,6 +85,13 @@ impl IntoResponse for AppError { "Internal server error".to_string(), ) } + AppError::OtherToastyErr(err) => { + capture_error(&err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + ) + } } .into_response() } diff --git a/crates/steep-server/src/event_bus.rs b/crates/steep-server/src/event_bus.rs new file mode 100644 index 0000000..ca7f443 --- /dev/null +++ b/crates/steep-server/src/event_bus.rs @@ -0,0 +1,335 @@ +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; + +use serde::Serialize; +use tokio::sync::broadcast; + +use crate::text::excerpt; + +const LIFECYCLE_CAPACITY: usize = 256; +const PROGRESS_CAPACITY: usize = 256; +const HISTORY_CAPACITY: usize = 256; +const MAX_EVENT_ARGUMENTS: usize = 200; + +/// Lifecycle event of a job, broadcast globally. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum JobEvent { + Queued { + id: u64, + repo: String, + pr: u64, + }, + Started { + id: u64, + repo: String, + pr: u64, + }, + ReviewDone { + id: u64, + repo: String, + pr: u64, + ok: bool, + comments: u64, + duration_ms: u64, + }, + Failed { + id: u64, + repo: String, + pr: u64, + }, + Skipped { + id: u64, + repo: String, + pr: u64, + }, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Progress { + ToolCall { + iteration: usize, + name: String, + arguments: String, + }, + ToolResult { + iteration: usize, + name: String, + truncated: bool, + output: String, + }, + Finished { + ok: bool, + }, +} + +#[derive(Debug)] +struct JobChannel { + tx: broadcast::Sender, + history: Mutex>, +} + +impl JobChannel { + fn subscribe(&self) -> (Vec, broadcast::Receiver) { + let history = self.history.lock().expect("job history lock poisoned"); + let rx = self.tx.subscribe(); + + (history.iter().cloned().collect(), rx) + } + + fn emit(&self, event: Progress) { + let mut history = self.history.lock().expect("job history lock poisoned"); + + if history.len() >= HISTORY_CAPACITY { + history.pop_front(); + } + + let _ = self.tx.send(event.clone()); + history.push_back(event); + } +} + +pub struct JobSubscription { + pub history: Vec, + pub live: broadcast::Receiver, +} + +#[derive(Clone)] +pub struct EventBus { + lifecycle: broadcast::Sender, + jobs: Arc>>>, +} + +impl EventBus { + pub fn new() -> Self { + let (lifecycle, _rx) = broadcast::channel(LIFECYCLE_CAPACITY); + + Self { + lifecycle, + jobs: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub fn publish(&self, event: JobEvent) { + let _ = self.lifecycle.send(event); + } + + pub fn subscribe_lifecycle(&self) -> broadcast::Receiver { + self.lifecycle.subscribe() + } + + pub fn start(&self, id: u64, repo: impl Into, pr: u64) -> Reporter { + let (tx, _rx) = broadcast::channel(PROGRESS_CAPACITY); + let channel = Arc::new(JobChannel { + tx, + history: Mutex::new(VecDeque::new()), + }); + + self.jobs + .lock() + .expect("job registry lock poisoned") + .insert(id, channel.clone()); + + self.publish(JobEvent::Started { + id, + repo: repo.into(), + pr, + }); + + Reporter { channel } + } + + pub fn finish(&self, id: u64, repo: &str, pr: u64, ok: bool, comments: u64, duration_ms: u64) { + let event = if ok { + JobEvent::ReviewDone { + id, + repo: repo.to_string(), + pr, + ok, + comments, + duration_ms, + } + } else { + JobEvent::Failed { + id, + repo: repo.to_string(), + pr, + } + }; + + self.publish(event); + + if let Some(channel) = self + .jobs + .lock() + .expect("job registry lock poisoned") + .remove(&id) + { + let _ = channel.tx.send(Progress::Finished { ok }); + } + } + + pub fn skip(&self, id: u64, repo: &str, pr: u64) { + self.publish(JobEvent::Skipped { + id, + repo: repo.to_string(), + pr, + }); + } + + pub fn subscribe_job(&self, id: u64) -> Option { + let channel = self + .jobs + .lock() + .expect("job registry lock poisoned") + .get(&id) + .cloned()?; + + let (history, live) = channel.subscribe(); + + Some(JobSubscription { history, live }) + } +} + +impl Default for EventBus { + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone, Debug)] +pub struct Reporter { + channel: Arc, +} + +impl Reporter { + pub fn tool_call(&self, iteration: usize, name: &str, arguments: &str) { + self.channel.emit(Progress::ToolCall { + iteration, + name: name.to_string(), + arguments: excerpt(arguments, MAX_EVENT_ARGUMENTS), + }); + } + + /// Reports the end of a tool call. + pub fn tool_result(&self, iteration: usize, name: &str, truncated: bool, output: &str) { + self.channel.emit(Progress::ToolResult { + iteration, + name: name.to_string(), + truncated, + output: output.to_string(), + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_started_job_is_announced_and_subscribable() { + let bus = EventBus::new(); + let mut lifecycle = bus.subscribe_lifecycle(); + + let _reporter = bus.start(1, "owner/repo", 7); + + assert!(matches!( + lifecycle.try_recv().unwrap(), + JobEvent::Started { id: 1, pr: 7, .. } + )); + assert!(bus.subscribe_job(1).is_some()); + } + + #[test] + fn progress_emitted_before_subscribing_is_replayed() { + let bus = EventBus::new(); + let reporter = bus.start(1, "owner/repo", 7); + + reporter.tool_call(1, "ls", "{}"); + reporter.tool_result(1, "ls", false, "src\nmain.rs"); + + let subscription = bus.subscribe_job(1).unwrap(); + + assert_eq!(subscription.history.len(), 2); + assert!(matches!( + subscription.history[0], + Progress::ToolCall { iteration: 1, .. } + )); + assert!(matches!( + &subscription.history[1], + Progress::ToolResult { name, .. } if name == "ls" + )); + } + + #[test] + fn progress_emitted_after_subscribing_arrives_live() { + let bus = EventBus::new(); + let reporter = bus.start(1, "owner/repo", 7); + let mut subscription = bus.subscribe_job(1).unwrap(); + + reporter.tool_call(2, "grep", r#"{"pattern":"fn main"}"#); + + assert!(matches!( + subscription.live.try_recv().unwrap(), + Progress::ToolCall { iteration: 2, name, .. } if name == "grep" + )); + } + + #[test] + fn finish_closes_the_channel_and_announces_the_review() { + let bus = EventBus::new(); + let mut lifecycle = bus.subscribe_lifecycle(); + let _reporter = bus.start(1, "owner/repo", 7); + let mut subscription = bus.subscribe_job(1).unwrap(); + + bus.finish(1, "owner/repo", 7, true, 3, 120); + + assert!(bus.subscribe_job(1).is_none()); + assert!(matches!( + subscription.live.try_recv().unwrap(), + Progress::Finished { ok: true } + )); + + let _started = lifecycle.try_recv().unwrap(); + assert!(matches!( + lifecycle.try_recv().unwrap(), + JobEvent::ReviewDone { + id: 1, + ok: true, + comments: 3, + duration_ms: 120, + .. + } + )); + } + + #[test] + fn a_failed_job_is_announced_as_failed() { + let bus = EventBus::new(); + let mut lifecycle = bus.subscribe_lifecycle(); + let _reporter = bus.start(1, "owner/repo", 7); + + bus.finish(1, "owner/repo", 7, false, 0, 42); + + let _started = lifecycle.try_recv().unwrap(); + assert!(matches!( + lifecycle.try_recv().unwrap(), + JobEvent::Failed { id: 1, .. } + )); + } + + #[test] + fn a_skipped_job_is_announced_without_a_channel() { + let bus = EventBus::new(); + let mut lifecycle = bus.subscribe_lifecycle(); + + bus.skip(1, "owner/repo", 7); + + assert!(matches!( + lifecycle.try_recv().unwrap(), + JobEvent::Skipped { id: 1, pr: 7, .. } + )); + assert!(bus.subscribe_job(1).is_none()); + } +} diff --git a/crates/steep-server/src/gitea.rs b/crates/steep-server/src/gitea.rs index 7dbbfcc..071ce79 100644 --- a/crates/steep-server/src/gitea.rs +++ b/crates/steep-server/src/gitea.rs @@ -1,15 +1,15 @@ use std::time::Duration; use futures_util::stream::TryStreamExt; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tokio::io::AsyncReadExt; use tokio_util::io::StreamReader; use tracing::{instrument, warn}; use crate::{ - bot::{ReviewResult, ReviewSeverity, ReviewSide}, consts::MAX_DIFF_SIZE, + domain::review::{ReviewResult, ReviewSeverity, ReviewSide}, errors::AppError, }; @@ -27,7 +27,7 @@ pub struct GiteaAPI { } impl GiteaAPI { - pub fn new(base_url: &str, token: &str, timeout: u64) -> anyhow::Result { + pub fn new(base_url: &str, token: &str, timeout: Duration) -> anyhow::Result { let mut default_headers = reqwest::header::HeaderMap::new(); default_headers.insert( reqwest::header::HeaderName::from_static("authorization"), @@ -38,7 +38,7 @@ impl GiteaAPI { base_url: String::from(base_url), token: String::from(token), client: reqwest::Client::builder() - .timeout(Duration::from_secs(timeout)) + .timeout(timeout) .default_headers(default_headers) .build()?, }) @@ -281,7 +281,7 @@ async fn read_capped(response: reqwest::Response) -> anyhow::Result { Ok(String::from_utf8_lossy(&buf).into_owned()) } -#[derive(Debug)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub enum WebhookType { Review(ReviewPayload), } @@ -298,9 +298,21 @@ impl WebhookType { WebhookType::Review(_) => "review", } } + + pub fn repository_full_name(&self) -> &str { + match self { + WebhookType::Review(payload) => &payload.repository.full_name, + } + } + + pub fn pull_request_number(&self) -> u64 { + match self { + WebhookType::Review(payload) => payload.pull_request.number, + } + } } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug, Clone)] pub struct ReviewPayload { pub action: String, pub pull_request: PullRequest, @@ -308,13 +320,28 @@ pub struct ReviewPayload { pub comment: Comment, } -#[derive(Deserialize, Debug)] +impl ReviewPayload { + /// Checks that the comment is a new one that mentions the bot. + fn check_addressed_to(&self, bot_name: &str) -> Result<(), AppError> { + if self.action != "created" { + return Err(AppError::InvalidActionErr); + } + + if !self.comment.body.starts_with(&format!("@{bot_name}")) { + return Err(AppError::UnauthorizedUserErr); + } + + Ok(()) + } +} + +#[derive(Deserialize, Serialize, Debug, Clone)] pub struct PullRequest { pub number: u64, pub title: String, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug, Clone)] pub struct Comment { pub id: u64, pub body: String, @@ -325,7 +352,7 @@ pub struct User { pub login: String, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug, Clone)] pub struct Repository { pub full_name: String, pub clone_url: String, @@ -345,28 +372,15 @@ pub struct PullRequestFile { impl WebhookType { pub fn from_event(event: &str, bot_name: &str, json: Value) -> Result { - let wb = match event { - "pull_request_comment" => Ok(WebhookType::Review(serde_json::from_value(json)?)), + match event { + "pull_request_comment" => { + let payload: ReviewPayload = serde_json::from_value(json)?; + payload.check_addressed_to(bot_name)?; + + Ok(WebhookType::Review(payload)) + } _ => Err(AppError::UnknownEventErr), - }?; - - let action = match &wb { - WebhookType::Review(review_payload) => &review_payload.action, - }; - - if action != "created" { - return Err(AppError::InvalidActionErr); } - - let pr_body = match &wb { - WebhookType::Review(review_payload) => &review_payload.comment.body, - }; - - if !pr_body.starts_with(&format!("@{}", bot_name)) { - return Err(AppError::UnauthorizedUserErr); - } - - Ok(wb) } } diff --git a/crates/steep-server/src/job_queue.rs b/crates/steep-server/src/job_queue.rs new file mode 100644 index 0000000..2c06958 --- /dev/null +++ b/crates/steep-server/src/job_queue.rs @@ -0,0 +1,273 @@ +use std::sync::Arc; + +use toasty::{Db, stmt::Value}; +use tokio::sync::Notify; + +use crate::{ + gitea::WebhookType, + schemas::{Job, JobStatus}, +}; + +/// The reviews to do, stored in PostgreSQL. +/// +/// Steep runs as a single instance, so every job is pushed by this process: the +/// worker is woken up by [`JobQueue::push`] instead of polling the table. The table +/// only makes the queue survive a restart. +#[derive(Clone)] +pub struct JobQueue { + db: Db, + work: Arc, +} + +impl JobQueue { + pub fn new(db: Db) -> Self { + Self { + db, + work: Arc::new(Notify::new()), + } + } + + pub async fn push(&self, webhook: WebhookType) -> toasty::Result { + let event_id = webhook.event_id(); + let repo = webhook.repository_full_name().to_string(); + let pr = webhook.pull_request_number(); + + let mut db = self.db.clone(); + + let job = toasty::create!(Job { + event_id, + repo, + pr, + payload: webhook, + status: JobStatus::Pending, + }) + .exec(&mut db) + .await?; + + // `notify_one` keeps a permit when nobody is waiting yet, so a job pushed + // while the worker is busy is not missed. + self.work.notify_one(); + + Ok(job) + } + + /// Whether a job is already queued for this event. + pub async fn is_queued(&self, event_id: u64) -> toasty::Result { + let mut db = self.db.clone(); + + Ok(Job::filter_by_event_id(event_id) + .first() + .exec(&mut db) + .await? + .is_some()) + } + + /// Puts back in the queue the jobs a previous process left `processing`. + /// + /// To call once at startup, before the worker starts: it is only correct because + /// no other instance can be working on those jobs. + pub async fn requeue_interrupted(&self) -> toasty::Result<()> { + let mut db = self.db.clone(); + + Job::update_by_status(JobStatus::Processing) + .status(JobStatus::Pending) + .exec(&mut db) + .await?; + + Ok(()) + } + + /// Takes the oldest pending job and marks it `processing`, or returns `None` when + /// the queue is empty. + /// + /// The selection and the update are a single statement, so a job cannot be taken + /// twice. + pub async fn claim_next(&self) -> toasty::Result> { + let mut db = self.db.clone(); + + let rows = toasty::sql::query( + "UPDATE jobs + SET status = 'processing' + WHERE id = ( + SELECT id FROM jobs + WHERE status = 'pending' + ORDER BY id + LIMIT 1 + ) + RETURNING id", + ) + .exec(&mut db) + .await?; + + let Some(Value::Record(row)) = rows.into_iter().next() else { + return Ok(None); + }; + + let id = u64::try_from(row.as_slice()[0].clone())?; + + Ok(Some(Job::get_by_id(&mut db, id).await?)) + } + + /// Waits until a job may have been pushed. + pub async fn wait_for_work(&self) { + self.work.notified().await; + } + + /// Removes a job from the queue, once its review is recorded. + pub async fn remove(&self, id: u64) -> toasty::Result<()> { + let mut db = self.db.clone(); + + Job::delete_by_id(&mut db, id).await?; + + Ok(()) + } + + pub async fn list(&self, status: JobStatus) -> toasty::Result> { + let mut db = self.db.clone(); + let jobs = Job::filter_by_status(status).exec(&mut db).await?; + + Ok(jobs) + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tokio::sync::{Mutex, MutexGuard}; + + use super::*; + use crate::gitea::{Comment, PullRequest, Repository, ReviewPayload}; + + const FIRST_EVENT: u64 = 999_000_200; + + fn webhook(event_id: u64) -> WebhookType { + WebhookType::Review(ReviewPayload { + action: "created".to_string(), + pull_request: PullRequest { + number: 1, + title: "Test".to_string(), + }, + repository: Repository { + full_name: "owner/repo".to_string(), + clone_url: "https://example.com/owner/repo.git".to_string(), + }, + comment: Comment { + id: event_id, + body: "@bot review".to_string(), + }, + }) + } + + /// The tests claim whichever job is next, so they must not overlap: one would + /// claim the jobs of another. + async fn exclusive_queue() -> (MutexGuard<'static, ()>, JobQueue, Db) { + static LOCK: Mutex<()> = Mutex::const_new(()); + + let guard = LOCK.lock().await; + let db = crate::db::test_connect().await; + + toasty::sql::statement( + "DELETE FROM jobs WHERE event_id >= 999000200 AND event_id < 999000300", + ) + .exec(&mut db.clone()) + .await + .unwrap(); + + (guard, JobQueue::new(db.clone()), db) + } + + #[tokio::test] + #[ignore = "requires DATABASE_URL"] + async fn a_pushed_job_is_claimed_then_removed() { + let (_guard, queue, db) = exclusive_queue().await; + + queue.push(webhook(FIRST_EVENT)).await.unwrap(); + assert!(queue.is_queued(FIRST_EVENT).await.unwrap()); + + let job = queue.claim_next().await.unwrap().expect("a job to claim"); + assert_eq!(job.event_id, FIRST_EVENT); + assert_eq!(job.status, JobStatus::Processing); + + queue.remove(job.id).await.unwrap(); + + assert!( + Job::filter_by_event_id(FIRST_EVENT) + .first() + .exec(&mut db.clone()) + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + #[ignore = "requires DATABASE_URL"] + async fn a_duplicate_event_id_is_rejected() { + let (_guard, queue, _db) = exclusive_queue().await; + + queue.push(webhook(FIRST_EVENT + 1)).await.unwrap(); + assert!(queue.push(webhook(FIRST_EVENT + 1)).await.is_err()); + } + + #[tokio::test] + #[ignore = "requires DATABASE_URL"] + async fn a_claimed_job_is_not_claimed_again() { + let (_guard, queue, _db) = exclusive_queue().await; + + queue.push(webhook(FIRST_EVENT + 2)).await.unwrap(); + + assert!(queue.claim_next().await.unwrap().is_some()); + assert!(queue.claim_next().await.unwrap().is_none()); + } + + #[tokio::test] + #[ignore = "requires DATABASE_URL"] + async fn jobs_are_claimed_oldest_first() { + let (_guard, queue, _db) = exclusive_queue().await; + + queue.push(webhook(FIRST_EVENT + 3)).await.unwrap(); + queue.push(webhook(FIRST_EVENT + 4)).await.unwrap(); + + let first = queue.claim_next().await.unwrap().unwrap(); + let second = queue.claim_next().await.unwrap().unwrap(); + + assert_eq!(first.event_id, FIRST_EVENT + 3); + assert_eq!(second.event_id, FIRST_EVENT + 4); + } + + #[tokio::test] + #[ignore = "requires DATABASE_URL"] + async fn an_interrupted_job_is_requeued() { + let (_guard, queue, _db) = exclusive_queue().await; + + queue.push(webhook(FIRST_EVENT + 5)).await.unwrap(); + let job = queue.claim_next().await.unwrap().unwrap(); + assert!(queue.claim_next().await.unwrap().is_none()); + + // The process died here: on restart, the job goes back to the queue. + queue.requeue_interrupted().await.unwrap(); + + let again = queue.claim_next().await.unwrap().expect("the job back"); + assert_eq!(again.id, job.id); + } + + #[tokio::test] + #[ignore = "requires DATABASE_URL"] + async fn waiting_for_work_returns_as_soon_as_a_job_is_pushed() { + let (_guard, queue, _db) = exclusive_queue().await; + + let waiter = { + let queue = queue.clone(); + tokio::spawn(async move { queue.wait_for_work().await }) + }; + + tokio::time::sleep(Duration::from_millis(100)).await; + queue.push(webhook(FIRST_EVENT + 6)).await.unwrap(); + + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("the worker was not woken up") + .unwrap(); + } +} diff --git a/crates/steep-server/src/main.rs b/crates/steep-server/src/main.rs index b18de22..aa3c9aa 100644 --- a/crates/steep-server/src/main.rs +++ b/crates/steep-server/src/main.rs @@ -1,9 +1,12 @@ use std::time::Duration; use crate::{ - bot::Bot, - gitea::{GiteaAPI, WebhookType}, + bot::{Bot, BotDeps}, + event_bus::EventBus, + gitea::GiteaAPI, + job_queue::JobQueue, open_router::OpenRouterClient, + review_store::ReviewStore, sandbox::SandboxConfig, state::AppState, }; @@ -18,28 +21,46 @@ mod api; mod bot; mod bot_actions; mod consts; +mod db; +mod domain; mod env; mod errors; +mod event_bus; mod gitea; +mod job_queue; mod metrics; mod open_router; +mod review_store; mod sandbox; +mod schemas; mod state; mod text; -/// Délai laissé aux reviews en cours et aux serveurs pour s'arrêter proprement. +/// Time left to the running reviews and the servers to stop cleanly. /// -/// Sans lui, une review prise dans une sandbox récalcitrante garderait le processus -/// en vie jusqu'à ce que le superviseur le tue. +/// Without it, a review stuck in an unresponsive sandbox would keep the process +/// alive until the supervisor kills it. const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(60); fn main() -> anyhow::Result<()> { dotenv().ok(); + // TLS goes through rustls, which has no default crypto provider here: the + // workspace compiles `ring` only, and it is installed once, for every client + // (reqwest, sentry). + rustls::crypto::ring::default_provider() + .install_default() + .map_err(|_| anyhow::anyhow!("a rustls crypto provider is already installed"))?; + + // Migration subcommand: `steep-server migration `. + if std::env::args().nth(1).as_deref() == Some("migration") { + return tokio::runtime::Runtime::new()?.block_on(run_migrations()); + } + tracing_subscriber::registry() .with(fmt::layer()) .with( - EnvFilter::try_from_default_env() // lit RUST_LOG depuis l'env + EnvFilter::try_from_default_env() // reads RUST_LOG from the environment .unwrap_or_else(|_| EnvFilter::new("info")), ) .init(); @@ -61,6 +82,24 @@ fn main() -> anyhow::Result<()> { tokio::runtime::Runtime::new()?.block_on(run()) } +async fn run_migrations() -> anyhow::Result<()> { + let database_url = env::try_get_env("DATABASE_URL")?; + let db = db::open(&database_url).await?; + + let migrations = match std::env::var("MIGRATIONS_DIR") { + Ok(dir) => std::path::PathBuf::from(dir), + Err(_) => std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("toasty"), + }; + let config = + toasty_cli::Config::new().migration(toasty_cli::MigrationConfig::new().path(migrations)); + + toasty_cli::ToastyCli::with_config(db, config) + .parse_and_run() + .await?; + + Ok(()) +} + async fn run() -> anyhow::Result<()> { let config = env::load_config()?; @@ -99,20 +138,33 @@ async fn run() -> anyhow::Result<()> { ); } + let db = db::open(&config.database_url).await?; + let reviews = ReviewStore::new(db.clone()); + + let queue = JobQueue::new(db); + queue.requeue_interrupted().await?; + + let event_bus = EventBus::new(); + let bot = Bot::new( - gitea_user.login, - gitea_api, - open_router_client, + BotDeps { + gitea: gitea_api, + open_router: open_router_client, + sandbox, + reviews: reviews.clone(), + queue: queue.clone(), + event_bus: event_bus.clone(), + }, config.bot_max_concurrent, - config.open_router_model.clone(), - sandbox, ); - let (tx, rx) = tokio::sync::mpsc::channel::(config.bot_max_concurrent * 2); let app_state = AppState { - bot_tx: tx, - bot: bot.clone(), + queue, + reviews, + event_bus, + bot_name: gitea_user.login.clone(), config, + shutdown: shutdown.clone(), }; let signal = async { @@ -136,8 +188,8 @@ async fn run() -> anyhow::Result<()> { tokio::select! { result = async { tokio::try_join!( - bot.start(rx, shutdown.clone()), - api::start(app_state, shutdown.clone()), + bot.start(shutdown.clone()), + api::start(app_state), signal ) } => { diff --git a/crates/steep-server/src/metrics.rs b/crates/steep-server/src/metrics.rs index e23c36e..3f41b99 100644 --- a/crates/steep-server/src/metrics.rs +++ b/crates/steep-server/src/metrics.rs @@ -10,8 +10,8 @@ pub fn webhook_duplicate(event_type: &str) { counter!("steep_webhooks_duplicate_total", "event_type" => event_type.to_string()).increment(1); } -pub fn webhook_channel_full(event_type: &str) { - counter!("steep_webhooks_channel_full_total", "event_type" => event_type.to_string()) +pub fn webhook_enqueue_failed(event_type: &str) { + counter!("steep_webhooks_enqueue_failed_total", "event_type" => event_type.to_string()) .increment(1); } @@ -23,6 +23,10 @@ pub fn decrement_task_active() { gauge!("steep_bot_tasks_active").decrement(1.0); } +pub fn task_skipped(event_type: &str) { + counter!("steep_bot_tasks_skipped_total", "event_type" => event_type.to_string()).increment(1); +} + pub fn task_completed(event_type: &str) { counter!("steep_bot_tasks_completed_total", "event_type" => event_type.to_string()) .increment(1); @@ -48,15 +52,20 @@ pub fn describe() { "Webhooks rejected as duplicates" ); describe_counter!( - "steep_webhooks_channel_full_total", + "steep_webhooks_enqueue_failed_total", Unit::Count, - "Webhooks dropped because the bot channel was full" + "Webhooks dropped because enqueueing to PostgreSQL failed" ); describe_gauge!( "steep_bot_tasks_active", Unit::Count, "Bot tasks currently in progress" ); + describe_counter!( + "steep_bot_tasks_skipped_total", + Unit::Count, + "Reviews skipped because they were already done" + ); describe_counter!( "steep_bot_tasks_completed_total", Unit::Count, diff --git a/crates/steep-server/src/open_router.rs b/crates/steep-server/src/open_router.rs index 938c2fe..040b2ea 100644 --- a/crates/steep-server/src/open_router.rs +++ b/crates/steep-server/src/open_router.rs @@ -254,16 +254,19 @@ pub struct OpenRouterClient { } impl OpenRouterClient { - pub fn new(token: &str, model: &str, timeout: u64) -> anyhow::Result { + pub fn new(token: &str, model: &str, timeout: Duration) -> anyhow::Result { Ok(Self { - client: reqwest::Client::builder() - .timeout(Duration::from_secs(timeout)) - .build()?, + client: reqwest::Client::builder().timeout(timeout).build()?, api_key: String::from(token), model: String::from(model), }) } + /// The model every completion is requested from. + pub fn model(&self) -> &str { + &self.model + } + /// Sends a conversation with tool definitions and returns either a final /// message or the tool calls requested by the model. /// diff --git a/crates/steep-server/src/review_store.rs b/crates/steep-server/src/review_store.rs new file mode 100644 index 0000000..d278be4 --- /dev/null +++ b/crates/steep-server/src/review_store.rs @@ -0,0 +1,114 @@ +use toasty::Db; + +use crate::schemas::Review; + +/// What is kept of a processed review. +pub struct ReviewRecord { + pub event_id: u64, + pub repo: String, + pub pr: u64, + pub ok: bool, + pub model: String, + pub markdown: String, + pub cost: Option, + pub comments: u64, + pub duration_ms: u64, +} + +/// The history of the reviews Steep has processed. +/// +/// It also is what keeps an event from being reviewed twice. +#[derive(Clone)] +pub struct ReviewStore { + db: Db, +} + +impl ReviewStore { + pub fn new(db: Db) -> Self { + Self { db } + } + + pub async fn is_reviewed(&self, event_id: u64) -> toasty::Result { + let mut db = self.db.clone(); + + Ok(Review::filter_by_event_id(event_id) + .first() + .exec(&mut db) + .await? + .is_some()) + } + + /// Records a processed review, replacing the one of the same event if any. + pub async fn record(&self, record: ReviewRecord) -> toasty::Result<()> { + let mut db = self.db.clone(); + + Review::upsert_by_event_id(record.event_id) + .repo(record.repo) + .pr(record.pr) + .ok(record.ok) + .model(record.model) + .markdown(record.markdown) + .cost(record.cost) + .comments(record.comments) + .duration_ms(record.duration_ms) + .exec(&mut db) + .await?; + + Ok(()) + } + + pub async fn list(&self) -> toasty::Result> { + let mut db = self.db.clone(); + let reviews = Review::all().exec(&mut db).await?; + + Ok(reviews) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + #[ignore = "requires DATABASE_URL"] + async fn a_recorded_review_is_reviewed() { + let db = crate::db::test_connect().await; + let store = ReviewStore::new(db.clone()); + + let event_id = 999_000_101; + Review::delete_by_event_id(&mut db.clone(), event_id) + .await + .ok(); + + assert!(!store.is_reviewed(event_id).await.unwrap()); + + store + .record(ReviewRecord { + event_id, + repo: "owner/repo".to_string(), + pr: 1, + ok: true, + model: "test/model".to_string(), + markdown: "looks good".to_string(), + cost: Some(0.01), + comments: 2, + duration_ms: 5, + }) + .await + .unwrap(); + + assert!(store.is_reviewed(event_id).await.unwrap()); + assert!( + store + .list() + .await + .unwrap() + .iter() + .any(|r| r.event_id == event_id) + ); + + Review::delete_by_event_id(&mut db.clone(), event_id) + .await + .unwrap(); + } +} diff --git a/crates/steep-server/src/sandbox/agent.rs b/crates/steep-server/src/sandbox/agent.rs index 7de2702..4ca9643 100644 --- a/crates/steep-server/src/sandbox/agent.rs +++ b/crates/steep-server/src/sandbox/agent.rs @@ -13,6 +13,7 @@ use serde_json::Value; use tracing::{info, warn}; use crate::{ + event_bus::Reporter, open_router::{Message, OpenRouterClient, Role, Tool, ToolCall, ToolChoice}, sandbox::{Sandbox, tools}, text::{excerpt, truncate_bytes}, @@ -61,17 +62,6 @@ pub struct AgentResult { pub iterations: usize, } -/// Runs the tool-calling loop until the model answers or `max_iterations` is -/// reached. -/// -/// `tool_definitions` is the set of tools the model may call; it is selected by -/// the caller based on the webhook action (see [`tools::for_webhook`]). -/// -/// The answer of the model is parsed into `A`, which is how the caller states the -/// format it expects: while [`FromStr`] rejects the answer, the error is sent back -/// to the model so that it can correct itself, which costs an iteration. The -/// parsing happens here because that is where the conversation and the remaining -/// budget are at hand. pub async fn run( open_router: &OpenRouterClient, sandbox: &Sandbox, @@ -79,6 +69,7 @@ pub async fn run( system_prompt: &str, user_prompt: &str, max_iterations: usize, + reporter: &Reporter, ) -> anyhow::Result> where A: FromStr, @@ -104,8 +95,8 @@ where let mut last_rejection: Option = None; for iteration in 1..=max_iterations { - // Le dernier tour n'est plus un tour d'exploration : le modèle doit rendre - // sa réponse, avec ce qu'il a déjà vu. + // The last turn is no longer an exploration turn: the model must give its + // answer, with what it has already seen. let last = iteration == max_iterations; if last { @@ -141,8 +132,8 @@ where "Model answered" ); - // Au dernier tour, les outils sont refusés : même si le fournisseur les - // renvoie malgré tout, on ne les exécute pas et on tente la réponse. + // On the last turn, tools are refused: even if the provider returns them + // anyway, we do not run them and try the answer instead. if last || response.tool_calls.is_empty() { let answer = response.message.unwrap_or_default(); @@ -177,16 +168,20 @@ where )); for call in &response.tool_calls { + reporter.tool_call(iteration, call.name(), call.arguments_json()); + let started = Instant::now(); let (content, truncated) = execute(sandbox, call).await; + reporter.tool_result(iteration, call.name(), truncated, &content); + info!( iteration, tool = call.name(), arguments = %excerpt(call.arguments_json(), MAX_LOGGED_ARGUMENTS), output_bytes = content.len(), truncated, - // Les sauts de ligne casseraient la lisibilité d'une ligne de log. + // Line breaks would hurt the readability of a log line. output = %excerpt(&content, MAX_LOGGED_OUTPUT).replace('\n', " "), elapsed_ms = started.elapsed().as_millis() as u64, "Tool call finished" diff --git a/crates/steep-server/src/sandbox/instance.rs b/crates/steep-server/src/sandbox/instance.rs index 880166c..cc31fdf 100644 --- a/crates/steep-server/src/sandbox/instance.rs +++ b/crates/steep-server/src/sandbox/instance.rs @@ -1,5 +1,5 @@ -//! Le [`Sandbox`] : clone d'une pull request exécuté dans un devcontainer -//! éphémère, et sa configuration. +//! The [`Sandbox`]: a pull request clone running inside an ephemeral +//! devcontainer, and its configuration. use std::{ path::{Path, PathBuf}, @@ -11,13 +11,15 @@ use devcontainer_rs::{Container, ContainerRuntime, ExecOutput}; use tempfile::TempDir; use tracing::{info, instrument}; +use crate::event_bus::Reporter; + /// Devcontainer locations recognized within a repository, in priority order. const DEVCONTAINER_PATHS: [&str; 2] = [".devcontainer/devcontainer.json", ".devcontainer.json"]; /// Sandbox-related runtime configuration. #[derive(Clone)] pub struct SandboxConfig { - /// Client du daemon de containers qui exécute la sandbox. + /// Client of the container daemon that runs the sandbox. pub runtime: ContainerRuntime, /// Maximum number of tool-calling iterations per agent run. pub max_iterations: usize, @@ -43,6 +45,7 @@ impl Sandbox { clone_url: &str, token: &str, pull_request_number: u64, + reporter: &Reporter, ) -> anyhow::Result { let workspace = tempfile::tempdir().context("failed to create sandbox workspace")?; let repo_dir = workspace.path().join("repo"); @@ -76,12 +79,12 @@ impl Sandbox { Ok(sandbox) } - /// Vérifie que le clone est bien visible dans le container. + /// Checks that the clone is actually visible inside the container. /// - /// Sans ce contrôle, un workspace vide — par exemple un daemon qui n'a pas pu - /// recevoir le clone — fait échouer chaque outil ; le modèle enchaîne alors les - /// appels ratés jusqu'au budget d'itérations, sans jamais pouvoir reviewer quoi - /// que ce soit. + /// Without this check, an empty workspace (for example a daemon that could not + /// receive the clone) makes every tool fail; the model then chains failed calls + /// until the iteration budget is spent, without ever being able to review + /// anything. async fn check_workspace(&self) -> anyhow::Result<()> { let workspace_folder = self.workspace_folder(); let probe = self @@ -115,12 +118,12 @@ impl Sandbox { Ok(()) } - /// Rassemble de quoi expliquer un refus d'accès au workspace. + /// Gathers enough information to explain a refused access to the workspace. /// - /// Un `EACCES` a deux causes possibles, indistinguables dans le message de - /// `ls` : les permissions du clone, ou un confinement du noyau qui bloque - /// l'accès. L'identité de l'utilisateur d'exec et les permissions du point de - /// montage permettent de trancher. + /// An `EACCES` has two possible causes, indistinguishable in the `ls` message: + /// the clone's permissions, or kernel confinement blocking the access. The + /// identity of the exec user and the permissions of the mount point tell them + /// apart. async fn diagnose_workspace(&self) -> String { let mut details = Vec::new(); @@ -212,12 +215,12 @@ async fn clone_pull_request( Ok(()) } -/// Rend le clone lisible par les utilisateurs du container sandbox. +/// Makes the clone readable by the users of the sandbox container. /// -/// Le container peut ne pas avoir les mêmes uid que Steep (podman rootless -/// mappe les uid à travers des plages subuid), et l'umask de l'opérateur peut être -/// restrictif : sans cela, les outils de la sandbox échouent en `Permission -/// denied` sur des fichiers que Steep vient de cloner lui-même. +/// The container may not share Steep's uids (rootless podman maps uids through +/// subuid ranges), and the operator's umask may be restrictive: without this, the +/// sandbox tools fail with `Permission denied` on files that Steep itself just +/// cloned. async fn make_readable(repo_dir: &Path) -> anyhow::Result<()> { let output = tokio::process::Command::new("chmod") .args(["-R", "a+rX"]) diff --git a/crates/steep-server/src/sandbox/tools.rs b/crates/steep-server/src/sandbox/tools.rs index 3d4f5a0..7a9d249 100644 --- a/crates/steep-server/src/sandbox/tools.rs +++ b/crates/steep-server/src/sandbox/tools.rs @@ -167,11 +167,11 @@ async fn read_file(sandbox: &Sandbox, args: &Value) -> anyhow::Result { Ok(number_lines(&into_stdout(output)?, first_line)) } -/// Préfixe chaque ligne par son numéro. +/// Prefixes each line with its number. /// -/// Le modèle doit citer une ligne précise pour ancrer son commentaire : sans -/// numéros, il les compte lui-même et se décale de quelques lignes, ce qui place le -/// commentaire à côté du code visé. +/// The model must cite a precise line to anchor its comment: without numbers, it +/// counts them itself and drifts by a few lines, which puts the comment next to +/// the targeted code. fn number_lines(content: &str, first_line: u64) -> String { content .lines() @@ -291,8 +291,8 @@ mod tests { #[test] fn a_range_keeps_the_absolute_line_numbers() { - // Un extrait lu à partir de la ligne 12 doit garder la numérotation du - // fichier : sinon le modèle citerait des lignes décalées. + // An excerpt read from line 12 must keep the file's numbering: otherwise the + // model would cite shifted lines. assert_eq!(number_lines("x\ny", 12), "12:x\n13:y"); } diff --git a/crates/steep-server/src/schemas/job.rs b/crates/steep-server/src/schemas/job.rs new file mode 100644 index 0000000..174df5f --- /dev/null +++ b/crates/steep-server/src/schemas/job.rs @@ -0,0 +1,33 @@ +use jiff::Timestamp; +use serde::Serialize; + +use crate::gitea::WebhookType; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, toasty::Embed, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum JobStatus { + Pending, + Processing, +} + +#[derive(Debug, toasty::Model, Clone, Serialize)] +pub struct Job { + #[key] + #[auto] + pub id: u64, + + #[unique] + pub event_id: u64, + + pub repo: String, + pub pr: u64, + + #[column(type = jsonb)] + pub payload: toasty::Json, + + #[index] + pub status: JobStatus, + + #[auto] + pub created_at: Timestamp, +} diff --git a/crates/steep-server/src/schemas/mod.rs b/crates/steep-server/src/schemas/mod.rs new file mode 100644 index 0000000..0a80355 --- /dev/null +++ b/crates/steep-server/src/schemas/mod.rs @@ -0,0 +1,5 @@ +pub mod job; +pub mod review; + +pub use job::{Job, JobStatus}; +pub use review::Review; diff --git a/crates/steep-server/src/schemas/review.rs b/crates/steep-server/src/schemas/review.rs new file mode 100644 index 0000000..0041d9d --- /dev/null +++ b/crates/steep-server/src/schemas/review.rs @@ -0,0 +1,36 @@ +use jiff::Timestamp; +use serde::Serialize; + +#[derive(Debug, toasty::Model, Serialize, Clone)] +pub struct Review { + #[key] + #[auto] + pub id: u64, + + #[unique] + pub event_id: u64, + + pub repo: String, + pub pr: u64, + + /// True if the review succeeded. + pub ok: bool, + + /// Model used. + pub model: String, + + /// Markdown posted on the pull request (empty on failure). + pub markdown: String, + + /// OpenRouter cost in USD, if known. + pub cost: Option, + + /// Number of comments produced. + pub comments: u64, + + /// Processing duration in milliseconds. + pub duration_ms: u64, + + #[default(jiff::Timestamp::now())] + pub completed_at: Timestamp, +} diff --git a/crates/steep-server/src/state.rs b/crates/steep-server/src/state.rs index 7ef7b47..d1e0d46 100644 --- a/crates/steep-server/src/state.rs +++ b/crates/steep-server/src/state.rs @@ -1,8 +1,14 @@ -use crate::{bot::Bot, env::EnvConfig, gitea::WebhookType}; +use tokio_util::sync::CancellationToken; + +use crate::{env::EnvConfig, event_bus::EventBus, job_queue::JobQueue, review_store::ReviewStore}; #[derive(Clone)] pub struct AppState { - pub bot_tx: tokio::sync::mpsc::Sender, - pub bot: Bot, + pub queue: JobQueue, + pub reviews: ReviewStore, + pub event_bus: EventBus, + pub bot_name: String, pub config: EnvConfig, + /// Cancelled when the graceful shutdown starts: it closes the SSE streams. + pub shutdown: CancellationToken, } diff --git a/crates/steep-server/toasty/history.toml b/crates/steep-server/toasty/history.toml new file mode 100644 index 0000000..c82f11c --- /dev/null +++ b/crates/steep-server/toasty/history.toml @@ -0,0 +1,6 @@ +version = 1 + +[[migrations]] +id = 3569579561409902287 +name = "0000_init.sql" +snapshot_name = "0000_snapshot.toml" diff --git a/crates/steep-server/toasty/migrations/0000_init.sql b/crates/steep-server/toasty/migrations/0000_init.sql new file mode 100644 index 0000000..5fccfab --- /dev/null +++ b/crates/steep-server/toasty/migrations/0000_init.sql @@ -0,0 +1,28 @@ +CREATE TYPE "job_status" AS ENUM ('pending', 'processing'); +CREATE TABLE "jobs" ( + "id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "event_id" BIGINT NOT NULL, + "repo" TEXT NOT NULL, + "pr" BIGINT NOT NULL, + "payload" JSONB NOT NULL, + "status" "job_status" NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL, + PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "index_jobs_by_event_id" ON "jobs" ("event_id"); +CREATE INDEX "index_jobs_by_status" ON "jobs" ("status"); +CREATE TABLE "reviews" ( + "id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "event_id" BIGINT NOT NULL, + "repo" TEXT NOT NULL, + "pr" BIGINT NOT NULL, + "ok" BOOLEAN NOT NULL, + "model" TEXT NOT NULL, + "markdown" TEXT NOT NULL, + "cost" DOUBLE PRECISION, + "comments" BIGINT NOT NULL, + "duration_ms" BIGINT NOT NULL, + "completed_at" TIMESTAMPTZ(6) NOT NULL, + PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "index_reviews_by_event_id" ON "reviews" ("event_id"); diff --git a/crates/steep-server/toasty/snapshots/0000_snapshot.toml b/crates/steep-server/toasty/snapshots/0000_snapshot.toml new file mode 100644 index 0000000..1a0732c --- /dev/null +++ b/crates/steep-server/toasty/snapshots/0000_snapshot.toml @@ -0,0 +1,160 @@ +version = 1 + +[schema] + +[[schema.tables]] +id = 0 +name = "jobs" +primary_key = { columns = [{ table = 0, index = 0 }], index = { table = 0, index = 0 } } + +[[schema.tables.columns]] +id = { table = 0, index = 0 } +name = "id" +ty = "U64" +storage_ty = { UnsignedInteger = 8 } +auto_increment = true + +[[schema.tables.columns]] +id = { table = 0, index = 1 } +name = "event_id" +ty = "U64" +storage_ty = { UnsignedInteger = 8 } + +[[schema.tables.columns]] +id = { table = 0, index = 2 } +name = "repo" +ty = "String" +storage_ty = "Text" + +[[schema.tables.columns]] +id = { table = 0, index = 3 } +name = "pr" +ty = "U64" +storage_ty = { UnsignedInteger = 8 } + +[[schema.tables.columns]] +id = { table = 0, index = 4 } +name = "payload" +ty = "String" +storage_ty = "Jsonb" + +[[schema.tables.columns]] +id = { table = 0, index = 5 } +name = "status" +ty = "String" +storage_ty = { Enum = { name = "job_status", variants = [{ name = "pending" }, { name = "processing" }] } } + +[[schema.tables.columns]] +id = { table = 0, index = 6 } +name = "created_at" +ty = "Timestamp" +storage_ty = { Timestamp = 6 } + +[[schema.tables.indices]] +id = { table = 0, index = 0 } +name = "index_jobs_by_id" +on = 0 +columns = [{ column = { table = 0, index = 0 }, op = "Eq", scope = "Partition" }] +unique = true +primary_key = true + +[[schema.tables.indices]] +id = { table = 0, index = 1 } +name = "index_jobs_by_event_id" +on = 0 +columns = [{ column = { table = 0, index = 1 }, op = "Eq", scope = "Partition" }] +unique = true + +[[schema.tables.indices]] +id = { table = 0, index = 2 } +name = "index_jobs_by_status" +on = 0 +columns = [{ column = { table = 0, index = 5 }, op = "Eq", scope = "Partition" }] + +[[schema.tables]] +id = 1 +name = "reviews" +primary_key = { columns = [{ table = 1, index = 0 }], index = { table = 1, index = 0 } } + +[[schema.tables.columns]] +id = { table = 1, index = 0 } +name = "id" +ty = "U64" +storage_ty = { UnsignedInteger = 8 } +auto_increment = true + +[[schema.tables.columns]] +id = { table = 1, index = 1 } +name = "event_id" +ty = "U64" +storage_ty = { UnsignedInteger = 8 } + +[[schema.tables.columns]] +id = { table = 1, index = 2 } +name = "repo" +ty = "String" +storage_ty = "Text" + +[[schema.tables.columns]] +id = { table = 1, index = 3 } +name = "pr" +ty = "U64" +storage_ty = { UnsignedInteger = 8 } + +[[schema.tables.columns]] +id = { table = 1, index = 4 } +name = "ok" +ty = "Bool" +storage_ty = "Boolean" + +[[schema.tables.columns]] +id = { table = 1, index = 5 } +name = "model" +ty = "String" +storage_ty = "Text" + +[[schema.tables.columns]] +id = { table = 1, index = 6 } +name = "markdown" +ty = "String" +storage_ty = "Text" + +[[schema.tables.columns]] +id = { table = 1, index = 7 } +name = "cost" +ty = "F64" +storage_ty = { Float = 8 } +nullable = true + +[[schema.tables.columns]] +id = { table = 1, index = 8 } +name = "comments" +ty = "U64" +storage_ty = { UnsignedInteger = 8 } + +[[schema.tables.columns]] +id = { table = 1, index = 9 } +name = "duration_ms" +ty = "U64" +storage_ty = { UnsignedInteger = 8 } + +[[schema.tables.columns]] +id = { table = 1, index = 10 } +name = "completed_at" +ty = "Timestamp" +storage_ty = { Timestamp = 6 } + +[[schema.tables.indices]] +id = { table = 1, index = 0 } +name = "index_reviews_by_id" +on = 1 +columns = [{ column = { table = 1, index = 0 }, op = "Eq", scope = "Partition" }] +unique = true +primary_key = true + +[[schema.tables.indices]] +id = { table = 1, index = 1 } +name = "index_reviews_by_event_id" +on = 1 +columns = [{ column = { table = 1, index = 1 }, op = "Eq", scope = "Partition" }] +unique = true diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..124dc17 --- /dev/null +++ b/deny.toml @@ -0,0 +1,53 @@ +# cargo-deny configuration: https://embarkstudios.github.io/cargo-deny/ +# Run locally with `cargo deny check`. + +[graph] +all-features = true + +[advisories] +# A vulnerable, unmaintained or yanked dependency fails the build. +version = 2 +yanked = "deny" +ignore = [ + # Unmaintained, but only a thin wrapper around `rustls-pki-types`, and pulled by + # `toasty-driver-postgresql`: it can only go away with a toasty release. + { id = "RUSTSEC-2025-0134", reason = "transitive dependency of toasty, no vulnerability" }, +] + +[licenses] +version = 2 +# The workspace crates are not published, and have no license of their own. +private = { ignore = true } +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MIT-0", + "MPL-2.0", + "OpenSSL", + "Unicode-3.0", + "Zlib", +] + +[bans] +multiple-versions = "warn" +wildcards = "deny" +# `path` dependencies between the (unpublished) workspace crates carry no version. +allow-wildcard-paths = true +# TLS goes through rustls: linking libssl would be a regression of the image +# (it would need `libssl` installed again). +deny = [ + { crate = "openssl-sys", reason = "TLS is rustls only, so that the image needs no libssl" }, + { crate = "native-tls", reason = "TLS is rustls only, so that the image needs no libssl" }, +] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/front/css/dashboard.css b/front/css/dashboard.css new file mode 100644 index 0000000..ec12438 --- /dev/null +++ b/front/css/dashboard.css @@ -0,0 +1,366 @@ +/* Dashboard Steep — reprend le design system du site public + (mêmes jetons de couleur, mêmes composants d'en-tête/pied) et ajoute les + styles propres au tableau de bord. */ + +:root { + color-scheme: dark; + --paper: #0b1520; + --surface: #111f2d; + --ink: #edf4f7; + --ink-soft: #c2cfd9; + --muted: #94a8b8; + --line: #263b4d; + --green: #087b5b; + --green-dark: #5bd4a7; + --green-pale: #153a32; + --blue-pale: #1a3044; + --shadow: 0 18px 50px rgba(0, 0, 0, 0.24); + --button-hover: #066449; + --brand-mark-bg: #149b72; + --brand-mark-fg: #07111b; + --step-line: #23604c; + --setup-bg: #07111b; + --radius: 20px; + --content: 1160px; + --red: #d1667f; + --amber: #d7a35b; +} + +:root[data-theme="light"] { + color-scheme: light; + --paper: #f7f8f5; + --surface: #ffffff; + --ink: #102f50; + --ink-soft: #38536c; + --muted: #718092; + --line: #e1e8e5; + --green: #149b72; + --green-dark: #087b5b; + --green-pale: #e8f5ef; + --blue-pale: #edf3f8; + --shadow: 0 18px 50px rgba(16, 47, 80, 0.08); + --button-hover: #087b5b; + --brand-mark-bg: #102f50; + --brand-mark-fg: #ffffff; + --step-line: #cce8da; + --setup-bg: #102f50; + --red: #b04a63; + --amber: #a9741f; +} + +*, *::before, *::after { box-sizing: border-box; } +html { scroll-behavior: smooth; scroll-padding-top: 90px; } +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 16px; + line-height: 1.65; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +a { color: inherit; } +img { display: block; max-width: 100%; } +.container { width: min(var(--content), calc(100% - 48px)); margin-inline: auto; } +.mono { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; } +.muted { color: var(--muted); } + +.site-header { + position: sticky; + z-index: 5; + top: 0; + border-bottom: 1px solid var(--line); + background: var(--paper); + backdrop-filter: blur(16px); +} +.nav { + min-height: 76px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 28px; +} +.wordmark { + display: inline-flex; + align-items: center; + gap: 11px; + color: var(--ink); + text-decoration: none; + font-size: 18px; + font-weight: 850; + letter-spacing: 0.13em; +} +.wordmark-mark { + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 11px; + background: var(--brand-mark-bg); + color: var(--brand-mark-fg); + font-size: 17px; + letter-spacing: 0; +} +.nav-links { display: flex; align-items: center; gap: 22px; } +.nav-links a { + color: var(--ink-soft); + font-size: 14px; + font-weight: 650; + text-decoration: none; + transition: color 160ms ease; +} +.nav-links a:hover { color: var(--green-dark); } +.nav-links .nav-cta { + padding: 10px 16px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface); + color: var(--ink); +} +.nav-links .nav-cta:hover { border-color: var(--green); color: var(--green-dark); } + +.theme-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 13px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface); + color: var(--ink-soft); + font: inherit; + font-size: 13px; + font-weight: 700; + cursor: pointer; + transition: border-color 160ms ease, color 160ms ease, background 160ms ease; +} +.theme-toggle:hover { border-color: var(--green-dark); color: var(--green-dark); } +.theme-icon { color: var(--green-dark); font-size: 16px; line-height: 1; } + +.section { padding: 46px 0 82px; } +.section-heading { max-width: 720px; margin-bottom: 26px; } +.section-heading h1 { + margin-bottom: 12px; + font-size: clamp(30px, 4vw, 44px); + line-height: 1.12; + letter-spacing: -0.045em; +} +.section-heading p { margin-bottom: 0; color: var(--muted); font-size: 17px; } +.eyebrow { + display: inline-flex; + align-items: center; + gap: 9px; + margin: 0 0 16px; + color: var(--green-dark); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} +.eyebrow::before { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--green); + content: ""; +} + +.button { + display: inline-flex; + min-height: 42px; + align-items: center; + justify-content: center; + gap: 9px; + padding: 9px 16px; + border: 1px solid transparent; + border-radius: 999px; + background: var(--green); + color: white; + font: inherit; + font-size: 14px; + font-weight: 750; + text-decoration: none; + cursor: pointer; + transition: transform 160ms ease, background 160ms ease, box-shadow 160ms ease; +} +.button:hover { transform: translateY(-2px); background: var(--button-hover); box-shadow: 0 8px 20px rgba(20, 155, 114, 0.2); } +.button-secondary { border-color: var(--line); background: var(--surface); color: var(--ink); } +.button-secondary:hover { border-color: #b8d8ca; background: var(--green-pale); color: var(--green-dark); box-shadow: none; } +.arrow { font-size: 17px; line-height: 1; } + +/* ---- Tableau de bord ---- */ + +.toolbar { + display: flex; + align-items: center; + justify-content: flex-end; + margin-bottom: 22px; +} + +.board { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; + margin-bottom: 18px; +} + +.panel { + padding: 22px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); + box-shadow: 0 5px 20px rgba(16, 47, 80, 0.025); +} +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} +.panel-head h2 { margin: 0; font-size: 17px; letter-spacing: -0.02em; } +.count { + min-width: 26px; + padding: 3px 9px; + border-radius: 999px; + background: var(--blue-pale); + color: var(--ink); + font-size: 12px; + font-weight: 850; + text-align: center; +} +.detail-panel { margin-bottom: 18px; } + +steep-job-list { display: grid; gap: 10px; } + +.job { + border: 1px solid var(--line); + border-radius: 14px; + background: var(--paper); + transition: border-color 140ms ease, background 140ms ease; +} +.job:hover { border-color: var(--green); } +.job[data-selected="true"] { border-color: var(--green); background: var(--green-pale); } + +.job-head { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: baseline; + gap: 10px; + padding: 13px 15px; + cursor: pointer; + list-style: none; +} +.job-head::-webkit-details-marker { display: none; } +.job-repo { font-weight: 700; font-size: 14px; overflow-wrap: anywhere; } +.job-pr { color: var(--green-dark); font-weight: 800; font-size: 13px; white-space: nowrap; } + +.job-info { display: grid; gap: 12px; padding: 0 15px 15px; } +.job-info dl { display: grid; gap: 6px; margin: 0; } +.job-info dl > div { display: grid; grid-template-columns: 74px 1fr; gap: 10px; } +.job-info dt { color: var(--muted); font-size: 12px; } +.job-info dd { margin: 0; font-size: 12px; overflow-wrap: anywhere; } +.job-watch { justify-self: start; } + +/* Chevron partagé par tous les
repliables. */ +.chev::before { color: var(--muted); content: "▸"; font-size: 11px; } +details[open] .chev::before { content: "▾"; } + +.empty { margin: 0; padding: 14px 2px; color: var(--muted); font-size: 14px; } + +.pill { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 800; + text-transform: lowercase; +} +.pill.done { background: var(--green-pale); color: var(--green-dark); } +.pill.failed { background: rgba(209, 102, 127, 0.16); color: var(--red); } + +.reviews { overflow-x: auto; } +.reviews-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.reviews-table th { + padding: 0 12px 10px 0; + border-bottom: 1px solid var(--line); + color: var(--muted); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + text-align: left; +} +.reviews-table td { padding: 11px 12px 11px 0; border-bottom: 1px solid var(--line); vertical-align: top; } +.reviews-table tr:last-child td { border-bottom: 0; } + +/* Journal d'activité d'un job */ +.log { + max-height: 360px; + overflow-y: auto; + padding: 4px 2px; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 12.5px; + line-height: 1.6; +} +.log-entry { display: grid; grid-template-columns: auto 1fr; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--line); } +.log-entry:last-child { border-bottom: 0; } + +/* Résultat d'outil replié par défaut : le résumé tient sur une ligne, le corps + complet s'affiche au dépliage. */ +.log-result { padding: 6px 0; border-bottom: 1px solid var(--line); } +.log-result:last-child { border-bottom: 0; } +.log-result > summary { + display: grid; + grid-template-columns: auto auto 1fr; + gap: 10px; + align-items: baseline; + cursor: pointer; + list-style: none; +} +.log-result > summary::-webkit-details-marker { display: none; } +.log-result > .log-body { padding-top: 6px; } +.log-preview { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.log-tag { + align-self: start; + padding: 2px 8px; + border-radius: 7px; + background: var(--blue-pale); + color: var(--ink); + font-size: 11px; + font-weight: 800; + white-space: nowrap; +} +.log-entry[data-kind="tool_call"] .log-tag { background: var(--green-pale); color: var(--green-dark); } +.log-entry[data-kind="finished"] .log-tag { background: var(--green-pale); color: var(--green-dark); } +.log-entry[data-kind="gap"] .log-tag { background: rgba(215, 163, 91, 0.18); color: var(--amber); } +.log-body { overflow-wrap: anywhere; white-space: pre-wrap; } +.log-args { color: var(--muted); } +.log-truncated { color: var(--amber); font-style: italic; } + +footer { padding: 26px 0; background: #0b233b; color: #b6c5d3; font-size: 13px; } +.footer-inner { display: flex; align-items: center; justify-content: space-between; gap: 20px; } +footer a { color: white; text-decoration: none; } +footer a:hover { color: #8be0bc; } +.footer-brand { color: white; font-weight: 800; letter-spacing: 0.1em; } + +:focus-visible { outline: 3px solid #35bb8e; outline-offset: 4px; } + +@media (max-width: 820px) { + .board { grid-template-columns: 1fr; } +} +@media (max-width: 600px) { + .container { width: min(var(--content), calc(100% - 32px)); } + .nav { min-height: 66px; gap: 12px; } + .nav-links { gap: 12px; } + .nav-links .nav-cta { display: none; } + .section { padding: 30px 0 62px; } + .toolbar { flex-direction: column; align-items: stretch; } + .footer-inner { align-items: flex-start; flex-direction: column; gap: 6px; } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; } +} diff --git a/front/favicon.png b/front/favicon.png new file mode 100644 index 0000000..e7017a4 Binary files /dev/null and b/front/favicon.png differ diff --git a/front/index.html b/front/index.html new file mode 100644 index 0000000..02e90ff --- /dev/null +++ b/front/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + Steep — Dashboard + + + + + + +
+ +
+ +
+ +
+ + + + diff --git a/front/js/api.js b/front/js/api.js new file mode 100644 index 0000000..144f590 --- /dev/null +++ b/front/js/api.js @@ -0,0 +1,78 @@ +// Accès aux routes exposées par le serveur axum et petits utilitaires partagés +// par les web components du dashboard. + +export const endpoints = { + jobs: "/jobs", + reviews: "/reviews", + lifecycle: "/events", + jobEvents: (id) => `/jobs/${id}/events`, +}; + +async function getJson(url) { + const response = await fetch(url, { headers: { accept: "application/json" } }); + if (!response.ok) { + throw new Error(`${url} → ${response.status}`); + } + return response.json(); +} + +export const fetchJobs = () => getJson(endpoints.jobs); +export const fetchReviews = () => getJson(endpoints.reviews); + +// Ouvre un flux SSE et route les évènements nommés vers les callbacks fournis. +// Les évènements du serveur sont nommés (`lifecycle`, `progress`, `gap`), donc +// `addEventListener("message")` ne suffit pas : il faut écouter par nom. +export function subscribe(url, handlers = {}) { + const source = new EventSource(url); + + const onData = (name, handler) => { + if (handler) { + source.addEventListener(name, (event) => handler(JSON.parse(event.data))); + } + }; + + if (handlers.onOpen) source.addEventListener("open", () => handlers.onOpen()); + if (handlers.onError) source.addEventListener("error", () => handlers.onError()); + onData("lifecycle", handlers.onLifecycle); + onData("progress", handlers.onProgress); + if (handlers.onGap) { + source.addEventListener("gap", (event) => handlers.onGap(Number(event.data))); + } + + return source; +} + +export function escapeHtml(value) { + return String(value ?? "").replace(/[&<>"']/g, (char) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[char]); +} + +// Horodatage relatif (« il y a 3 min »), tolérant sur les valeurs absentes. +export function relativeTime(value) { + if (!value) return "—"; + const then = Date.parse(value); + if (Number.isNaN(then)) return "—"; + + const seconds = Math.round((then - Date.now()) / 1000); + const steps = [ + ["year", 60 * 60 * 24 * 365], + ["month", 60 * 60 * 24 * 30], + ["day", 60 * 60 * 24], + ["hour", 60 * 60], + ["minute", 60], + ["second", 1], + ]; + + const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); + for (const [unit, size] of steps) { + if (Math.abs(seconds) >= size || unit === "second") { + return formatter.format(Math.round(seconds / size), unit); + } + } + return "—"; +} diff --git a/front/js/dashboard.js b/front/js/dashboard.js new file mode 100644 index 0000000..80309b3 --- /dev/null +++ b/front/js/dashboard.js @@ -0,0 +1,212 @@ +// : assemble le tableau de bord. +// +// Au montage, il charge l'état initial via REST (`/jobs`, `/reviews`), puis +// s'abonne au flux global `/events` pour tenir les listes à jour en direct. +// La sélection d'un job ouvre son flux fin via . + +import { + endpoints, + escapeHtml, + fetchJobs, + fetchReviews, + relativeTime, + subscribe, +} from "./api.js"; +import "./theme-toggle.js"; +import "./job-list.js"; +import "./job-detail.js"; + +class Dashboard extends HTMLElement { + connectedCallback() { + this._jobs = { pending: [], processing: [] }; + this._reviews = []; + this._selectedId = null; + + this.render(); + this.refresh(); + this.connect(); + } + + disconnectedCallback() { + this._source?.close(); + if (this._refreshTimer) clearTimeout(this._refreshTimer); + } + + // ---- Chargement et flux ------------------------------------------------- + + async refresh() { + try { + const [jobs, reviews] = await Promise.all([fetchJobs(), fetchReviews()]); + this._jobs = { + pending: jobs.pending ?? [], + processing: jobs.processing ?? [], + }; + this._reviews = reviews.reviews ?? []; + this.update(); + } catch (error) { + console.error("failed to refresh the dashboard", error); + } + } + + connect() { + this._source = subscribe(endpoints.lifecycle, { + onLifecycle: (event) => this.applyLifecycle(event), + // En cas de gap (évènements ratés), on recharge l'état depuis le REST. + onGap: () => this.refresh(), + }); + } + + // Recharge la liste des reviews peu après une fin de job, pour récupérer les + // champs que l'évènement ne porte pas (markdown, coût, modèle). + scheduleRefresh() { + if (this._refreshTimer) clearTimeout(this._refreshTimer); + this._refreshTimer = setTimeout(() => this.refresh(), 400); + } + + applyLifecycle(event) { + switch (event.type) { + case "queued": + if (!this._jobs.pending.some((job) => job.id === event.id)) { + this._jobs.pending.push(this.jobFromEvent(event)); + } + break; + case "started": { + // On garde le job déjà connu s'il existe, pour conserver les champs que + // l'évènement `started` ne porte pas (event_id, created_at). + const known = this._jobs.pending.find((job) => job.id === event.id); + this.removeJob(event.id); + this._jobs.processing.push(known ?? this.jobFromEvent(event)); + break; + } + case "review_done": + case "failed": + this.removeJob(event.id); + this.scheduleRefresh(); + break; + case "skipped": + this.removeJob(event.id); + break; + default: + return; + } + this.update(); + } + + removeJob(id) { + this._jobs.pending = this._jobs.pending.filter((job) => job.id !== id); + this._jobs.processing = this._jobs.processing.filter((job) => job.id !== id); + } + + // L'évènement de cycle de vie porte assez de champs pour l'affichage ; le + // rafraîchissement REST fournira le reste (created_at, payload…). + jobFromEvent(event) { + return { + id: event.id, + event_id: event.event_id, + repo: event.repo, + pr: event.pr, + created_at: event.created_at, + }; + } + + // ---- Rendu -------------------------------------------------------------- + + render() { + this.innerHTML = ` +
+
+

Live operations

+

Review dashboard

+

Pending jobs, work in progress, and completed reviews — kept in sync over SSE.

+
+ +
+ +
+ +
+
+

Pending

0
+ +
+
+

In progress

0
+ +
+
+ +
+

Activity

+ +
+ +
+

Completed reviews

0
+
+
+
`; + + this._pending = this.querySelector("#pending"); + this._processing = this.querySelector("#processing"); + this._detail = this.querySelector("#detail"); + this._reviewsEl = this.querySelector("#reviews"); + + this.querySelector("#refresh").addEventListener("click", () => this.refresh()); + this.addEventListener("job-select", (event) => this.select(event.detail.id)); + + this.update(); + } + + update() { + this._pending.jobs = this._jobs.pending; + this._pending.selected = this._selectedId; + this._processing.jobs = this._jobs.processing; + this._processing.selected = this._selectedId; + + this.querySelector("#pending-count").textContent = String(this._jobs.pending.length); + this.querySelector("#processing-count").textContent = String(this._jobs.processing.length); + this.querySelector("#reviews-count").textContent = String(this._reviews.length); + + this._reviewsEl.innerHTML = this.reviewsHtml(); + } + + select(id) { + this._selectedId = id; + this._detail.jobId = id; + this.update(); + this._detail.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + + reviewsHtml() { + if (this._reviews.length === 0) { + return `

No review completed yet.

`; + } + + const rows = [...this._reviews] + .sort((a, b) => String(b.completed_at ?? "").localeCompare(String(a.completed_at ?? ""))) + .map( + (review) => ` + + ${review.ok ? "done" : "failed"} + ${escapeHtml(review.repo)}#${review.pr} + ${review.comments} + ${review.duration_ms} ms + ${escapeHtml(review.model ?? "—")} + ${relativeTime(review.completed_at)} + `, + ) + .join(""); + + return ` + + + + + + + ${rows} +
StatusPull requestCommentsDurationModelCompleted
`; + } +} + +customElements.define("steep-dashboard", Dashboard); diff --git a/front/js/job-card.js b/front/js/job-card.js new file mode 100644 index 0000000..6853d4d --- /dev/null +++ b/front/js/job-card.js @@ -0,0 +1,61 @@ +// Carte d'un job. Le détail (id, event, date) est replié par défaut ; le +// bouton « Watch activity » remonte un évènement `job-select` que le dashboard +// écoute pour ouvrir le flux d'activité du job. + +import { escapeHtml, relativeTime } from "./api.js"; + +class JobCard extends HTMLElement { + set job(value) { + this._job = value; + this.render(); + } + + get job() { + return this._job; + } + + set selected(value) { + this._selected = Boolean(value); + this.render(); + } + + connectedCallback() { + this.render(); + } + + render() { + if (!this._job) return; + const job = this._job; + + // Préserve l'état replié/déplié à travers les re-rendus (mises à jour SSE). + const open = this._details?.open ? " open" : ""; + + this.innerHTML = ` +
+ + + ${escapeHtml(job.repo)} + #${job.pr} + +
+
+
Job
${job.id}
+ ${job.event_id != null ? `
Event
${job.event_id}
` : ""} +
Created
${escapeHtml(relativeTime(job.created_at))}
+
+ +
+
`; + + this._details = this.querySelector("details"); + this.querySelector(".job-watch").addEventListener("click", () => this.select()); + } + + select() { + this.dispatchEvent( + new CustomEvent("job-select", { detail: { id: this._job.id }, bubbles: true }), + ); + } +} + +customElements.define("steep-job-card", JobCard); diff --git a/front/js/job-detail.js b/front/js/job-detail.js new file mode 100644 index 0000000..abe149e --- /dev/null +++ b/front/js/job-detail.js @@ -0,0 +1,150 @@ +// Panneau d'activité d'un job : ouvre un flux SSE vers `/jobs/{id}/events`, +// rejoue l'historique renvoyé par le serveur puis affiche le direct. + +import { endpoints, escapeHtml, subscribe } from "./api.js"; + +class JobDetail extends HTMLElement { + set jobId(value) { + const id = value ?? null; + if (this._jobId === id) return; + this._jobId = id; + this.open(); + } + + connectedCallback() { + if (!this._jobId) this.renderPlaceholder(); + } + + disconnectedCallback() { + this.close(); + } + + close() { + if (this._source) { + this._source.close(); + this._source = null; + } + } + + open() { + this.close(); + this._finished = false; + + if (!this._jobId) { + this.renderPlaceholder(); + return; + } + + this.innerHTML = ` +
`; + this._log = this.querySelector("#log"); + + this._source = subscribe(endpoints.jobEvents(this._jobId), { + onProgress: (event) => { + this.append(event); + if (event.type === "finished") { + this._finished = true; + this.close(); + } + }, + onGap: (skipped) => + this.append({ type: "gap", note: `missed ${skipped} events, refresh to resync` }), + // Un `error` survient aussi quand le serveur ferme le flux (job terminé). + // On ferme alors la source pour éviter la boucle de reconnexion (le job + // n'est plus en cours, la route renverrait 404). + onError: () => { + if (this._finished) return; + this.append({ type: "gap", note: "stream closed" }); + this.close(); + }, + }); + } + + renderPlaceholder() { + this.innerHTML = `

Select a job in progress to watch the agent work.

`; + } + + append(event) { + if (!this._log) return; + this._log.append(this.entry(event)); + this._log.scrollTop = this._log.scrollHeight; + } + + entry(event) { + return event.type === "tool_result" ? this.resultEntry(event) : this.lineEntry(event); + } + + // Ligne simple : appel d'outil, fin de review, gap. + lineEntry(event) { + const row = document.createElement("div"); + row.className = "log-entry"; + row.dataset.kind = event.type; + + const { tag, body } = this.describe(event); + + const tagEl = document.createElement("span"); + tagEl.className = "log-tag"; + tagEl.textContent = tag; + + const bodyEl = document.createElement("span"); + bodyEl.className = "log-body"; + bodyEl.innerHTML = body; + + row.append(tagEl, bodyEl); + return row; + } + + // Résultat d'outil : replié par défaut, avec un aperçu sur une ligne. + resultEntry(event) { + const details = document.createElement("details"); + details.className = "log-result"; + + const summary = document.createElement("summary"); + const chev = document.createElement("span"); + chev.className = "chev"; + chev.setAttribute("aria-hidden", "true"); + const tag = document.createElement("span"); + tag.className = "log-tag"; + tag.textContent = "result"; + const preview = document.createElement("span"); + preview.className = "log-preview"; + preview.textContent = this.preview(event.output); + summary.append(chev, tag, preview); + + const body = document.createElement("div"); + body.className = "log-body"; + const truncated = event.truncated + ? ' (truncated)' + : ""; + body.innerHTML = `${escapeHtml(event.output)}${truncated}`; + + details.append(summary, body); + return details; + } + + preview(text) { + const line = String(text ?? "").replace(/\s+/g, " ").trim(); + return line.length > 80 ? `${line.slice(0, 80)}…` : line; + } + + describe(event) { + switch (event.type) { + case "tool_call": + return { + tag: "call", + body: `${escapeHtml(event.name)} ${escapeHtml(event.arguments)}`, + }; + case "finished": + return { + tag: "done", + body: event.ok ? "review finished" : "review failed", + }; + case "gap": + return { tag: "gap", body: escapeHtml(event.note ?? "gap") }; + default: + return { tag: escapeHtml(event.type ?? "event"), body: "" }; + } + } +} + +customElements.define("steep-job-detail", JobDetail); diff --git a/front/js/job-list.js b/front/js/job-list.js new file mode 100644 index 0000000..f79221c --- /dev/null +++ b/front/js/job-list.js @@ -0,0 +1,58 @@ +// Liste de jobs (une colonne du tableau). Reçoit un tableau de jobs via la +// propriété `jobs` et l'id sélectionné via `selected`. + +import "./job-card.js"; + +class JobList extends HTMLElement { + set jobs(value) { + this._jobs = Array.isArray(value) ? value : []; + this.render(); + } + + get jobs() { + return this._jobs ?? []; + } + + set selected(value) { + this._selected = value; + this.render(); + } + + connectedCallback() { + if (this._mounted) return; + this._mounted = true; + this.render(); + } + + render() { + const jobs = this.jobs; + + if (jobs.length === 0) { + this.replaceChildren(); + const empty = document.createElement("p"); + empty.className = "empty"; + empty.textContent = this.getAttribute("empty") || "Nothing here."; + this.append(empty); + return; + } + + // Réconciliation par id : on réutilise les cartes existantes pour préserver + // l'état replié/déplié de chacune à travers les mises à jour. + const known = new Map(); + for (const card of this.querySelectorAll("steep-job-card")) { + if (card.job) known.set(card.job.id, card); + } + + for (const job of jobs) { + const card = known.get(job.id) ?? document.createElement("steep-job-card"); + known.delete(job.id); + card.job = job; + card.selected = job.id === this._selected; + this.append(card); + } + + for (const card of known.values()) card.remove(); + } +} + +customElements.define("steep-job-list", JobList); diff --git a/front/js/theme-toggle.js b/front/js/theme-toggle.js new file mode 100644 index 0000000..774ec53 --- /dev/null +++ b/front/js/theme-toggle.js @@ -0,0 +1,59 @@ +// Bascule de thème, identique à celle du site public (même clé localStorage, +// même rendu), empaquetée en web component. + +const STORAGE_KEY = "steep-theme"; + +export function currentTheme() { + return document.documentElement.dataset.theme === "light" ? "light" : "dark"; +} + +export function applyTheme(theme, persist = true) { + document.documentElement.dataset.theme = theme; + + const meta = document.querySelector('meta[name="theme-color"]'); + if (meta) meta.content = theme === "dark" ? "#0b1520" : "#f7f8f5"; + + if (persist) { + try { + localStorage.setItem(STORAGE_KEY, theme); + } catch (_) { + // stockage indisponible : on garde juste le thème en mémoire. + } + } + + window.dispatchEvent(new CustomEvent("steep:theme", { detail: theme })); +} + +class ThemeToggle extends HTMLElement { + connectedCallback() { + this.innerHTML = ` + `; + + this._button = this.querySelector("button"); + this._icon = this.querySelector(".theme-icon"); + this._label = this.querySelector(".theme-label"); + this._onTheme = () => this.render(); + + this._button.addEventListener("click", () => { + applyTheme(currentTheme() === "dark" ? "light" : "dark"); + }); + window.addEventListener("steep:theme", this._onTheme); + + this.render(); + } + + disconnectedCallback() { + window.removeEventListener("steep:theme", this._onTheme); + } + + render() { + const theme = currentTheme(); + this._icon.textContent = theme === "dark" ? "☾" : "☀"; + this._label.textContent = theme === "dark" ? "Dark theme" : "Light theme"; + this._button.setAttribute("aria-checked", String(theme === "dark")); + } +} + +customElements.define("steep-theme-toggle", ThemeToggle); diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..ba1b3cf Binary files /dev/null and b/logo.png differ diff --git a/public/Caddyfile b/public/Caddyfile new file mode 100644 index 0000000..9fecb4d --- /dev/null +++ b/public/Caddyfile @@ -0,0 +1,5 @@ +:80 { + root * /usr/share/caddy + encode zstd gzip + file_server +} diff --git a/public/Containerfile b/public/Containerfile new file mode 100644 index 0000000..f07906b --- /dev/null +++ b/public/Containerfile @@ -0,0 +1,4 @@ +FROM caddy:2-alpine + +COPY Caddyfile /etc/caddy/Caddyfile +COPY index.html favicon.png banniere_readme.png /usr/share/caddy/ diff --git a/banniere_readme.png b/public/banniere_readme.png similarity index 100% rename from banniere_readme.png rename to public/banniere_readme.png diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..e7017a4 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..c29f29e --- /dev/null +++ b/public/index.html @@ -0,0 +1,646 @@ + + + + + + + + + + Steep — Let your code steep + + + + + + +
+
+
+ Steep — Let your code steep +
+
+
+

AI code review · built for Gitea

+

Code reviews with the full picture.

+
+
+

Steep explores pull requests inside an isolated Dev Container, then leaves focused, line-by-line feedback right where your team works.

+ +
+
+
+ +
+
+
+

Thoughtful by design

+

Useful reviews. No noisy pipeline.

+

Bring an AI reviewer into your Gitea workflow with a single comment.

+
+
+
+ +

Reviews on demand

+

Mention Steep in a pull-request comment and get an AI review streamed back to the PR. No CI hook to wire, no pipeline to learn.

+
+
+ +

Sandboxed by design

+

Every review runs in an ephemeral Dev Container with read-only tools. The network is cut after setup, and the container is removed when the review ends.

+
+
+ +

Lightweight & observable

+

A lean Rust service designed for fast reviews, with Prometheus metrics and Sentry error tracking.

+
+
+
+
+ +
+
+
+

From comment to context

+
+

Your code stays in its environment.

+

Steep gives the model tools to inspect the repository instead of sending the source code in the prompt.

+
+ Set up Steep +
+
+
+ 01 +

Steep hears your request

A Gitea pull-request comment triggers a review when you mention the bot.

+
+
+ 02 +

A disposable workspace spins up

Steep clones the pull-request head and starts the repository's Dev Container—or a safe default when none is configured.

+
+
+ 03 +

The model explores, read-only

It can inspect files with tools such as ls, read_file, and grep. Setup hooks run first; then network access is removed.

+
+
+ 04 +

Feedback lands on the changed lines

Steep posts actionable findings with severity labels, plus a concise summary of what the PR does well.

+
+
+
+
+ +
+
+
+

What’s brewing

+

The roadmap

+

From safer reviews to an assistant that can help move work forward.

+
+
    +
  • 1.2Sandboxed pull-request reviews.
  • +
  • 1.2.1A default Dev Container when a repository has no configuration.
  • +
  • 1.3Durable reviews: persist jobs in PostgreSQL, list them by state (running, pending, review done), and stream live updates over SSE.
  • +
  • 1.4Configuration from a YAML file, replacing part of the environment variables.
  • +
  • 2.0Type-safe action routing between reviews and implementation requests, implement requested changes from issues or existing PRs, then create or update a PR automatically.
  • +
  • 2.1Project instructions from AGENTS.md and separate review/implementation models.
  • +
  • 2.2Sub-agents that take on well-scoped parts of a task and run in parallel.
  • +
  • 3.0Automatically investigate and fix project errors reported by Sentry.
  • +
+
+
+ +
+
+
+

A few minutes to get steeping

+

Bring Steep to your Gitea.

+

Follow these steps in order. You will need a Rust toolchain, a reachable Docker or Podman daemon, an OpenRouter API key, and a Gitea account for Steep.

+
+
+
+

1. Install and configure

+
    +
  1. + Clone the source +
    git clone https://gitea.qpismont.fr/qpismont/herald.git
    +cd herald
    +
  2. +
  3. + Create a Gitea token +

    Create a token for the account Steep should act as. Give it access to the repositories it will review, including permission to read pull requests and publish comments/reviews. Steep uses this token to identify the account; you will use that account's username when asking for a review.

    +
  4. +
  5. + Configure every required setting +
    cp .env.example .env
    +$EDITOR .env
    +

    Set all nine required variables:

    +
      +
    • GITEA_URL — base URL, e.g. https://gitea.example.com (no /api/v1)
    • +
    • GITEA_TOKEN — the token created above
    • +
    • GITEA_TIMEOUT — API timeout in seconds
    • +
    • OPEN_ROUTER_API_KEY — your OpenRouter key
    • +
    • OPEN_ROUTER_MODEL — an available OpenRouter model ID
    • +
    • OPEN_ROUTER_TIMEOUT — request timeout in seconds
    • +
    • BOT_MAX_CONCURRENT — maximum simultaneous reviews
    • +
    • HTTP_PORT — web server port; use 3001 for the commands below
    • +
    • WEBHOOK_SIG_HEADER_SECRET — generate this in the next step
    • +
    +

    Keep the example's numeric values for the timeouts and concurrency, or replace them with positive integers. Do not commit your completed .env.

    +
  6. +
  7. + Generate and configure the webhook secret +
    openssl rand -hex 32
    +

    Copy the output into WEBHOOK_SIG_HEADER_SECRET in .env. You will enter the exact same value in Gitea in the next step.

    +
  8. +
  9. + Start the daemon, build, and launch Steep +
    podman system service --time=0 &
    +export DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock
    +cargo build --release -p steep-server
    +./target/release/steep-server
    +

    Keep this process running. With Docker, omit the Podman command and use its socket; the default is unix:///var/run/docker.sock. Optional settings such as metrics and Sentry can be added later.

    +
  10. +
+ Explore the source on Gitea +
+ +
+

2. Add the Gitea webhook

+
    +
  1. + Choose an address Gitea can reach +

    In the repository's Settings → Webhooks, add a Gitea webhook. Set the target to Steep's public/reachable address and port:

    +
    http://steep.example.com:3001/webhook
    Replace the host and scheme with yours. Do not use localhost unless Gitea and Steep share that network.
    +
  2. +
  3. + Select the event and set the secret +

    Enable at least Pull Request Comment. Paste the generated value into Gitea's Secret field; it must exactly match WEBHOOK_SIG_HEADER_SECRET.

    +
  4. +
  5. + Allow the connection +

    Make sure Gitea can connect to Steep on port 3001 (or your configured HTTP_PORT), including through any firewall or reverse proxy. Steep must also be able to reach Gitea's API, OpenRouter, and the container daemon.

    +
  6. +
  7. + Trigger a review +

    Open a pull request and add a comment that starts with @username, using the username of the Gitea account behind GITEA_TOKEN. For example:

    +
    @steep review this PR
    +

    The mention must be at the very start of the comment. If nothing happens, check the webhook's delivery history in Gitea and Steep's logs.

    +
  8. +
+
+
+
+
+
+ +
+ +
+ + + diff --git a/scripts/build-website.sh b/scripts/build-website.sh new file mode 100755 index 0000000..4697daf --- /dev/null +++ b/scripts/build-website.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE="docker.io/tintounn/steep-website" + +if [ -z "${CI_COMMIT_TAG:-}" ]; then + echo "Error: CI_COMMIT_TAG is not set" >&2 + exit 1 +fi + +TAG="${CI_COMMIT_TAG}" +IMAGE_REF="${IMAGE}:${TAG}" + +echo "Building ${IMAGE_REF}..." +buildah build \ + --file public/Containerfile \ + --tag "${IMAGE_REF}" \ + public + +echo "Pushing ${IMAGE_REF}..." +buildah push "${IMAGE_REF}" + +echo "Done: ${IMAGE_REF}" diff --git a/scripts/migrate-apply.sh b/scripts/migrate-apply.sh new file mode 100755 index 0000000..5dff092 --- /dev/null +++ b/scripts/migrate-apply.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [ -f "${ROOT_DIR}/.env" ]; then + set -a + # shellcheck disable=SC1091 + . "${ROOT_DIR}/.env" + set +a +fi + +cd "${ROOT_DIR}" + +if [ -x "${ROOT_DIR}/steep-server" ]; then + exec "${ROOT_DIR}/steep-server" migration apply "$@" +else + exec cargo run -p steep-server -- migration apply "$@" +fi diff --git a/scripts/migrate-generate.sh b/scripts/migrate-generate.sh new file mode 100755 index 0000000..bbce364 --- /dev/null +++ b/scripts/migrate-generate.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [ -f "${ROOT_DIR}/.env" ]; then + set -a + . "${ROOT_DIR}/.env" + set +a +fi + +cd "${ROOT_DIR}" + +if [ "$#" -eq 0 ]; then + echo "Usage: $0 " >&2 + echo "Exemple: $0 add_users_table" >&2 + exit 1 +fi + +NAME="$1" + +exec cargo run -p steep-server -- migration generate --name "${NAME}"