diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..d0f7273 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# +# pre-push hook -- block a push that CI would reject. +# +# Runs the fast, no-compile gates (formatting + supply-chain audit) that have +# historically slipped through to CI. These are cheap (no project build), so +# they are safe to run on every push. Heavier gates (clippy, test) are NOT run +# here to avoid rebuilding on every push; run `scripts/preflight.sh` for the +# full local CI mirror before opening or updating a PR. +# +# Enable once per clone: +# git config core.hooksPath .githooks +# +# Bypass in an emergency (use sparingly, you own the CI result): +# git push --no-verify + +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +preflight="$repo_root/scripts/preflight.sh" + +if [[ ! -x "$preflight" ]]; then + echo "pre-push: scripts/preflight.sh missing or not executable; skipping gate" >&2 + exit 0 +fi + +echo "pre-push: running fast preflight (fmt + audit) ..." +if ! "$preflight" --fast; then + echo "" >&2 + echo "pre-push: BLOCKED -- fix the above before pushing (CI would fail)." >&2 + echo " run 'scripts/preflight.sh' for the full check, or" >&2 + echo " 'git push --no-verify' to bypass (you own the CI result)." >&2 + exit 1 +fi + +exit 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72d18da..8d3c019 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,18 +80,24 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo test -p frameshift-catalog-postgres -- --include-ignored - # Supply-chain advisory scan against the RustSec database. Non-blocking for - # now (continue-on-error) so a pre-existing transitive advisory cannot wedge - # the pipeline before the backlog is triaged; ratchet to blocking once clean. + # Supply-chain advisory scan against the RustSec database. Now blocking: the + # advisory backlog has been triaged and the vulnerable dependencies bumped, so + # a new advisory should fail the pipeline rather than slip in silently. + # + # The single ignore is RUSTSEC-2024-0370 (proc-macro-error unmaintained). It is + # a build-time-only proc-macro pulled transitively by age -> i18n-embed-fl; it + # never ships in any runtime artifact, and the only way to drop it is to bump + # age from 0.10 to the 0.11 BETA, which is unacceptable for the crate that + # backs local vault encryption. Re-evaluate when age ships a stable 0.11. audit: name: Security audit runs-on: ubuntu-latest - continue-on-error: true steps: - uses: actions/checkout@v4 - uses: rustsec/audit-check@v2 with: token: ${{ secrets.GITHUB_TOKEN }} + ignore: RUSTSEC-2024-0370 # Later tier (intentionally not yet wired, tracked in the roadmap): # - cargo-deny (needs a deny.toml: license allowlist, ban list, advisory policy) diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index b0f28c6..9dc7048 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -18,5 +18,13 @@ jobs: env: MIRROR_TOKEN: ${{ secrets.MIRROR_TOKEN }} run: | - git remote add mirror https://x-access-token:${MIRROR_TOKEN}@github.com/Syntheos-Systems/FrameShift.git - git push mirror --mirror \ No newline at end of file + git remote add mirror "https://x-access-token:${MIRROR_TOKEN}@github.com/Syntheos-Systems/FrameShift.git" + # actions/checkout only fetches the triggering ref, so pull every source + # branch into remote-tracking refs before mirroring. + git fetch --prune origin '+refs/heads/*:refs/remotes/origin/*' + # Mirror all branches and tags. We deliberately avoid `git push --mirror`: + # it deletes refs absent from the local checkout, which fails because the + # remote refuses to delete its default branch `main`. Force-push so the + # mirror still follows rebases and force-updates on the source. + git push --force mirror 'refs/remotes/origin/*:refs/heads/*' + git push --force mirror 'refs/tags/*:refs/tags/*' \ No newline at end of file diff --git a/.gitignore b/.gitignore index e9c702d..2b919c8 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ data/ # Secrets bin/leak-patterns.txt + +# Local audit working directories (raw findings, local paths, tool identifiers) +.audit-fleet/ +.audit-fleet-web/ diff --git a/Cargo.lock b/Cargo.lock index ec7e2d9..2be7a0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,15 +149,15 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -172,6 +172,22 @@ dependencies = [ "serde_json", ] +[[package]] +name = "astral-tokio-tar" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08648fef353ab39a9d26f909ad53fc4f071be4c91853b78523f5cc3d9e5ebffd" +dependencies = [ + "futures-core", + "libc", + "portable-atomic", + "rustc-hash 2.1.2", + "rustix 0.38.44", + "tokio", + "tokio-stream", + "xattr", +] + [[package]] name = "async-compression" version = "0.4.42" @@ -184,6 +200,28 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -192,7 +230,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -212,9 +250,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" @@ -298,13 +336,13 @@ dependencies = [ [[package]] name = "bb8" -version = "0.8.6" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89aabfae550a5c44b43ab941844ffcd2e993cb6900b342debf59e9ea74acdb8" +checksum = "457d7ed3f888dfd2c7af56d4975cade43c622f74bdcddfed6d4352f57acc6310" dependencies = [ - "async-trait", "futures-util", "parking_lot", + "portable-atomic", "tokio", ] @@ -322,9 +360,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "block-buffer" @@ -337,20 +375,23 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] [[package]] name = "bollard" -version = "0.18.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97ccca1260af6a459d75994ad5acc1651bcabcbdbc41467cc9786519ab854c30" +checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" dependencies = [ + "async-stream", "base64 0.22.1", + "bitflags 2.13.0", + "bollard-buildkit-proto", "bollard-stubs", "bytes", "futures-core", @@ -365,33 +406,54 @@ dependencies = [ "hyper-util", "hyperlocal", "log", + "num", "pin-project-lite", + "rand 0.9.4", "rustls", "rustls-native-certs", - "rustls-pemfile", "rustls-pki-types", "serde", "serde_derive", "serde_json", - "serde_repr", "serde_urlencoded", "thiserror 2.0.18", + "time", "tokio", + "tokio-stream", "tokio-util", + "tonic", "tower-service", "url", "winapi", ] +[[package]] +name = "bollard-buildkit-proto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" +dependencies = [ + "prost", + "prost-types", + "tonic", + "tonic-prost", + "ureq 3.3.0", +] + [[package]] name = "bollard-stubs" -version = "1.47.1-rc.27.3.1" +version = "1.52.1-rc.29.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f179cfbddb6e77a5472703d4b30436bff32929c0aa8a9008ecf23d1d3cdd0da" +checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" dependencies = [ + "base64 0.22.1", + "bollard-buildkit-proto", + "bytes", + "prost", "serde", + "serde_json", "serde_repr", - "serde_with", + "time", ] [[package]] @@ -405,9 +467,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" @@ -423,15 +485,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "cc" -version = "1.2.62" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "shlex", @@ -462,9 +524,9 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -486,9 +548,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -540,7 +602,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -551,9 +613,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmov" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "colorchoice" @@ -660,9 +722,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", ] @@ -700,17 +762,17 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "darling" -version = "0.20.11" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] @@ -725,16 +787,16 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.11" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -747,18 +809,18 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "darling_macro" -version = "0.20.11" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core 0.20.11", + "darling_core 0.21.3", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -769,7 +831,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -856,20 +918,20 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] [[package]] name = "diesel" -version = "2.2.12" +version = "2.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229850a212cd9b84d4f0290ad9d294afc0ae70fccaa8949dbe8b43ffafa1e20c" +checksum = "29fe29a87fb84c631ffb3ba21798c4b1f3a964701ba78f0dce4bf8668562ec88" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "byteorder", "chrono", "diesel_derives", + "downcast-rs", "itoa", "pq-sys", "serde_json", @@ -878,37 +940,37 @@ dependencies = [ [[package]] name = "diesel-async" -version = "0.5.2" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51a307ac00f7c23f526a04a77761a0519b9f0eb2838ebf5b905a58580095bdcb" +checksum = "dd39af30158d444884f166fe4c58f35dc40ad71ad017bb59408a3448526ff4bd" dependencies = [ - "async-trait", "bb8", "diesel", + "futures-core", "futures-util", - "scoped-futures", + "pin-project-lite", "tokio", "tokio-postgres", ] [[package]] name = "diesel_derives" -version = "2.2.7" +version = "2.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b96984c469425cb577bf6f17121ecb3e4fe1e81de5d8f780dd372802858d756" +checksum = "d1817b7f4279b947fc4cafddec12b0e5f8727141706561ce3ac94a60bddd1cf5" dependencies = [ "diesel_table_macro_syntax", "dsl_auto_type", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "diesel_migrations" -version = "2.2.0" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a73ce704bad4231f001bff3314d91dce4aba0770cee8b233991859abc15c1f6" +checksum = "28d0f4a98124ba6d4ca75da535f65984badec16a003b6e2f94a01e31a79490b8" dependencies = [ "diesel", "migrations_internals", @@ -917,11 +979,11 @@ dependencies = [ [[package]] name = "diesel_table_macro_syntax" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "209c735641a413bc68c4923a9d6ad4bcb3ca306b794edaa7eb0b3228a99ffb25" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" dependencies = [ - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -941,46 +1003,52 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid 0.10.2", - "crypto-common 0.2.1", + "crypto-common 0.2.2", "ctutils", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "docker_credential" -version = "1.3.3" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4564c274ebf369f501de192b02a0b81a5c4bda375abfe526aa70fc702fa6fa0" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" dependencies = [ "base64 0.22.1", "serde", "serde_json", ] +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + [[package]] name = "dsl_auto_type" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ae9aca7527f85f26dd76483eb38533fd84bd571065da1739656ef71c5ff5b" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" dependencies = [ - "darling 0.20.11", + "darling 0.21.3", "either", "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1016,9 +1084,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "encoding_rs" @@ -1047,13 +1115,12 @@ dependencies = [ [[package]] name = "etcetera" -version = "0.8.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ "cfg-if", - "home", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -1080,6 +1147,17 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ferroid" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4" +dependencies = [ + "portable-atomic", + "rand 0.10.1", + "web-time", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1185,12 +1263,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -1296,7 +1368,7 @@ dependencies = [ "thiserror 2.0.18", "toml 0.8.23", "tracing", - "ureq", + "ureq 2.12.1", ] [[package]] @@ -1667,7 +1739,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1744,16 +1816,14 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", ] [[package]] @@ -1781,9 +1851,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -1813,15 +1883,6 @@ dependencies = [ "ahash", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - [[package]] name = "hashbrown" version = "0.16.1" @@ -1830,7 +1891,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -1904,9 +1965,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -1955,18 +2016,18 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -2013,7 +2074,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] @@ -2119,7 +2180,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.10.0", - "syn 2.0.117", + "syn 2.0.118", "unic-langid", ] @@ -2133,7 +2194,7 @@ dependencies = [ "i18n-config", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2242,12 +2303,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -2387,21 +2442,20 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] [[package]] name = "kqueue" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" dependencies = [ "kqueue-sys", "libc", @@ -2413,7 +2467,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "libc", ] @@ -2423,12 +2477,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -2437,18 +2485,18 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libmimalloc-sys" -version = "0.1.47" +version = "0.1.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d1eacfa31c33ec25e873c136ba5669f00f9866d0688bea7be4d3f7e43067df6" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" dependencies = [ "cc", ] [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "libc", ] @@ -2464,6 +2512,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2487,9 +2541,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru-slab" @@ -2534,15 +2588,15 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "migrations_internals" -version = "2.2.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bda1634d70d5bd53553cf15dca9842a396e8c799982a3ad22998dc44d961f24" +checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" dependencies = [ "serde", "toml 0.9.12+spec-1.1.0", @@ -2550,9 +2604,9 @@ dependencies = [ [[package]] name = "migrations_macros" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffb161cc72176cb37aa47f1fc520d3ef02263d67d661f44f05d05a079e1237fd" +checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" dependencies = [ "migrations_internals", "proc-macro2", @@ -2561,9 +2615,9 @@ dependencies = [ [[package]] name = "mimalloc" -version = "0.1.50" +version = "0.1.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3627c4272df786b9260cabaa46aec1d59c93ede723d4c3ef646c503816b0640" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" dependencies = [ "libmimalloc-sys", ] @@ -2604,9 +2658,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -2658,7 +2712,7 @@ version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "filetime", "inotify", "kqueue", @@ -2678,11 +2732,75 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] [[package]] name = "num-traits" @@ -2709,7 +2827,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -2801,7 +2919,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] @@ -2828,7 +2946,7 @@ dependencies = [ "regex", "regex-syntax", "structmeta", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2861,7 +2979,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2906,7 +3024,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2950,9 +3068,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "postgres-protocol" -version = "0.6.11" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" dependencies = [ "base64 0.22.1", "byteorder", @@ -2968,9 +3086,9 @@ dependencies = [ [[package]] name = "postgres-types" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dc729a129e682e8d24170cd30ae1aa01b336b096cbb56df6d534ffec133d186" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" dependencies = [ "bytes", "fallible-iterator 0.2.0", @@ -3012,16 +3130,6 @@ dependencies = [ "vcpkg", ] -[[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.117", -] - [[package]] name = "proc-macro-error" version = "1.0.4" @@ -3063,7 +3171,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "version_check", "yansi", ] @@ -3079,15 +3187,40 @@ dependencies = [ "lazy_static", "memchr", "parking_lot", - "protobuf", "thiserror 1.0.69", ] [[package]] -name = "protobuf" -version = "2.28.0" +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", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "prost-types" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] [[package]] name = "quanta" @@ -3116,9 +3249,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -3136,9 +3269,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "bytes", "getrandom 0.3.4", @@ -3171,9 +3304,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -3217,8 +3350,8 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ - "chacha20 0.10.0", - "getrandom 0.4.2", + "chacha20 0.10.1", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -3272,16 +3405,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.11.1", -] - -[[package]] -name = "redox_syscall" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" -dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.0", ] [[package]] @@ -3290,7 +3414,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -3310,14 +3434,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -3338,9 +3462,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -3382,7 +3506,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] @@ -3405,7 +3529,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "fallible-iterator 0.3.0", "fallible-streaming-iterator", "hashlink", @@ -3433,7 +3557,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.117", + "syn 2.0.118", "walkdir", ] @@ -3468,24 +3592,37 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "log", "once_cell", @@ -3498,9 +3635,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -3508,15 +3645,6 @@ dependencies = [ "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.14.1" @@ -3601,15 +3729,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "scoped-futures" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b24aae2d0636530f359e9d5ef0c04669d11c5e756699b27a6a6d845d8329091" -dependencies = [ - "pin-project-lite", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -3643,7 +3762,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation", "core-foundation-sys", "libc", @@ -3708,14 +3827,14 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -3743,7 +3862,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3778,9 +3897,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", "bs58", @@ -3798,14 +3917,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3841,9 +3960,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -3884,15 +4003,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3961,7 +4080,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta-derive", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3972,7 +4091,7 @@ checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3993,9 +4112,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -4019,14 +4138,14 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "tar" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", @@ -4040,26 +4159,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] [[package]] name = "testcontainers" -version = "0.23.3" +version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59a4f01f39bb10fc2a5ab23eb0d888b1e2bb168c157f61a1b98e6c501c639c74" +checksum = "bfd5785b5483672915ed5fe3cddf9f546802779fc1eceff0a6fb7321fac81c1e" dependencies = [ + "astral-tokio-tar", "async-trait", "bollard", - "bollard-stubs", "bytes", "docker_credential", "either", "etcetera", + "ferroid", "futures", + "http", + "itertools", "log", "memchr", "parse-display", @@ -4070,16 +4192,15 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", - "tokio-tar", "tokio-util", "url", ] [[package]] name = "testcontainers-modules" -version = "0.11.6" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d43ed4e8f58424c3a2c6c56dbea6643c3c23e8666a34df13c54f0a184e6c707" +checksum = "e5985fde5befe4ffa77a052e035e16c2da86e8bae301baa9f9904ad3c494d357" dependencies = [ "testcontainers", ] @@ -4110,7 +4231,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4121,7 +4242,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4135,12 +4256,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -4150,15 +4270,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" dependencies = [ "num-conv", "time-core", @@ -4198,7 +4318,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", - "mio 1.2.0", + "mio 1.2.1", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -4215,14 +4335,14 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "tokio-postgres" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dd8df5ef180f6364759a6f00f7aadda4fbbac86cdee37480826a6ff9f3574ce" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" dependencies = [ "async-trait", "byteorder", @@ -4265,21 +4385,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tar" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d5714c010ca3e5c27114c1cdeb9d14641ace49874aa5626d7149e47aedace75" -dependencies = [ - "filetime", - "futures-core", - "libc", - "redox_syscall 0.3.5", - "tokio", - "tokio-stream", - "xattr", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -4320,12 +4425,10 @@ version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap 2.14.0", "serde_core", "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", - "toml_writer", "winnow 0.7.15", ] @@ -4367,7 +4470,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -4376,12 +4479,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" -[[package]] -name = "toml_writer" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" - [[package]] name = "tonic" version = "0.14.6" @@ -4411,6 +4508,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + [[package]] name = "tower" version = "0.5.3" @@ -4432,12 +4540,12 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.10" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", "futures-core", "futures-util", @@ -4504,7 +4612,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4576,9 +4684,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uncased" @@ -4635,12 +4743,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" -[[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" @@ -4675,6 +4777,33 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4688,6 +4817,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4702,11 +4837,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -4766,20 +4901,11 @@ dependencies = [ [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -4793,9 +4919,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -4806,9 +4932,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -4816,9 +4942,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4826,48 +4952,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.4.2" @@ -4881,23 +4985,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -4919,14 +5011,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] @@ -4996,7 +5088,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5007,7 +5099,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5052,6 +5144,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -5267,9 +5368,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" [[package]] name = "wiremock" @@ -5294,100 +5395,12 @@ dependencies = [ "url", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" @@ -5413,7 +5426,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -5424,9 +5437,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -5441,28 +5454,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5482,28 +5495,28 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5537,7 +5550,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 944aa80..8fe8b2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,9 @@ members = [ [workspace.package] edition = "2021" -rust-version = "1.75" +# diesel 2.3 requires 1.86 and the diesel-async 0.9 transaction API uses async +# closures (stable since 1.85); 1.86 is the real floor after those bumps. +rust-version = "1.86" license = "Elastic-2.0" [workspace.dependencies] @@ -60,12 +62,12 @@ tower = "0.5" tower-http = { version = "0.6", features = ["trace", "compression-gzip", "request-id", "limit", "cors", "set-header"] } tokio = { version = "1", features = ["full"] } http-body-util = "0.1" -diesel = { version = "2.2", features = ["postgres", "chrono", "serde_json", "uuid"] } -diesel-async = { version = "0.5", features = ["postgres", "bb8"] } -diesel_migrations = { version = "2.2", features = ["postgres"] } -bb8 = "0.8" -testcontainers = "0.23" -testcontainers-modules = { version = "0.11", features = ["postgres"] } +diesel = { version = "2.3.8", features = ["postgres", "chrono", "serde_json", "uuid"] } +diesel-async = { version = "0.9", features = ["postgres", "bb8"] } +diesel_migrations = { version = "2.3", features = ["postgres"] } +bb8 = "0.9" +testcontainers = "0.27" +testcontainers-modules = { version = "0.15", features = ["postgres"] } figment = { version = "0.10", features = ["env", "toml"] } mimalloc = "0.1" clap = { version = "4", features = ["derive"] } diff --git a/crates/frameshift-catalog-postgres/src/backend.rs b/crates/frameshift-catalog-postgres/src/backend.rs index 946d859..c814dab 100644 --- a/crates/frameshift-catalog-postgres/src/backend.rs +++ b/crates/frameshift-catalog-postgres/src/backend.rs @@ -379,92 +379,93 @@ impl CatalogBackend for PostgresCatalog { use diesel_async::AsyncConnection as _; let tx_result = conn - .transaction::<(), TxError, _>(|conn| { - let new_pack = new_pack.clone(); - let new_version = new_version.clone(); - let pack_name = pack_name_clone.clone(); - let version = version_clone.clone(); - let incoming_author = incoming_author_bytes.clone(); - Box::pin(async move { - // D5: If the pack head already exists, verify the publishing - // author matches the stored current_author. First-publish - // (no existing row) is always allowed. - let existing_pack: Option = packs::table - .filter(packs::name.eq(&pack_name)) - .select(PackRow::as_select()) - .first(conn) - .await - .optional() - .map_err(|e| { - TxError::Catalog(map_diesel_error(e, "pack", pack_name.clone())) - })?; - - if let Some(ref existing) = existing_pack { - // Pack already exists -- check ownership. - if existing.current_author != incoming_author { - return Err(TxError::Catalog(CatalogError::Unauthorized { - kind: "pack", - key: pack_name.clone(), - })); - } + .transaction::<(), TxError, _>(async move |conn| { + // diesel-async 0.9 takes an `AsyncFnOnce`, so the old + // `|conn| Box::pin(async move { .. })` wrapper is gone -- the body + // is now the async closure directly. `new_pack` and `new_version` + // are captured by move under their own names; the comparison values + // are rebound (by move, no clone) to the short names used below. + let pack_name = pack_name_clone; + let version = version_clone; + let incoming_author = incoming_author_bytes; + // D5: If the pack head already exists, verify the publishing + // author matches the stored current_author. First-publish + // (no existing row) is always allowed. + let existing_pack: Option = packs::table + .filter(packs::name.eq(&pack_name)) + .select(PackRow::as_select()) + .first(conn) + .await + .optional() + .map_err(|e| { + TxError::Catalog(map_diesel_error(e, "pack", pack_name.clone())) + })?; + + if let Some(ref existing) = existing_pack { + // Pack already exists -- check ownership. + if existing.current_author != incoming_author { + return Err(TxError::Catalog(CatalogError::Unauthorized { + kind: "pack", + key: pack_name.clone(), + })); } + } - // Upsert the parent pack row; do nothing if it already exists. - diesel::insert_into(packs::table) - .values(&new_pack) - .on_conflict(packs::name) - .do_nothing() - .execute(conn) - .await - .map_err(|e| { - TxError::Catalog(map_diesel_error(e, "pack", pack_name.clone())) - })?; - - // Insert the version row. - diesel::insert_into(pack_versions::table) - .values(&new_version) + // Upsert the parent pack row; do nothing if it already exists. + diesel::insert_into(packs::table) + .values(&new_pack) + .on_conflict(packs::name) + .do_nothing() + .execute(conn) + .await + .map_err(|e| { + TxError::Catalog(map_diesel_error(e, "pack", pack_name.clone())) + })?; + + // Insert the version row. + diesel::insert_into(pack_versions::table) + .values(&new_version) + .execute(conn) + .await + .map_err(|e| { + TxError::Catalog(map_diesel_error( + e, + "pack_version", + format!("{pack_name}@{version}"), + )) + })?; + + // D8: Update latest_version using true semver precedence. + // Read the current stored value (may have changed from the + // row we fetched above if this is a first insert), then + // compare using semver_gt before issuing the UPDATE. + let current_latest: Option = packs::table + .filter(packs::name.eq(&pack_name)) + .select(packs::latest_version) + .first(conn) + .await + .map_err(|e| { + TxError::Catalog(map_diesel_error(e, "pack", pack_name.clone())) + })?; + + // Only update when the new version has strictly higher + // semver precedence than the stored latest. + let should_update = match ¤t_latest { + None => true, + Some(stored) => semver_gt(&version, stored), + }; + + if should_update { + diesel::update(packs::table.filter(packs::name.eq(&pack_name))) + .set(packs::latest_version.eq(Some(&version))) .execute(conn) .await - .map_err(|e| { - TxError::Catalog(map_diesel_error( - e, - "pack_version", - format!("{pack_name}@{version}"), - )) - })?; - - // D8: Update latest_version using true semver precedence. - // Read the current stored value (may have changed from the - // row we fetched above if this is a first insert), then - // compare using semver_gt before issuing the UPDATE. - let current_latest: Option = packs::table - .filter(packs::name.eq(&pack_name)) - .select(packs::latest_version) - .first(conn) - .await .map_err(|e| { TxError::Catalog(map_diesel_error(e, "pack", pack_name.clone())) })?; + } - // Only update when the new version has strictly higher - // semver precedence than the stored latest. - let should_update = match ¤t_latest { - None => true, - Some(stored) => semver_gt(&version, stored), - }; - - if should_update { - diesel::update(packs::table.filter(packs::name.eq(&pack_name))) - .set(packs::latest_version.eq(Some(&version))) - .execute(conn) - .await - .map_err(|e| { - TxError::Catalog(map_diesel_error(e, "pack", pack_name.clone())) - })?; - } - - Ok(()) - }) + Ok(()) }) .await; diff --git a/crates/frameshift-catalog-postgres/src/pool.rs b/crates/frameshift-catalog-postgres/src/pool.rs index 0606ca9..3de88fe 100644 --- a/crates/frameshift-catalog-postgres/src/pool.rs +++ b/crates/frameshift-catalog-postgres/src/pool.rs @@ -46,7 +46,11 @@ pub async fn build_pool( // Build the manager with a custom connection setup callback. // The callback runs for every new physical connection, before the // connection enters the pool's idle queue. - let mut manager_config = ManagerConfig::default(); + // + // diesel-async 0.6+ made `ManagerConfig` generic over the connection type, + // so the connection type is named explicitly here rather than left to + // inference. + let mut manager_config = ManagerConfig::::default(); manager_config.custom_setup = Box::new(move |url: &str| { let url = url.to_string(); let timeout_ms = statement_timeout_ms; diff --git a/crates/frameshift-cli/src/cmd/use_persona.rs b/crates/frameshift-cli/src/cmd/use_persona.rs index e7ab889..3e28711 100644 --- a/crates/frameshift-cli/src/cmd/use_persona.rs +++ b/crates/frameshift-cli/src/cmd/use_persona.rs @@ -7,6 +7,7 @@ use std::path::{Path, PathBuf}; use clap::Args; use frameshift_client::{Client, ClientError, InstallRequest, InstallSource, PersonaSpec}; +use frameshift_orchestrator::Preferences; use crate::util::CliError; @@ -76,6 +77,18 @@ pub fn run_use(client: &Client, args: UseArgs) -> Result<(), CliError> { other => CliError::Orchestrator(other.to_string()), })?; + // Learn from the explicit choice: nudge future automatic selection toward + // the persona the user activated. This writes the same `automate-prefs.json` + // that `select` and the daemon read, so the bias actually closes the loop. + // Best-effort -- activation has already succeeded, so a preferences failure + // must not fail the command. + if let Ok(state_dir) = client.orchestrator_state_dir(&project_root) { + let prefs_path = state_dir.join("automate-prefs.json"); + if let Err(e) = record_persona_use(&prefs_path, &args.name) { + eprintln!("warning: could not record persona preference: {e}"); + } + } + // Read and print the rendered persona for the claude target. let rendered = client.rendered_persona(&project_root, &args.name, "claude")?; println!("{}", rendered); @@ -103,3 +116,53 @@ fn read_pack_version(persona_dir: &Path) -> Option { } None } + +/// Record that the user explicitly activated `persona`, nudging future +/// automatic selection toward it. +/// +/// Loads the shared `automate-prefs.json` (the same store `select` and the +/// daemon read), bumps the persona's bias via [`Preferences::record_override`] +/// (with no auto-pick to penalize -- an explicit `use` is a positive signal, +/// not a correction of a specific automatic pick), and persists it atomically. +fn record_persona_use(prefs_path: &Path, persona: &str) -> Result<(), String> { + let mut prefs = Preferences::load(prefs_path).map_err(|e| e.to_string())?; + prefs.record_override(None, persona); + prefs.save(prefs_path).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + /// Recording a use bumps the persona's bias and persists it to the shared + /// preferences file so later selection can read it back. + #[test] + fn record_persona_use_biases_persona() { + let tmp = TempDir::new().unwrap(); + let prefs_path = tmp.path().join("automate-prefs.json"); + + record_persona_use(&prefs_path, "rust").unwrap(); + + let prefs = Preferences::load(&prefs_path).unwrap(); + assert!( + prefs.bias_for("rust") > 0.0, + "an explicit `use` should bias the persona upward" + ); + } + + /// Repeated uses accumulate bias (capped by the feedback layer) and never + /// error on an existing preferences file. + #[test] + fn repeated_use_accumulates_and_persists() { + let tmp = TempDir::new().unwrap(); + let prefs_path = tmp.path().join("automate-prefs.json"); + + record_persona_use(&prefs_path, "rust").unwrap(); + let first = Preferences::load(&prefs_path).unwrap().bias_for("rust"); + record_persona_use(&prefs_path, "rust").unwrap(); + let second = Preferences::load(&prefs_path).unwrap().bias_for("rust"); + + assert!(second >= first, "bias should not decrease on repeated use"); + } +} diff --git a/crates/frameshift-daemon/src/orchestrator.rs b/crates/frameshift-daemon/src/orchestrator.rs index 0a43985..faef248 100644 --- a/crates/frameshift-daemon/src/orchestrator.rs +++ b/crates/frameshift-daemon/src/orchestrator.rs @@ -17,6 +17,20 @@ use frameshift_orchestrator::{ run::{select, SelectionInputs}, }; +/// Read the persona name recorded in the project's active marker, if any. +/// +/// Returns `None` when the marker is absent, unreadable, or empty after +/// trimming. Shared by the manual-override check and the audit `from` capture. +fn read_active_persona(client: &Client, project_root: &Path) -> Option { + match client.project_paths(project_root) { + Ok(paths) if paths.active_path.exists() => std::fs::read_to_string(&paths.active_path) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + _ => None, + } +} + /// Evaluate the current project context and apply a persona switch if warranted. /// /// Steps: @@ -66,6 +80,31 @@ pub fn evaluate_and_apply(client: &Client, controller: &mut SwitchController, pr return; } + // Load learned preferences (shared with the CLI and the selection pass). + let mut prefs = Preferences::load(&prefs_path).unwrap_or_default(); + + // Manual-override learning (A1): if the persona on disk differs from the + // auto-pick the controller last applied, the user switched by hand. Reward + // their choice, decay the rejected auto-pick, persist the preference, and + // re-baseline the controller so the same override is not re-learned on every + // subsequent tick. The updated bias also feeds this tick's selection. + let auto_pick = controller.active_persona().map(str::to_string); + let active_now = read_active_persona(client, project_root); + if let (Some(auto), Some(chosen)) = (auto_pick.as_deref(), active_now.as_deref()) { + if auto != chosen { + prefs.record_override(Some(auto), chosen); + if let Err(e) = prefs.save(&prefs_path) { + tracing::warn!(error = %e, "orchestrator: failed to persist override preference"); + } + controller.adopt_active(chosen); + tracing::info!( + from = %auto, + to = %chosen, + "orchestrator: learned manual persona override" + ); + } + } + // Step 3: collect persona source dirs and run selection. let source_dirs = match client.installed_persona_source_dirs(project_root) { Ok(dirs) => dirs, @@ -75,8 +114,6 @@ pub fn evaluate_and_apply(client: &Client, controller: &mut SwitchController, pr } }; - let prefs = Preferences::load(&prefs_path).unwrap_or_default(); - let inputs = SelectionInputs { project_root, task_hint: None, @@ -105,13 +142,7 @@ pub fn evaluate_and_apply(client: &Client, controller: &mut SwitchController, pr } = decision { // Read the currently active persona before overwriting the marker. - let from = match client.project_paths(project_root) { - Ok(paths) if paths.active_path.exists() => std::fs::read_to_string(&paths.active_path) - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()), - _ => None, - }; + let from = read_active_persona(client, project_root); tracing::info!( persona = %to, @@ -382,4 +413,59 @@ mod tests { } } } + + /// A manual switch away from the daemon's auto-pick is learned: the chosen + /// persona is rewarded and the rejected auto-pick is decayed in the shared + /// preferences file, and the controller re-baselines to the manual choice. + /// Prior to A1 the daemon applied its own switches but never learned when + /// the user overrode them by hand. + #[test] + fn evaluate_learns_from_manual_override() { + let tmp = tempfile::tempdir().unwrap(); + let project_root = tmp.path().join("project"); + fs::create_dir_all(&project_root).unwrap(); + + let data_root = tmp.path().join("data"); + let client = test_client(&data_root); + + // Enable automate mode (no lock) so evaluation proceeds. + let state_dir = client.orchestrator_state_dir(&project_root).unwrap(); + fs::create_dir_all(&state_dir).unwrap(); + let mode = ModeState { + mode: Mode::On, + sensitivity: 0.5, + }; + mode.save(&state_dir.join("automate.json")).unwrap(); + + // Seed the controller's auto-pick deterministically, as if the daemon + // had activated "auto-pick" on a prior tick. + let mut controller = SwitchController::new(SwitchPolicy::default()); + controller.adopt_active("auto-pick"); + + // Simulate the user manually switching to a different persona by writing + // the on-disk active marker directly. + let paths = client.project_paths(&project_root).unwrap(); + if let Some(parent) = paths.active_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&paths.active_path, "manual-choice\n").unwrap(); + + // Tick: the daemon must detect the divergence and learn from it. + evaluate_and_apply(&client, &mut controller, &project_root); + + let prefs_path = state_dir.join("automate-prefs.json"); + let prefs = Preferences::load(&prefs_path).expect("preferences should load"); + assert!( + prefs.bias_for("manual-choice") > 0.0, + "the user's manual choice must be rewarded" + ); + assert!( + prefs.bias_for("auto-pick") < 0.0, + "the rejected auto-pick must be decayed" + ); + + // The controller re-baselines to the manual choice so the same override + // is not re-learned on the next tick. + assert_eq!(controller.active_persona(), Some("manual-choice")); + } } diff --git a/crates/frameshift-orchestrator/src/audit.rs b/crates/frameshift-orchestrator/src/audit.rs index fc1cb30..e903589 100644 --- a/crates/frameshift-orchestrator/src/audit.rs +++ b/crates/frameshift-orchestrator/src/audit.rs @@ -1,5 +1,7 @@ //! Explainable audit log of persona transitions. +use std::collections::VecDeque; +use std::io::{BufRead, BufReader}; use std::path::Path; use chrono::Utc; @@ -34,25 +36,44 @@ pub struct AuditLog { } impl AuditLog { + /// Maximum number of audit entries retained in memory when loading. Entries + /// older than the most recent `MAX_AUDIT_ENTRIES` are dropped so a log grown + /// over a long-lived deployment cannot exhaust memory on load. + const MAX_AUDIT_ENTRIES: usize = 50_000; + /// Load an audit log from a JSON-lines file. /// - /// Returns an empty `AuditLog` if the file does not exist. Each line must - /// be a valid JSON object matching `Transition`. + /// Returns an empty `AuditLog` if the file does not exist. Each non-empty + /// line must be a valid JSON object matching `Transition`. Only the most + /// recent `MAX_AUDIT_ENTRIES` are retained. pub fn load(path: &Path) -> Result { + Self::load_capped(path, Self::MAX_AUDIT_ENTRIES) + } + + /// Load at most `max` most-recent entries, streaming the file line by line + /// so the full file contents are never held in memory at once. + fn load_capped(path: &Path, max: usize) -> Result { if !path.exists() { return Ok(AuditLog::default()); } - let data = std::fs::read_to_string(path)?; - let mut entries = Vec::new(); - for line in data.lines() { + let file = std::fs::File::open(path)?; + let reader = BufReader::new(file); + let mut entries: VecDeque = VecDeque::new(); + for line in reader.lines() { + let line = line?; let trimmed = line.trim(); if trimmed.is_empty() { continue; } let t: Transition = serde_json::from_str(trimmed)?; - entries.push(t); + entries.push_back(t); + if max > 0 && entries.len() > max { + entries.pop_front(); + } } - Ok(AuditLog { entries }) + Ok(AuditLog { + entries: entries.into(), + }) } /// Append a transition to both the in-memory log and the backing file. @@ -181,4 +202,22 @@ mod tests { assert_eq!(log.recent(100).len(), 1); } + + /// load_capped retains only the most recent `max` entries from the file. + #[test] + fn load_capped_keeps_most_recent() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("audit.jsonl"); + + let mut log = AuditLog::default(); + for i in 0..5 { + log.append(&path, make_transition(None, &format!("p{i}"))) + .unwrap(); + } + + let loaded = AuditLog::load_capped(&path, 3).unwrap(); + assert_eq!(loaded.entries.len(), 3, "only the 3 most recent retained"); + assert_eq!(loaded.entries.first().unwrap().to, "p2"); + assert_eq!(loaded.entries.last().unwrap().to, "p4"); + } } diff --git a/crates/frameshift-orchestrator/src/context.rs b/crates/frameshift-orchestrator/src/context.rs index ce85a1a..ebca587 100644 --- a/crates/frameshift-orchestrator/src/context.rs +++ b/crates/frameshift-orchestrator/src/context.rs @@ -20,6 +20,33 @@ const SKIP_DIRS: &[&str] = &[ ".svn", ]; +/// Weight assigned to languages present in the active git working set. +/// +/// Sits above the file-count census ceiling (1.0) but below the prose sentinel +/// (2.0, see [`augment_languages_from_task`]) so that what the user is actively +/// editing outranks the static census without overriding an explicit +/// prose-writing signal. +const ACTIVE_LANGUAGE_WEIGHT: f32 = 1.5; + +/// Upper bound on changed-file paths processed from `git status` output. +/// +/// Bounds work on pathological repos (e.g. a freshly checked-out tree reported +/// as fully untracked); far more than enough to characterize the active set. +const MAX_CHANGED_FILES: usize = 5000; + +/// Upper bound on dependency-name tokens harvested from project manifests. +/// +/// Keeps the additive context-token signal bounded on dependency-heavy projects. +const MAX_DEP_TOKENS: usize = 100; + +/// Manifest section headers under which a TOML key names a dependency. +const CARGO_DEP_SECTIONS: &[&str] = &[ + "[dependencies]", + "[dev-dependencies]", + "[build-dependencies]", + "[workspace.dependencies]", +]; + /// A snapshot of the inferred work context for a project directory. #[derive(Debug, Clone, PartialEq)] pub struct ContextSignal { @@ -39,6 +66,12 @@ pub struct ContextSignal { /// Used by the policy scorer for lexical matching against persona keywords. pub task_tokens: Vec, + /// Lowercase dependency names parsed from project manifests (Cargo.toml, + /// package.json). Scored by the policy as a small additive bonus when they + /// match persona keywords -- they sharpen framework-level matching (e.g. + /// `axum` vs `actix`) without diluting the task-token lexical score. + pub context_tokens: Vec, + /// The inferred task intent from task token analysis, if any. pub inferred_intent: Option, } @@ -91,6 +124,16 @@ pub fn sense(project_root: &Path, task_hint: Option<&str>) -> ContextSignal { // personas can compete with code-language personas on equal footing. let languages = augment_languages_from_task(languages, &task_tokens); + // Emphasize the languages of the active git working set (best-effort): the + // files the user is editing right now are a stronger signal than the static + // whole-repo census. Non-git directories degrade to the census unchanged. + let changed_languages = changed_file_languages(&git_changed_paths(project_root)); + let languages = augment_languages_from_git(languages, &changed_languages); + + // Harvest dependency names from project manifests (best-effort). These feed + // the policy as a small additive bonus, sharpening framework-level matching. + let context_tokens = manifest_dependency_tokens(project_root); + // Classify the inferred task intent from task token analysis. let inferred_intent = crate::intent::classify(&task_tokens); @@ -99,6 +142,7 @@ pub fn sense(project_root: &Path, task_hint: Option<&str>) -> ContextSignal { languages, frameworks, task_tokens, + context_tokens, inferred_intent, } } @@ -129,16 +173,26 @@ fn walk( break; } + // Use the entry's own file type (this does not follow symlinks) so a + // planted symlink cannot redirect the walk outside the project tree. + let file_type = match entry.file_type() { + Ok(ft) => ft, + Err(_) => continue, + }; + if file_type.is_symlink() { + continue; + } + let path = entry.path(); let name = entry.file_name(); let name_str = name.to_string_lossy(); - if path.is_dir() { + if file_type.is_dir() { if SKIP_DIRS.contains(&name_str.as_ref()) { continue; } walk(&path, depth + 1, raw_counts, frameworks, file_count); - } else if path.is_file() { + } else if file_type.is_file() { *file_count += 1; // Check for framework marker files. @@ -361,6 +415,245 @@ fn augment_languages_from_task( languages } +/// Best-effort list of files in the active git working set for `project_root`. +/// +/// Runs `git status --porcelain` (which reports staged, unstaged, and untracked +/// changes) with the project as the working directory. Returns an empty vector +/// on any failure -- not a git repository, git not installed, or a non-zero +/// exit -- so callers degrade gracefully to the static project census. +fn git_changed_paths(project_root: &Path) -> Vec { + let output = std::process::Command::new("git") + .arg("-C") + .arg(project_root) + .args(["status", "--porcelain", "--untracked-files=all"]) + .output(); + match output { + Ok(o) if o.status.success() => { + let stdout = String::from_utf8_lossy(&o.stdout); + parse_porcelain_paths(&stdout) + } + _ => Vec::new(), + } +} + +/// Extract file paths from `git status --porcelain` (v1) output. +/// +/// Each line is `XY `: a two-character status code, a space, then the +/// path. Rename and copy entries use `XY -> `; the destination path +/// is taken. Surrounding quotes (added by git for paths with special +/// characters) are stripped -- extension mapping still works on the suffix. +/// Capped at [`MAX_CHANGED_FILES`] entries. +fn parse_porcelain_paths(porcelain: &str) -> Vec { + porcelain + .lines() + .filter_map(|line| { + // Need at least the 2 status chars, a space, and one path char. + if line.len() < 4 { + return None; + } + let rest = &line[3..]; + // Rename/copy: "old -> new" keeps the destination path. + let path = match rest.rsplit_once(" -> ") { + Some((_, new)) => new, + None => rest, + }; + let path = path.trim().trim_matches('"'); + if path.is_empty() { + None + } else { + Some(path.to_string()) + } + }) + .take(MAX_CHANGED_FILES) + .collect() +} + +/// Map a set of changed file paths to per-language hit counts using the same +/// extension table as the project census ([`ext_to_language`]). Paths whose +/// extension does not map to a tracked language are ignored. +pub(crate) fn changed_file_languages(paths: &[String]) -> BTreeMap { + let mut counts: BTreeMap = BTreeMap::new(); + for path in paths { + if let Some(ext) = Path::new(path).extension().and_then(|e| e.to_str()) { + if let Some(lang) = ext_to_language(ext) { + *counts.entry(lang.to_string()).or_insert(0) += 1; + } + } + } + counts +} + +/// Raise the weight of every language in the active git working set to +/// [`ACTIVE_LANGUAGE_WEIGHT`], so files the user is currently editing outrank +/// the static file-count census. A language already weighted higher (e.g. the +/// prose sentinel) is left untouched. +fn augment_languages_from_git( + mut languages: BTreeMap, + changed: &BTreeMap, +) -> BTreeMap { + for lang in changed.keys() { + let entry = languages.entry(lang.clone()).or_insert(0.0); + if *entry < ACTIVE_LANGUAGE_WEIGHT { + *entry = ACTIVE_LANGUAGE_WEIGHT; + } + } + languages +} + +/// Best-effort dependency-name tokens harvested from the project's root +/// manifests (`Cargo.toml` and `package.json`). +/// +/// Returns lowercase, deduplicated names (length >= 2) capped at +/// [`MAX_DEP_TOKENS`], in first-seen order. Missing or unreadable manifests +/// contribute nothing, so non-manifest projects yield an empty list. +fn manifest_dependency_tokens(project_root: &Path) -> Vec { + let mut names: Vec = Vec::new(); + if let Some(content) = read_capped(&project_root.join("Cargo.toml")) { + names.extend(parse_cargo_dependencies(&content)); + } + if let Some(content) = read_capped(&project_root.join("package.json")) { + names.extend(parse_package_json_dependencies(&content)); + } + + let mut seen = std::collections::HashSet::new(); + names + .into_iter() + .map(|n| n.to_lowercase()) + .filter(|n| n.len() >= 2 && seen.insert(n.clone())) + .take(MAX_DEP_TOKENS) + .collect() +} + +/// Read a file to a string, returning `None` on any error and truncating (at a +/// char boundary) to a bounded size so a pathological manifest cannot blow up +/// the scan. Manifests are normally tiny; the cap is purely defensive. +fn read_capped(path: &Path) -> Option { + const MAX_MANIFEST_BYTES: usize = 256 * 1024; + let mut content = std::fs::read_to_string(path).ok()?; + if content.len() > MAX_MANIFEST_BYTES { + let mut end = MAX_MANIFEST_BYTES; + while end > 0 && !content.is_char_boundary(end) { + end -= 1; + } + content.truncate(end); + } + Some(content) +} + +/// Extract dependency names from `Cargo.toml` content via a line scan. +/// +/// Tracks the current `[section]` header and, while inside a dependency section +/// ([`CARGO_DEP_SECTIONS`]), takes the key to the left of `=` on each entry. The +/// `name.workspace = true` / `name.version = ".."` forms yield `name` (the part +/// before the first dot). The `[dependencies.NAME]` sub-table header yields +/// `NAME`. This is a best-effort scan, not a full TOML parse -- unusual +/// formatting simply yields fewer names. +fn parse_cargo_dependencies(content: &str) -> Vec { + let mut names = Vec::new(); + let mut in_dep_section = false; + for raw in content.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if line.starts_with('[') { + in_dep_section = CARGO_DEP_SECTIONS.contains(&line); + if let Some(name) = cargo_subtable_dep(line) { + if is_valid_dep_name(&name) { + names.push(name); + } + } + continue; + } + if !in_dep_section { + continue; + } + if let Some((key, _)) = line.split_once('=') { + // `name.workspace`/`name.version` -> take the leading segment. + let name = key + .trim() + .split('.') + .next() + .unwrap_or("") + .trim() + .trim_matches('"'); + if is_valid_dep_name(name) { + names.push(name.to_string()); + } + } + } + names +} + +/// Extract the dependency name from a `[dependencies.NAME]` style sub-table +/// header, or `None` if the header is not a dependency sub-table. +fn cargo_subtable_dep(header: &str) -> Option { + let inner = header.strip_prefix('[')?.strip_suffix(']')?; + for prefix in [ + "dependencies.", + "dev-dependencies.", + "build-dependencies.", + "workspace.dependencies.", + ] { + if let Some(rest) = inner.strip_prefix(prefix) { + let name = rest.split('.').next().unwrap_or("").trim(); + return Some(name.to_string()); + } + } + None +} + +/// Extract dependency names from `package.json` content via a line scan. +/// +/// Tracks whether the scan is inside a dependency object (`dependencies`, +/// `devDependencies`, `peerDependencies`, `optionalDependencies`) and takes the +/// quoted key from each `"name": "range"` entry, stopping at the closing brace. +/// Best-effort for the conventional pretty-printed layout (one dependency per +/// line); a minified single-line file yields little or nothing. +fn parse_package_json_dependencies(content: &str) -> Vec { + let mut names = Vec::new(); + let mut in_deps = false; + for raw in content.lines() { + let line = raw.trim(); + if line.contains("\"dependencies\"") + || line.contains("\"devDependencies\"") + || line.contains("\"peerDependencies\"") + || line.contains("\"optionalDependencies\"") + { + in_deps = true; + continue; + } + if !in_deps { + continue; + } + if line.starts_with('}') { + in_deps = false; + continue; + } + // Entry form: `"name": "range",`. + if let Some(rest) = line.strip_prefix('"') { + if let Some((key, _)) = rest.split_once('"') { + if is_valid_dep_name(key) { + names.push(key.to_string()); + } + } + } + } + names +} + +/// Whether `name` looks like a real dependency identifier rather than a parsing +/// artifact: non-empty, bounded length, made of name characters, and containing +/// at least one alphanumeric. Allows the npm scope/path characters `@` and `/`. +fn is_valid_dep_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '/' | '@' | '.')) + && name.chars().any(|c| c.is_ascii_alphanumeric()) +} + /// Push `value` into `vec` only if not already present (cheap dedup during walk). fn push_unique(vec: &mut Vec, value: &str) { if !vec.iter().any(|v| v == value) { @@ -491,4 +784,225 @@ mod tests { assert!(tokens.contains(&"security".to_string())); assert!(tokens.contains(&"cve".to_string())); } + + /// Symlinked directories are not followed during the project walk. + #[cfg(unix)] + #[test] + fn walk_does_not_follow_symlinks() { + use std::os::unix::fs::symlink; + let tmp = tempfile::tempdir().unwrap(); + + // A directory outside the project, full of rust files. + let outside = tmp.path().join("outside"); + fs::create_dir_all(&outside).unwrap(); + for i in 0..3 { + fs::write(outside.join(format!("lib{i}.rs")), "fn x() {}").unwrap(); + } + + // The project contains one python file and a symlink to `outside`. + let project = tmp.path().join("project"); + fs::create_dir_all(&project).unwrap(); + fs::write(project.join("app.py"), "x = 1").unwrap(); + symlink(&outside, project.join("linked")).unwrap(); + + let sig = sense(&project, None); + assert!( + sig.languages.contains_key("python"), + "the real project file should still be detected" + ); + assert!( + !sig.languages.contains_key("rust"), + "a symlinked directory must not be followed" + ); + } + + /// changed_file_languages maps recognized extensions and ignores the rest. + #[test] + fn changed_file_languages_maps_extensions() { + let paths = vec![ + "src/main.rs".to_string(), + "web/app.py".to_string(), + "README".to_string(), + "notes.rs".to_string(), + ]; + let langs = changed_file_languages(&paths); + assert_eq!(langs.get("rust").copied(), Some(2)); + assert_eq!(langs.get("python").copied(), Some(1)); + // README has no extension and contributes nothing. + assert_eq!(langs.len(), 2); + } + + /// parse_porcelain_paths handles status codes, untracked, and renames. + #[test] + fn parse_porcelain_extracts_paths() { + let out = " M src/main.rs\n?? new.py\nA staged.go\nR old.rs -> renamed.rs\n"; + let paths = parse_porcelain_paths(out); + assert!(paths.contains(&"src/main.rs".to_string())); + assert!(paths.contains(&"new.py".to_string())); + assert!(paths.contains(&"staged.go".to_string())); + // Rename keeps the destination, not the source. + assert!(paths.contains(&"renamed.rs".to_string())); + assert!(!paths.iter().any(|p| p.contains("old.rs"))); + } + + /// The git boost lifts an actively-edited minority language above the + /// census-dominant one. + #[test] + fn git_boost_lifts_minority_language() { + use std::collections::BTreeMap; + let mut census = BTreeMap::new(); + census.insert("javascript".to_string(), 1.0_f32); + census.insert("rust".to_string(), 0.3_f32); + let mut changed = BTreeMap::new(); + changed.insert("rust".to_string(), 2_usize); + + let boosted = augment_languages_from_git(census, &changed); + assert_eq!(boosted.get("rust").copied(), Some(ACTIVE_LANGUAGE_WEIGHT)); + assert!(boosted["rust"] > boosted["javascript"]); + } + + /// The git boost never lowers a language already weighted above the active + /// weight (e.g. the prose sentinel). + #[test] + fn git_boost_preserves_prose_sentinel() { + use std::collections::BTreeMap; + let mut census = BTreeMap::new(); + census.insert("prose".to_string(), 2.0_f32); + let mut changed = BTreeMap::new(); + changed.insert("rust".to_string(), 1_usize); + + let boosted = augment_languages_from_git(census, &changed); + assert_eq!(boosted.get("prose").copied(), Some(2.0)); + assert_eq!(boosted.get("rust").copied(), Some(ACTIVE_LANGUAGE_WEIGHT)); + } + + /// A non-git directory yields no boost: the census weight (<= 1.0) stands + /// and sense() does not panic. + #[test] + fn sense_on_non_git_dir_does_not_boost() { + let tmp = make_rust_project(); + let sig = sense(tmp.path(), None); + let rust_w = sig.languages.get("rust").copied().unwrap_or(0.0); + assert!( + rust_w > 0.0 && rust_w <= 1.0, + "expected census weight, not the active boost: {rust_w}" + ); + } + + /// In a real repo, an actively-edited Rust file outranks a census-dominant + /// JavaScript majority. Skipped if git is unavailable in the environment. + #[test] + fn sense_boosts_git_changed_languages() { + use std::process::Command; + if Command::new("git").arg("--version").output().is_err() { + return; + } + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let git = |args: &[&str]| { + Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .output() + .expect("git invocation"); + }; + git(&["init", "-q"]); + git(&["config", "user.email", "t@example.com"]); + git(&["config", "user.name", "tester"]); + + // Census-dominant language: three committed JavaScript files. + for i in 0..3 { + fs::write(root.join(format!("a{i}.js")), "var x = 1;").unwrap(); + } + git(&["add", "-A"]); + git(&["commit", "-q", "-m", "init"]); + + // Active edit: a single untracked Rust file. + fs::write(root.join("lib.rs"), "fn x() {}").unwrap(); + + let sig = sense(root, None); + let rust_w = sig.languages.get("rust").copied().unwrap_or(0.0); + let js_w = sig.languages.get("javascript").copied().unwrap_or(0.0); + assert!( + rust_w >= ACTIVE_LANGUAGE_WEIGHT, + "the actively-edited rust file should boost rust, got {rust_w}" + ); + assert!( + rust_w > js_w, + "actively-edited rust ({rust_w}) should outrank census-dominant js ({js_w})" + ); + } + + /// parse_cargo_dependencies pulls names from dependency sections only. + #[test] + fn parse_cargo_deps_extracts_names() { + let toml = "[package]\nname = \"x\"\n\n[dependencies]\nserde = \"1\"\n\ + axum = { version = \"0.7\" }\ntokio.workspace = true\n\n\ + [dev-dependencies]\ntempfile = \"3\"\n"; + let deps = parse_cargo_dependencies(toml); + assert!(deps.contains(&"serde".to_string())); + assert!(deps.contains(&"axum".to_string())); + assert!(deps.contains(&"tokio".to_string())); + assert!(deps.contains(&"tempfile".to_string())); + // The [package] name field is not a dependency. + assert!(!deps.contains(&"name".to_string())); + } + + /// parse_cargo_dependencies handles `[dependencies.NAME]` sub-tables and does + /// not treat their fields as dependencies. + #[test] + fn parse_cargo_deps_handles_subtables() { + let toml = "[dependencies.bb8]\nversion = \"0.9\"\n[dependencies]\nserde = \"1\"\n"; + let deps = parse_cargo_dependencies(toml); + assert!(deps.contains(&"bb8".to_string())); + assert!(deps.contains(&"serde".to_string())); + assert!(!deps.contains(&"version".to_string())); + } + + /// parse_package_json_dependencies pulls names from dependency objects only. + #[test] + fn parse_package_json_deps_extracts_names() { + let json = "{\n \"name\": \"app\",\n \"dependencies\": {\n \ + \"react\": \"^18\",\n \"next\": \"14\"\n },\n \ + \"devDependencies\": {\n \"vitest\": \"^1\"\n }\n}\n"; + let deps = parse_package_json_dependencies(json); + assert!(deps.contains(&"react".to_string())); + assert!(deps.contains(&"next".to_string())); + assert!(deps.contains(&"vitest".to_string())); + // The top-level package name is not a dependency. + assert!(!deps.contains(&"name".to_string())); + } + + /// manifest_dependency_tokens lowercases and deduplicates harvested names. + #[test] + fn manifest_tokens_lowercases_and_dedups() { + let tmp = tempfile::tempdir().unwrap(); + fs::write( + tmp.path().join("Cargo.toml"), + "[dependencies]\naxum = \"0.7\"\nSERDE = \"1\"\naxum = \"0.7\"\n", + ) + .unwrap(); + let tokens = manifest_dependency_tokens(tmp.path()); + assert!(tokens.contains(&"axum".to_string())); + assert!(tokens.contains(&"serde".to_string())); + assert_eq!( + tokens.iter().filter(|t| *t == "axum").count(), + 1, + "duplicate dependency names are collapsed" + ); + } + + /// sense() exposes manifest dependency names via context_tokens. + #[test] + fn sense_populates_context_tokens_from_manifest() { + let tmp = tempfile::tempdir().unwrap(); + fs::write( + tmp.path().join("Cargo.toml"), + "[package]\nname = \"x\"\n[dependencies]\naxum = \"0.7\"\n", + ) + .unwrap(); + let sig = sense(tmp.path(), None); + assert!(sig.context_tokens.contains(&"axum".to_string())); + } } diff --git a/crates/frameshift-orchestrator/src/controller.rs b/crates/frameshift-orchestrator/src/controller.rs index 95a108c..f04fc99 100644 --- a/crates/frameshift-orchestrator/src/controller.rs +++ b/crates/frameshift-orchestrator/src/controller.rs @@ -54,11 +54,16 @@ impl SwitchPolicy { /// - z_threshold: 1.0 - sensitivity * 0.7 (range 1.0 to 0.3) /// - min_gap_fraction: 0.15 - sensitivity * 0.12 (range 0.15 to 0.03) /// - debounce_ticks: max(0, 3 - floor(sensitivity * 3)) (range 3 to 0) + /// - min_confidence: max(0, 0.5 - sensitivity) * 0.6 (range 0.3 to 0.0) + /// - switch_margin: max(0, 0.5 - sensitivity) * 0.3 (range 0.15 to 0.0) pub fn from_sensitivity(sensitivity: f32) -> Self { let s = sensitivity.clamp(0.0, 1.0); SwitchPolicy { - min_confidence: 0.0, - switch_margin: 0.0, + // Below the midpoint (the conservative end) impose a confidence floor + // and a score margin the challenger must clear; at and above 0.5 both + // are 0.0, leaving switching to the distribution/debounce heuristics. + min_confidence: (0.5 - s).max(0.0) * 0.6, + switch_margin: (0.5 - s).max(0.0) * 0.3, debounce_ticks: (3.0 - (s * 3.0).floor()).max(0.0) as u32, z_threshold: 1.0 - s * 0.7, min_gap_fraction: 0.15 - s * 0.12, @@ -178,6 +183,47 @@ impl SwitchController { &self.state } + /// Return the name of the persona the controller currently tracks as active. + /// + /// Returns `Some` when the controller is `Active` or `Locked` on a named + /// persona, and `None` when `Off`, `Armed`, or `Locked` with no name. The + /// daemon uses this as its "last auto-pick" to detect when the on-disk + /// active marker has been changed out from under it (a manual override). + pub fn active_persona(&self) -> Option<&str> { + match &self.state { + AutomateState::Active { persona, .. } | AutomateState::Locked { persona } => { + if persona.is_empty() { + None + } else { + Some(persona.as_str()) + } + } + AutomateState::Off | AutomateState::Armed => None, + } + } + + /// Adopt an externally-applied persona as the new active baseline. + /// + /// Used when something outside the controller (a user manual switch) has + /// already changed the active persona. The controller records `persona` as + /// `Active` so subsequent `decide` calls apply hysteresis from the user's + /// choice rather than the controller's stale auto-pick, and so the same + /// override is not detected and re-learned on every tick. Debounce and + /// challenger tracking are reset. A user-asserted choice is treated as + /// maximally confident; the stored confidence is informational only and is + /// not read by `decide`. Locked state is left untouched. + pub fn adopt_active(&mut self, persona: &str) { + if matches!(self.state, AutomateState::Locked { .. }) { + return; + } + self.state = AutomateState::Active { + persona: persona.to_string(), + confidence: 1.0, + }; + self.debounce_count = 0; + self.challenger = None; + } + /// Evaluate a fresh ranking and decide whether to switch personas. /// /// Logic: @@ -204,7 +250,10 @@ impl SwitchController { match &self.state.clone() { AutomateState::Off | AutomateState::Armed => { - // Armed/Off: accept top candidate immediately without threshold gating. + // Do not activate a candidate below the confidence floor. + if top.confidence < self.policy.min_confidence { + return Decision::Hold; + } self.state = AutomateState::Active { persona: top.persona.clone(), confidence: top.confidence, @@ -228,6 +277,27 @@ impl SwitchController { return Decision::Hold; } + // Confidence floor: never switch to a challenger we are not + // confident in. + if top.confidence < self.policy.min_confidence { + self.debounce_count = 0; + self.challenger = None; + return Decision::Hold; + } + + // Margin floor: the challenger must beat the current active persona + // by at least switch_margin before any switch is considered. + let current_score = ranked + .iter() + .find(|s| s.persona == *current) + .map(|s| s.score) + .unwrap_or(0.0); + if top.score - current_score < self.policy.switch_margin { + self.debounce_count = 0; + self.challenger = None; + return Decision::Hold; + } + // Distribution-aware switching: compute mean and stddev of all scores. let scores: Vec = ranked.iter().map(|s| s.score).collect(); let mean = scores.iter().sum::() / scores.len() as f32; @@ -317,6 +387,8 @@ mod tests { lexical: 0.0, intent: 0.0, capability: 0.0, + context: 0.0, + semantic: 0.0, }, } } @@ -520,5 +592,50 @@ mod tests { let policy = SwitchPolicy::from_sensitivity(0.5); assert!((policy.z_threshold - 0.65).abs() < 0.01); assert_eq!(policy.debounce_ticks, 2); + // At and above the midpoint the confidence/margin floors are disabled, + // so default behavior is governed by the distribution heuristics only. + assert_eq!(policy.min_confidence, 0.0); + assert_eq!(policy.switch_margin, 0.0); + } + + /// A top candidate below min_confidence does not activate. + #[test] + fn min_confidence_blocks_low_confidence_activation() { + let policy = SwitchPolicy { + min_confidence: 0.5, + switch_margin: 0.0, + debounce_ticks: 0, + z_threshold: 0.65, + min_gap_fraction: 0.09, + }; + let mut ctrl = SwitchController::new(policy); + ctrl.arm(); + // Top candidate confidence 0.3 is below the 0.5 floor. + let ranked = vec![scored("alpha", 0.9, 0.3)]; + let d = ctrl.decide(&ranked); + assert_eq!(d, Decision::Hold, "low-confidence top must not activate"); + assert_eq!(*ctrl.state(), AutomateState::Armed, "state stays Armed"); + } + + /// A challenger that does not beat the active persona by switch_margin holds. + #[test] + fn switch_margin_blocks_marginal_challenger() { + let policy = SwitchPolicy { + min_confidence: 0.0, + switch_margin: 0.3, + debounce_ticks: 0, + z_threshold: 0.0, + min_gap_fraction: 0.0, + }; + let mut ctrl = SwitchController::new(policy); + ctrl.arm(); + // Activate alpha. + let ranked_a = vec![scored("alpha", 0.60, 0.9), scored("beta", 0.10, 0.2)]; + ctrl.decide(&ranked_a); + // Beta edges ahead by only 0.05, below the 0.3 switch_margin. Without the + // margin floor the lenient z/gap/debounce settings would switch here. + let ranked_b = vec![scored("beta", 0.65, 0.9), scored("alpha", 0.60, 0.5)]; + let d = ctrl.decide(&ranked_b); + assert_eq!(d, Decision::Hold, "margin below switch_margin must hold"); } } diff --git a/crates/frameshift-orchestrator/src/embed.rs b/crates/frameshift-orchestrator/src/embed.rs new file mode 100644 index 0000000..815e078 --- /dev/null +++ b/crates/frameshift-orchestrator/src/embed.rs @@ -0,0 +1,137 @@ +//! Semantic embedding abstraction for meaning-based persona matching. +//! +//! This is the dependency-free Phase 1 scaffolding for semantic selection: it +//! defines the [`Embedder`] trait and the cosine-similarity math the policy +//! scorer uses, but ships no concrete embedding engine. A real embedder (a +//! pure-Rust `candle` model, or `fastembed`/`ort`, plus model distribution) is +//! Phase 2 and is gated on an explicit decision because it adds a heavy ML +//! dependency to an otherwise lean workspace. +//! +//! Because no production caller supplies an [`Embedder`] yet, the semantic +//! channel contributes `0.0` everywhere and selection behavior is unchanged. + +/// Produces a dense vector embedding for a piece of text. +/// +/// Implementations map natural-language text to a fixed-dimensional vector +/// whose geometry encodes meaning, so that related texts have a high cosine +/// similarity. The trait is object-safe (`&dyn Embedder`) so the scorer can +/// accept an optional embedder without being generic over its type. Two calls +/// with equal input should return equal output (determinism), and all vectors +/// from a given embedder should share one dimensionality. +pub trait Embedder { + /// Return the embedding vector for `text`. + fn embed(&self, text: &str) -> Vec; +} + +/// Cosine similarity between two equal-length vectors, clamped to `[0.0, 1.0]`. +/// +/// Returns `0.0` (a safe no-signal value) rather than panicking or producing +/// `NaN` when the inputs are degenerate: empty vectors, vectors of differing +/// length, or a vector whose L2 norm is effectively zero. Negative cosine +/// (anti-correlated vectors) is clamped to `0.0` because the scorer treats this +/// as an additive similarity bonus, never a penalty. +pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { + if a.is_empty() || a.len() != b.len() { + return 0.0; + } + let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum(); + let norm_a = a.iter().map(|x| x * x).sum::().sqrt(); + let norm_b = b.iter().map(|x| x * x).sum::().sqrt(); + if norm_a <= f32::EPSILON || norm_b <= f32::EPSILON { + return 0.0; + } + (dot / (norm_a * norm_b)).clamp(0.0, 1.0) +} + +/// Embed both texts with `embedder` and return their cosine similarity in +/// `[0.0, 1.0]`. +/// +/// Convenience wrapper over [`cosine_similarity`]. Inherits its degenerate-case +/// handling, so an embedder that returns empty or mismatched-length vectors +/// yields `0.0` rather than an error. +pub fn semantic_similarity(embedder: &dyn Embedder, a: &str, b: &str) -> f32 { + let va = embedder.embed(a); + let vb = embedder.embed(b); + cosine_similarity(&va, &vb) +} + +/// A deterministic bag-of-words embedder used only by tests. +/// +/// Hashes each whitespace-separated, lowercased word into a fixed-width bucket +/// and counts occurrences. It is order-insensitive and dependency-free, so two +/// texts that share words have overlapping nonzero buckets and therefore a +/// positive cosine similarity -- enough to exercise the semantic channel +/// without a real embedding model. +#[cfg(test)] +pub(crate) struct BagOfWordsEmbedder; + +#[cfg(test)] +impl Embedder for BagOfWordsEmbedder { + /// Embed `text` as a 64-dimensional word-occurrence histogram. + fn embed(&self, text: &str) -> Vec { + const DIM: usize = 64; + let mut v = vec![0.0f32; DIM]; + for word in text.split_whitespace() { + let lowered = word.to_lowercase(); + // FNV-1a hash -> bucket index. Stable across runs (no randomness). + let mut h: u64 = 0xcbf29ce484222325; + for byte in lowered.bytes() { + h ^= byte as u64; + h = h.wrapping_mul(0x100000001b3); + } + v[(h as usize) % DIM] += 1.0; + } + v + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Identical vectors have cosine similarity 1.0. + #[test] + fn identical_vectors_are_maximally_similar() { + let v = vec![1.0, 2.0, 3.0]; + assert!((cosine_similarity(&v, &v) - 1.0).abs() < 1e-6); + } + + /// Orthogonal vectors have cosine similarity 0.0. + #[test] + fn orthogonal_vectors_are_dissimilar() { + let a = vec![1.0, 0.0]; + let b = vec![0.0, 1.0]; + assert_eq!(cosine_similarity(&a, &b), 0.0); + } + + /// Anti-correlated vectors clamp to 0.0 (no negative bonus). + #[test] + fn anti_correlated_clamps_to_zero() { + let a = vec![1.0, 0.0]; + let b = vec![-1.0, 0.0]; + assert_eq!(cosine_similarity(&a, &b), 0.0); + } + + /// Empty, mismatched-length, and zero-norm inputs yield 0.0 (no panic, no NaN). + #[test] + fn degenerate_inputs_yield_zero() { + assert_eq!(cosine_similarity(&[], &[]), 0.0); + assert_eq!(cosine_similarity(&[1.0, 2.0], &[1.0]), 0.0); + assert_eq!(cosine_similarity(&[0.0, 0.0], &[1.0, 1.0]), 0.0); + } + + /// The mock embedder gives a high similarity to identical text and a + /// positive-but-lower similarity to text that merely shares words. + #[test] + fn mock_embedder_reflects_word_overlap() { + let emb = BagOfWordsEmbedder; + let same = semantic_similarity(&emb, "rust cargo clippy", "rust cargo clippy"); + let related = semantic_similarity(&emb, "rust cargo clippy", "rust cargo ownership"); + let unrelated = semantic_similarity(&emb, "rust cargo clippy", "tacos"); + + assert!((same - 1.0).abs() < 1e-6, "identical text -> 1.0"); + assert!(related > 0.0, "shared words -> positive similarity"); + assert!(related < same, "partial overlap < full overlap"); + assert!(unrelated < related, "no shared words -> least similar"); + } +} diff --git a/crates/frameshift-orchestrator/src/feedback.rs b/crates/frameshift-orchestrator/src/feedback.rs index 25b57de..8ae0359 100644 --- a/crates/frameshift-orchestrator/src/feedback.rs +++ b/crates/frameshift-orchestrator/src/feedback.rs @@ -188,12 +188,23 @@ impl Preferences { } /// Persist preferences to a JSON file, creating parent directories as needed. + /// + /// The write is atomic: data is serialized to a uniquely-named temp file in + /// the same directory and then renamed over the target, so a crash or a + /// concurrent writer never leaves a half-written file that `load` would fail + /// to parse. pub fn save(&self, path: &Path) -> Result<(), OrchestratorError> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let data = serde_json::to_string_pretty(self)?; - std::fs::write(path, data)?; + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("preferences.json"); + let tmp_path = path.with_file_name(format!(".{file_name}.tmp.{}", std::process::id())); + std::fs::write(&tmp_path, data.as_bytes())?; + std::fs::rename(&tmp_path, path)?; Ok(()) } } @@ -262,6 +273,28 @@ mod tests { assert!((loaded.bias_for("a") - prefs.bias_for("a")).abs() < f32::EPSILON); } + /// Saving writes atomically and leaves no temporary file behind. + #[test] + fn save_leaves_no_temp_file() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("prefs.json"); + + let mut prefs = Preferences::new(); + prefs.record_override(Some("a"), "b"); + prefs.save(&path).unwrap(); + + assert!(path.exists()); + let leftovers: Vec<_> = std::fs::read_dir(tmp.path()) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().contains(".tmp.")) + .collect(); + // No temp file should remain after a successful save. + assert!(leftovers.is_empty()); + // And the saved file loads cleanly. + Preferences::load(&path).unwrap(); + } + /// record_override_with_intent records a per-intent bias for the chosen persona. #[test] fn per_intent_bias_is_recorded() { diff --git a/crates/frameshift-orchestrator/src/lib.rs b/crates/frameshift-orchestrator/src/lib.rs index 3360a2c..ca18e32 100644 --- a/crates/frameshift-orchestrator/src/lib.rs +++ b/crates/frameshift-orchestrator/src/lib.rs @@ -15,6 +15,7 @@ pub mod audit; pub mod context; pub mod controller; +pub mod embed; pub mod error; pub mod feedback; pub mod index; @@ -27,14 +28,16 @@ pub mod run; pub use audit::{now_timestamp, AuditLog, Transition}; pub use context::{sense, ContextSignal}; pub use controller::{AutomateState, Decision, SwitchController, SwitchPolicy}; +pub use embed::{cosine_similarity, semantic_similarity, Embedder}; pub use error::OrchestratorError; pub use feedback::Preferences; pub use index::{PersonaIndex, PersonaProfile}; pub use intent::{classify as classify_intent, Intent}; pub use mode::{Mode, ModeState}; -pub use policy::{rank, PolicyWeights, ScoreComponents, Scored}; +pub use policy::{rank, rank_with_embedder, PolicyWeights, ScoreComponents, Scored}; pub use run::{ - select, select_rich, CandidateOutput, ContextSnapshot, SelectionInputs, SelectionOutput, + select, select_rich, select_with_embedder, CandidateOutput, ContextSnapshot, SelectionInputs, + SelectionOutput, }; /// Facade that wires together the index, weights, policy, preferences, and controller. @@ -136,6 +139,7 @@ mod tests { }, frameworks: vec![], task_tokens: vec![], + context_tokens: vec![], inferred_intent: None, }; // With only one persona and confidence depends on scoring; just verify no panic. diff --git a/crates/frameshift-orchestrator/src/policy.rs b/crates/frameshift-orchestrator/src/policy.rs index 0f68c4e..8647ac6 100644 --- a/crates/frameshift-orchestrator/src/policy.rs +++ b/crates/frameshift-orchestrator/src/policy.rs @@ -1,8 +1,26 @@ //! Scoring policy: rank personas against a context signal. use crate::context::ContextSignal; +use crate::embed::{semantic_similarity, Embedder}; use crate::feedback::Preferences; -use crate::index::PersonaIndex; +use crate::index::{PersonaIndex, PersonaProfile}; + +/// Score added per project-dependency token (from `ContextSignal::context_tokens`) +/// that matches a persona keyword. +const CONTEXT_TOKEN_HIT: f32 = 0.04; + +/// Maximum additive bonus contributed by the semantic-similarity channel. +/// +/// The cosine similarity in `[0, 1]` is scaled by this weight and added on top +/// of the weighted blend, mirroring the context bonus: it can lift a persona +/// whose meaning matches the task but never penalizes others or redistributes +/// the primary weights. Contributes `0.0` when no embedder is supplied. +const SEMANTIC_WEIGHT: f32 = 0.15; + +/// Maximum total bonus contributed by dependency-token matches. Capped so a +/// dependency-heavy project cannot let framework matching dominate the language, +/// lexical, and intent signals. +const CONTEXT_TOKEN_CAP: f32 = 0.12; /// Weights controlling the relative contribution of each scoring component. /// @@ -43,6 +61,14 @@ pub struct ScoreComponents { pub intent: f32, /// Capability heuristic component (0..1). pub capability: f32, + /// Project-signal match bonus (0..[`CONTEXT_TOKEN_CAP`]) from dependency and + /// build-framework matches, added on top of the weighted blend rather than + /// diluting the lexical channel. + pub context: f32, + /// Semantic-similarity bonus (0..[`SEMANTIC_WEIGHT`]) from cosine distance + /// between the task text and the persona text, when an embedder is supplied. + /// `0.0` when no embedder is in use (the default), so it is inert by default. + pub semantic: f32, } /// A scored persona with rationale and confidence information. @@ -65,7 +91,35 @@ pub struct Scored { pub components: ScoreComponents, } -/// Rank all personas in `index` for the given `ctx`. +/// Rank all personas in `index` for the given `ctx` without a semantic embedder. +/// +/// Thin wrapper over [`rank_with_embedder`] with the embedder disabled. This is +/// the default entry point; the semantic channel contributes `0.0`, so results +/// are identical to the scorer before semantic matching was added. +pub fn rank( + ctx: &ContextSignal, + index: &PersonaIndex, + weights: &PolicyWeights, + prefs: &Preferences, +) -> Vec { + rank_with_embedder(ctx, index, weights, prefs, None) +} + +/// Build the text used to embed a persona for semantic matching. +/// +/// Concatenates the persona's optional description with its keyword set so the +/// embedder sees both the prose summary and the discriminating terms. Keyword +/// order is unspecified (it is a set), which suits bag-of-words style embedders. +fn persona_text(profile: &PersonaProfile) -> String { + let mut text = profile.description.clone().unwrap_or_default(); + for kw in &profile.keywords { + text.push(' '); + text.push_str(kw); + } + text +} + +/// Rank all personas in `index` for the given `ctx`, optionally using `embedder`. /// /// Scoring: /// - Language score: sum of `ctx.languages` weights for languages present in @@ -74,16 +128,21 @@ pub struct Scored { /// keywords; 0.0 if there are no task tokens. /// - Capability score: small bonus if the persona has no required tools and /// no network egress (i.e. "safe" / simple persona). +/// - Semantic score: when `embedder` is `Some` and the task has tokens, the +/// cosine similarity between the task text and each persona's text, scaled by +/// [`SEMANTIC_WEIGHT`] and added to the blend. `None` contributes `0.0`, so +/// the default path has no semantic component and no behavior change. /// - Per-persona preference bias from `prefs` is added after blending and /// clamped to [0.0, 1.0]. /// /// Returns results sorted descending by blended score. Confidence is computed /// after sorting based on the top-vs-runner-up gap and absolute score. -pub fn rank( +pub fn rank_with_embedder( ctx: &ContextSignal, index: &PersonaIndex, weights: &PolicyWeights, prefs: &Preferences, + embedder: Option<&dyn Embedder>, ) -> Vec { // Precompute IDF for each task token: tokens that appear in fewer persona // keyword sets are weighted higher (rare tokens are more discriminating). @@ -176,7 +235,12 @@ pub fn rank( } else { ctx.task_tokens .iter() - .filter(|t| profile.anti_keywords.contains(t)) + .filter(|t| { + profile + .anti_keywords + .iter() + .any(|ak| ak.eq_ignore_ascii_case(t.as_str())) + }) .count() }; let lex_score = if anti_hit_count > 0 && !ctx.task_tokens.is_empty() { @@ -195,11 +259,44 @@ pub fn rank( 0.0 }; - // Blended score. + // Context-signal bonus: a small, capped reward for personas whose + // keywords match a project signal -- either a declared dependency + // (`context_tokens`) or a detected build framework (`frameworks`, + // e.g. cargo/npm/go/python). Kept separate from the lexical IDF + // channel so that signals matching no persona cannot dilute + // task-token scoring. + let context_hits = ctx + .context_tokens + .iter() + .chain(ctx.frameworks.iter()) + .filter(|t| profile.keywords.contains(*t)) + .count(); + let context_score = (context_hits as f32 * CONTEXT_TOKEN_HIT).min(CONTEXT_TOKEN_CAP); + + // Semantic-similarity bonus: when an embedder is supplied, reward + // personas whose text is close in MEANING to the task -- catching + // matches the exact-token lexical channel misses. Additive and capped + // by SEMANTIC_WEIGHT, like the context bonus. The task text is the + // normalized task tokens; Phase 2 may embed the raw task hint instead. + // No embedder (the default) yields 0.0, so there is no regression. + let semantic_score = match embedder { + Some(emb) if !ctx.task_tokens.is_empty() => { + let task_text = ctx.task_tokens.join(" "); + let sim = semantic_similarity(emb, &task_text, &persona_text(profile)); + SEMANTIC_WEIGHT * sim + } + _ => 0.0, + }; + + // Blended score. The context and semantic bonuses are additive (not + // weighted): they can only raise a persona that matches the project + // or the task meaning, never lower others or redistribute the weights. let blended = weights.language * lang_score + weights.lexical * lex_score + weights.intent * intent_score - + weights.capability * cap_score; + + weights.capability * cap_score + + context_score + + semantic_score; // Apply preference bias and clamp. // Use intent-aware lookup when the context carries an inferred intent; @@ -245,6 +342,23 @@ pub fn rank( if cap_score > 0.0 { rationale_parts.push(format!("cap_score={:.2}", cap_score)); } + if context_score > 0.0 { + let hit_signals: Vec<&str> = ctx + .context_tokens + .iter() + .chain(ctx.frameworks.iter()) + .filter(|t| profile.keywords.contains(*t)) + .map(|t| t.as_str()) + .collect(); + rationale_parts.push(format!( + "project signals [{}] match: context_score={:.2}", + hit_signals.join(","), + context_score + )); + } + if semantic_score > 0.0 { + rationale_parts.push(format!("semantic_score={:.2}", semantic_score)); + } if bias.abs() > f32::EPSILON { rationale_parts.push(format!("pref_bias={:.3}", bias)); } @@ -264,6 +378,8 @@ pub fn rank( lexical: lex_score, intent: intent_score, capability: cap_score, + context: context_score, + semantic: semantic_score, }; Scored { @@ -332,6 +448,7 @@ mod tests { .collect::>(), frameworks: vec![], task_tokens: task_tokens.iter().map(|t| t.to_string()).collect(), + context_tokens: vec![], inferred_intent: None, } } @@ -434,6 +551,7 @@ mod tests { }, frameworks: vec![], task_tokens: vec!["debugging".to_string(), "error".to_string()], + context_tokens: vec![], inferred_intent: Some(Intent::Debugging), }; @@ -464,4 +582,117 @@ mod tests { "anti-keywords should penalize lexical score" ); } + + /// Anti-keyword matching is case-insensitive: uppercase manifest entries + /// still penalize lowercase task tokens. + #[test] + fn anti_keywords_penalize_case_insensitive() { + let mut persona_a = make_profile("backend", &["rust"], &["rust", "api"]); + persona_a.anti_keywords = vec!["CSS".to_string(), "Frontend".to_string()]; + + let persona_b = make_profile("frontend", &["typescript"], &["react", "css"]); + + let index = PersonaIndex { + profiles: vec![persona_a, persona_b], + }; + let ctx = make_ctx(&[("rust", 1.0)], &["css", "styling", "frontend"]); + + let ranked = rank(&ctx, &index, &PolicyWeights::default(), &Preferences::new()); + let backend_entry = ranked.iter().find(|s| s.persona == "backend").unwrap(); + assert!( + backend_entry.components.lexical < 0.3, + "case-insensitive anti-keywords should penalize lexical score" + ); + } + + /// A dependency token that matches a persona keyword adds a context bonus + /// and breaks a tie against an otherwise-equal persona. + #[test] + fn context_tokens_bonus_rewards_dependency_match() { + let web = make_profile("web-rust", &["rust"], &["rust", "axum"]); + let plain = make_profile("plain-rust", &["rust"], &["rust"]); + let index = PersonaIndex { + profiles: vec![plain, web], + }; + let mut ctx = make_ctx(&[("rust", 1.0)], &[]); + ctx.context_tokens = vec!["axum".to_string(), "tokio".to_string()]; + + let ranked = rank(&ctx, &index, &PolicyWeights::default(), &Preferences::new()); + assert_eq!( + ranked[0].persona, "web-rust", + "the axum dependency should boost the axum-keyworded persona" + ); + let web_entry = ranked.iter().find(|s| s.persona == "web-rust").unwrap(); + assert!(web_entry.components.context > 0.0); + } + + /// An empty context_tokens set contributes no bonus (regression guard). + #[test] + fn empty_context_tokens_add_no_bonus() { + let p = make_profile("rust-expert", &["rust"], &["rust", "axum"]); + let index = PersonaIndex { profiles: vec![p] }; + let ctx = make_ctx(&[("rust", 1.0)], &[]); + let ranked = rank(&ctx, &index, &PolicyWeights::default(), &Preferences::new()); + assert_eq!(ranked[0].components.context, 0.0); + } + + /// A detected build framework (e.g. `cargo`) contributes the same context + /// bonus as a dependency when it matches a persona keyword. + #[test] + fn framework_marker_contributes_context_bonus() { + let rust_dev = make_profile("rust-dev", &["rust"], &["rust", "cargo"]); + let plain = make_profile("plain", &["rust"], &["rust"]); + let index = PersonaIndex { + profiles: vec![plain, rust_dev], + }; + let mut ctx = make_ctx(&[("rust", 1.0)], &[]); + ctx.frameworks = vec!["cargo".to_string()]; + + let ranked = rank(&ctx, &index, &PolicyWeights::default(), &Preferences::new()); + let entry = ranked.iter().find(|s| s.persona == "rust-dev").unwrap(); + assert!( + entry.components.context > 0.0, + "the cargo build framework should give the cargo-keyworded persona a bonus" + ); + assert_eq!(ranked[0].persona, "rust-dev"); + } + + /// Without an embedder the semantic channel is inert: `rank` (and any caller + /// passing `None`) yields a semantic component of exactly 0.0. Regression + /// guard against the semantic bonus leaking into the default scoring path. + #[test] + fn semantic_channel_is_zero_without_embedder() { + let p = make_profile("rust-dev", &["rust"], &["rust", "cargo", "ownership"]); + let index = PersonaIndex { profiles: vec![p] }; + let ctx = make_ctx(&[("rust", 1.0)], &["ownership"]); + let ranked = rank(&ctx, &index, &PolicyWeights::default(), &Preferences::new()); + assert_eq!(ranked[0].components.semantic, 0.0); + } + + /// With a (mock) embedder, a task hint that overlaps a persona's text earns a + /// positive semantic bonus that lifts the blended score above the no-embedder + /// baseline. + #[test] + fn semantic_channel_rewards_related_hint() { + use crate::embed::BagOfWordsEmbedder; + + let p = make_profile("rust-dev", &["rust"], &["rust", "cargo", "ownership"]); + let index = PersonaIndex { profiles: vec![p] }; + let ctx = make_ctx(&[("rust", 1.0)], &["ownership", "cargo"]); + let weights = PolicyWeights::default(); + + let baseline = rank(&ctx, &index, &weights, &Preferences::new()); + + let emb = BagOfWordsEmbedder; + let ranked = rank_with_embedder(&ctx, &index, &weights, &Preferences::new(), Some(&emb)); + + assert!( + ranked[0].components.semantic > 0.0, + "an overlapping hint should produce a semantic bonus" + ); + assert!( + ranked[0].score >= baseline[0].score, + "the semantic bonus must not lower the score" + ); + } } diff --git a/crates/frameshift-orchestrator/src/run.rs b/crates/frameshift-orchestrator/src/run.rs index 7ca3bfd..2ce7c14 100644 --- a/crates/frameshift-orchestrator/src/run.rs +++ b/crates/frameshift-orchestrator/src/run.rs @@ -25,11 +25,12 @@ use std::path::{Path, PathBuf}; use serde::Serialize; use crate::context::sense; +use crate::embed::Embedder; use crate::error::OrchestratorError; use crate::feedback::Preferences; use crate::index::PersonaIndex; use crate::intent::Intent; -use crate::policy::{rank, PolicyWeights, Scored}; +use crate::policy::{rank, rank_with_embedder, PolicyWeights, Scored}; /// All inputs required to run a persona selection pass. pub struct SelectionInputs<'a> { @@ -127,6 +128,18 @@ pub struct ComponentsOutput { /// Returns an empty `Vec` (not an error) when both `catalog_root` is absent /// and `source_dirs` is empty. pub fn select(inputs: &SelectionInputs<'_>) -> Result, OrchestratorError> { + select_with_embedder(inputs, None) +} + +/// Run a persona selection pass using an optional semantic `embedder`. +/// +/// Identical to [`select`] but threads `embedder` into the scorer so the +/// semantic-similarity channel is active when an embedder is supplied. Passing +/// `None` reproduces [`select`] exactly, so existing callers are unaffected. +pub fn select_with_embedder( + inputs: &SelectionInputs<'_>, + embedder: Option<&dyn Embedder>, +) -> Result, OrchestratorError> { let index = if let Some(catalog_root) = &inputs.catalog_root { PersonaIndex::from_catalog(catalog_root)? } else { @@ -141,7 +154,7 @@ pub fn select(inputs: &SelectionInputs<'_>) -> Result, OrchestratorE } let ctx = sense(inputs.project_root, inputs.task_hint); - let ranked = rank(&ctx, &index, &inputs.weights, &inputs.prefs); + let ranked = rank_with_embedder(&ctx, &index, &inputs.weights, &inputs.prefs, embedder); Ok(ranked) } diff --git a/crates/frameshift-server/Cargo.toml b/crates/frameshift-server/Cargo.toml index 3f93c0e..68a98a5 100644 --- a/crates/frameshift-server/Cargo.toml +++ b/crates/frameshift-server/Cargo.toml @@ -46,7 +46,10 @@ flate2 = "1" hmac = { workspace = true } sha2 = { workspace = true } tower_governor = "0.8" -prometheus = "0.13" +# default-features disabled to drop the unmaintained protobuf 2.x dependency +# (RUSTSEC-2024-0437); the server only emits the Prometheus text exposition +# format via TextEncoder, never the protobuf wire format. +prometheus = { version = "0.13", default-features = false } [dev-dependencies] tokio = { workspace = true } diff --git a/crates/frameshift-server/src/routes/downloads.rs b/crates/frameshift-server/src/routes/downloads.rs index 273ac6e..e6a8d1e 100644 --- a/crates/frameshift-server/src/routes/downloads.rs +++ b/crates/frameshift-server/src/routes/downloads.rs @@ -253,5 +253,12 @@ pub async fn stream_signed_download( // Count successful signed-download responses (alongside direct pack downloads). state.metrics.pack_downloads_total.inc(); + // NOTE: total_downloads is NOT incremented here. This path has only a + // content hash -- pack name and version are not available, so + // increment_download_counter cannot be called. The official client + // (frameshift-client) downloads via `/v1/packs/{name}/versions/{version}/pack`, + // which does increment total_downloads. Callers that want counted downloads + // should use that route. + Ok(response) } diff --git a/crates/frameshift-server/src/routes/packs.rs b/crates/frameshift-server/src/routes/packs.rs index a7a9d74..894973f 100644 --- a/crates/frameshift-server/src/routes/packs.rs +++ b/crates/frameshift-server/src/routes/packs.rs @@ -893,11 +893,25 @@ pub async fn download_pack_bytes( // Count successful direct-download responses. state.metrics.pack_downloads_total.inc(); - // Record the download for trending ranking. Best-effort: a failure here must - // not fail the download the client already received. + // Record the download event for trending ranking -- feeds the 7-day velocity + // used by SortMode::Trending. Best-effort: a failure here must not fail the + // download the client already received. if let Err(e) = state.catalog.record_download(&name, &version).await { tracing::warn!(pack = %name, version = %version, error = %e, "record_download failed"); } + // Increment the cumulative download counter -- feeds `total_downloads` on + // the pack record shown on the marketplace catalog page. Best-effort: same + // policy as record_download above; warn and continue on failure. NotFound + // is unreachable here (the version record was fetched above), but the + // best-effort pattern handles it safely regardless. + if let Err(e) = state + .catalog + .increment_download_counter(&name, &version) + .await + { + tracing::warn!(pack = %name, version = %version, error = %e, "increment_download_counter failed"); + } + Ok(response) } diff --git a/crates/frameshift-server/tests/integration.rs b/crates/frameshift-server/tests/integration.rs index 638ed82..915ec87 100644 --- a/crates/frameshift-server/tests/integration.rs +++ b/crates/frameshift-server/tests/integration.rs @@ -307,6 +307,55 @@ async fn download_pack_502_when_blob_missing_from_objects() { assert_eq!(body["error"], "upstream backend mismatch"); } +/// `GET /v1/packs/{name}/versions/{version}/pack` calls `increment_download_counter` +/// on a successful 200 response. +/// +/// Regression test: before the fix, `download_pack_bytes` only called +/// `record_download` (trending) and never `increment_download_counter`, so +/// `total_downloads` on every pack was permanently 0. +#[tokio::test] +async fn download_pack_increments_download_counter() { + let blob = b"counted".to_vec(); + let hash = ObjectHash::of(&blob); + let author_key = Ed25519PublicKey([5u8; 32]); + + let catalog = MockCatalog::new(); + { + let mut state = catalog.state.write().unwrap(); + state.packs.insert( + "counted-pack".to_string(), + make_pack("counted-pack", author_key), + ); + state.versions.insert( + ("counted-pack".to_string(), "1.0.0".to_string()), + make_version("counted-pack", "1.0.0", hash, author_key), + ); + } + // Clone before moving into make_state -- both sides share the same + // Arc>, so writes through AppState are visible here. + let catalog_observer = catalog.clone(); + + let objects = MockPackStore::new(); + objects.insert(hash, blob.clone()); + + let state = make_state(catalog, objects); + let resp = oneshot_get(state, "/v1/packs/counted-pack/versions/1.0.0/pack").await; + assert_eq!(resp.status(), StatusCode::OK, "download should succeed"); + + let increments = catalog_observer + .state + .read() + .unwrap() + .download_counter_increments + .get(&("counted-pack".to_string(), "1.0.0".to_string())) + .copied() + .unwrap_or(0); + assert_eq!( + increments, 1, + "increment_download_counter must be called exactly once per successful download" + ); +} + // --------------------------------------------------------------------------- // /v1/authors // --------------------------------------------------------------------------- diff --git a/crates/frameshift-server/tests/mocks/catalog.rs b/crates/frameshift-server/tests/mocks/catalog.rs index 1efee37..300b575 100644 --- a/crates/frameshift-server/tests/mocks/catalog.rs +++ b/crates/frameshift-server/tests/mocks/catalog.rs @@ -48,6 +48,12 @@ pub struct MockState { /// When `true`, the next mutating call returns `CatalogError::Conflict`. pub inject_conflict: bool, + + /// Number of `increment_download_counter` calls per `(pack_name, version)`. + /// + /// Tests read this to assert that the cumulative download counter was + /// incremented after a successful download response. + pub download_counter_increments: HashMap<(String, String), u64>, } /// In-memory [`CatalogBackend`] for integration tests. @@ -259,13 +265,23 @@ impl CatalogBackend for MockCatalog { Ok(results) } - /// Increment download counter (no-op in mock). + /// Increment the download counter for a pack version. + /// + /// Records the call in `state.download_counter_increments` so tests can + /// assert that `download_pack_bytes` actually invoked this method. async fn increment_download_counter( &self, - _name: &str, - _version: &str, + name: &str, + version: &str, ) -> Result { - Ok(0) + let mut state = self + .state + .write() + .map_err(|e| CatalogError::BackendError(e.to_string().into()))?; + let key = (name.to_string(), version.to_string()); + let count = state.download_counter_increments.entry(key).or_insert(0); + *count += 1; + Ok(*count) } /// Tombstone a pack version (no-op in mock). diff --git a/scripts/preflight.sh b/scripts/preflight.sh new file mode 100755 index 0000000..5becf02 --- /dev/null +++ b/scripts/preflight.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# +# preflight.sh -- run the same gates CI runs, locally, before you push. +# +# Mirrors .github/workflows/ci.yml so a green preflight means a green CI (minus +# the Postgres-integration and mirror jobs, which need Docker / the remote and +# are not reproduced here). Run this before opening or updating a PR. +# +# Usage: +# scripts/preflight.sh # fmt + clippy + test + audit +# scripts/preflight.sh --fast # fmt + audit only (no compile; what the +# # pre-push hook runs) +# +# Exits nonzero on the first failing gate. + +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +# The single advisory CI ignores: RUSTSEC-2024-0370 (proc-macro-error, +# unmaintained, build-time only via age -> i18n-embed-fl). Keep this in sync +# with the `ignore:` field in .github/workflows/ci.yml. +AUDIT_IGNORE="RUSTSEC-2024-0370" + +fast_only=0 +if [[ "${1:-}" == "--fast" ]]; then + fast_only=1 +fi + +step() { printf '\n\033[1;36m== %s ==\033[0m\n' "$1"; } +ok() { printf '\033[1;32m ok: %s\033[0m\n' "$1"; } +fail() { printf '\033[1;31m FAIL: %s\033[0m\n' "$1" >&2; exit 1; } + +# 1. Formatting (instant, no compile). The most common CI failure. +step "cargo fmt --all -- --check" +cargo fmt --all -- --check || fail "rustfmt: run 'cargo fmt --all' to fix" +ok "formatting" + +# 2. Supply-chain advisories (fast, reads Cargo.lock; no project compile). +step "cargo audit" +if command -v cargo-audit >/dev/null 2>&1; then + cargo audit --ignore "$AUDIT_IGNORE" || fail "cargo audit found advisories" + ok "audit" +else + printf '\033[1;33m SKIP: cargo-audit not installed (cargo install cargo-audit)\033[0m\n' +fi + +if [[ "$fast_only" == "1" ]]; then + step "fast preflight complete" + ok "fmt + audit clean -- safe to push (clippy/test not run in --fast mode)" + exit 0 +fi + +# 3. Lints (compiles; matches CI's clippy job). +step "cargo clippy --workspace --all-targets -- -D warnings" +cargo clippy --workspace --all-targets -- -D warnings || fail "clippy" +ok "clippy" + +# 4. Tests (compiles; matches CI's plain test job, Docker-gated tests skipped). +step "cargo test --workspace" +cargo test --workspace || fail "tests" +ok "tests" + +step "preflight complete" +ok "all gates green -- safe to push"