diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index fe31b89d8..68abac6e8 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -16,7 +16,7 @@ - [ ] `cargo build --all-targets` - [ ] `cargo build --all-targets --all-features` - [ ] `cargo test` -- [ ] `cargo test --all-features` +- [ ] `scripts/test-features.sh` (stages pinned native artifacts and runs all feature lanes) ## Documentation diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e04e67f3..8176eed67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,3 +33,17 @@ jobs: coverage-script: scripts/check-coverage.sh module-e2e-script: scripts/test-e2e.sh feature-test-script: scripts/test-features.sh + storage-needs-boundary: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + submodules: recursive + persist-credentials: false + - uses: dtolnay/rust-toolchain@1.94.0 + - name: Test the standalone storage-needs consumer + run: cargo run --locked --manifest-path proofs/storage-needs-consumer/Cargo.toml --target-dir target/storage-needs-consumer + - name: Check the storage-needs dependency boundary + run: python3 scripts/check-storage-needs-boundary.py + - name: Test dependency-boundary negative controls + run: python3 scripts/test-storage-needs-boundary.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 55c106f99..fbfbda2c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,10 +2,21 @@ name: Release on: workflow_dispatch: inputs: + mode: + description: Select the ordinary tag release or the reviewed epic release path. + type: choice + options: [tag, reviewed] + default: tag + required: true tag: description: Existing version tag (vX.Y.Z) committed through a reviewed PR type: string required: true + reviewed_pr: + description: Positive canonical PR number for a reviewed epic release. + type: string + default: '0' + required: false push: tags: ['v*'] permissions: {} @@ -31,8 +42,48 @@ jobs: } echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + validate: + if: inputs.mode == 'reviewed' + runs-on: ubuntu-latest + permissions: {} + outputs: + reviewed_pr: ${{ steps.validate.outputs.reviewed_pr }} + steps: + - name: Validate reviewed epic dispatch inputs + id: validate + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + REPOSITORY: ${{ github.repository }} + REF: ${{ github.ref }} + RELEASE_MODE: ${{ inputs.mode }} + REVIEWED_PR: ${{ inputs.reviewed_pr || '0' }} + run: | + set -euo pipefail + [[ "$EVENT_NAME" == "workflow_dispatch" ]] || { + echo "reviewed release must be dispatched" >&2 + exit 2 + } + [[ "$REPOSITORY" == "tinyhumansai/tinychannels" ]] || { + echo "release caller is not the canonical TinyChannels repository" >&2 + exit 2 + } + [[ "$REF" == "refs/heads/epic/8056-modules-wiring" ]] || { + echo "release must run from epic/8056-modules-wiring" >&2 + exit 2 + } + [[ "$RELEASE_MODE" == "reviewed" ]] || { + echo "release mode must be reviewed" >&2 + exit 2 + } + [[ "$REVIEWED_PR" =~ ^[1-9][0-9]*$ ]] || { + echo "reviewed_pr must be a positive integer" >&2 + exit 2 + } + printf 'reviewed_pr=%s\n' "$REVIEWED_PR" >> "$GITHUB_OUTPUT" module-release: needs: resolve + if: github.event_name == 'push' || inputs.mode != 'reviewed' permissions: contents: write uses: tinyhumansai/.github/.github/workflows/module-release.yml@3e87ade76b622844985d0c665a3b075313e4545c @@ -40,3 +91,16 @@ jobs: tag: ${{ needs.resolve.outputs.tag }} version: ${{ needs.resolve.outputs.version }} module-crate: tinychannels-module + module-reviewed-epic-release: + needs: [resolve, validate] + if: inputs.mode == 'reviewed' + permissions: + contents: write + pull-requests: read + checks: read + actions: read + uses: tinyhumansai/.github/.github/workflows/module-reviewed-epic-release.yml@6ed83b04aab2d8977ec62e966cb914531e534fa4 + with: + tag: ${{ needs.resolve.outputs.tag }} + version: ${{ needs.resolve.outputs.version }} + reviewed-pr: ${{ fromJSON(needs.validate.outputs.reviewed_pr || '0') }} diff --git a/.gitignore b/.gitignore index 10665c356..eabbd6bde 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ # behind — which then gets committed as build output. target/ **/*.rs.bk +__pycache__/ +*.py[cod] # Release staging built by the `native-bundles` job. Local runs of that # packaging step leave a multi-megabyte `cdylib` and its archive here, and an diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a4d6703bb..9e2fabd96 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,3 +81,15 @@ Do not report vulnerabilities through public issues. Use the process in ## Releasing Change the Cargo package versions through a reviewed PR. After it merges, tag that commit as vX.Y.Z and push the tag, or dispatch Release with an existing version tag. The shared release verifies the package version, builds and loads all 17 native and distro artifacts, and publishes their checksum manifest. Release does not push version bumps to main. + +The TinyBus module SDK is pinned as a source gitlink in `vendor/tinybus` and +as an exact version and revision in the workspace dependencies. Keep the +client test fixture's path dependency on that same SDK version when updating +the pin. + +For a release through OpenHuman epic #8056, dispatch +`.github/workflows/release.yml` from `epic/8056-modules-wiring` with +`mode=reviewed`, the existing tag, and the positive canonical PR number whose +merge commit the tag names. The legacy zero default is rejected. Ordinary tags +and the existing Release dispatch continue through the same workflow's default +`mode=tag` path. diff --git a/Cargo.lock b/Cargo.lock index 0f6671439..3c2a4e6b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,9 +78,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.48" +version = "0.4.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd" +checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970" dependencies = [ "compression-codecs", "compression-core", @@ -130,7 +130,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -257,25 +257,25 @@ dependencies = [ [[package]] name = "bon" -version = "3.10.1" +version = "3.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60eafe0d77c3a2fc292c1d1346c3041b33c0a108085a2afabf672b70f69dbbc9" +checksum = "214f5df094ce551a10a30ffb9c70243d61f121a3d985a6495933e181dee6a7d2" dependencies = [ "bon-macros", ] [[package]] name = "bon-macros" -version = "3.10.1" +version = "3.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0f9631d8aaaee112c41985d675ef269e02acbd4f33122836af4f0c5f699ff6" +checksum = "2706da6c749998cc555d04184909608956a69ad3f8ab1a076fbc867b4a3c3ce5" dependencies = [ "darling", "ident_case", "prettyplease 0.3.0", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -293,7 +293,7 @@ dependencies = [ "serde", "serde_json", "smoothutf8", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -319,7 +319,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -366,9 +366,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.7" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "jobserver", @@ -446,9 +446,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.43" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9" +checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe" dependencies = [ "compression-core", "flate2", @@ -456,9 +456,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" +checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a" [[package]] name = "concurrent-queue" @@ -507,9 +507,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -550,9 +550,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.5" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" +checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a" dependencies = [ "cmov", ] @@ -603,7 +603,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -614,7 +614,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -692,7 +692,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -703,9 +703,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "email-encoding" @@ -725,9 +725,9 @@ checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -801,9 +801,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.13" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "flate2" @@ -899,7 +899,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -991,9 +991,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.19" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" dependencies = [ "atomic-waker", "bytes", @@ -1149,9 +1149,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", @@ -1171,9 +1171,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -1182,21 +1182,22 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.10", + "webpki-roots 1.0.11", ] [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -1232,14 +1233,37 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_casemap" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a6f143e9349267b80ae071a6ec25d4797c45756d8b2671a8b24b7b25b81ab94" +dependencies = [ + "icu_casemap_data", + "icu_collections", + "icu_locale_core", + "icu_properties", + "icu_provider", + "potential_utf", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_casemap_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71a5c321208537711696af55bc2ab5dad939e4f6e0aef92dd6a2555deceedf3a" + [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", + "serde", "utf8_iter", "yoke", "zerofrom", @@ -1248,12 +1272,13 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", @@ -1261,9 +1286,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1275,15 +1300,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ "displaydoc", "icu_collections", @@ -1296,18 +1321,20 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -1363,9 +1390,9 @@ dependencies = [ [[package]] name = "inout" -version = "0.2.2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" dependencies = [ "block-padding", "hybrid-array", @@ -1404,9 +1431,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -1415,9 +1442,9 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "lettre" @@ -1443,20 +1470,20 @@ dependencies = [ "tokio", "tokio-rustls", "url", - "webpki-roots 1.0.10", + "webpki-roots 1.0.11", ] [[package]] name = "libc" -version = "0.2.189" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libredox" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "libc", ] @@ -1560,27 +1587,15 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "nom" version = "8.0.0" @@ -1684,9 +1699,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" [[package]] name = "pkg-config" @@ -1717,6 +1732,8 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -1746,7 +1763,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" dependencies = [ "proc-macro2", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1795,7 +1812,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -1803,21 +1820,21 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.18" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand 0.10.3", "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -1825,9 +1842,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" dependencies = [ "cfg_aliases", "libc", @@ -1876,9 +1893,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -1919,6 +1936,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rawzip" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9999039f74cd73c03158cddfe4362991703c74efe5ce5c8073026f80a5e48c" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1935,7 +1958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ "libredox", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -1955,7 +1978,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2026,7 +2049,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.10", + "webpki-roots 1.0.11", ] [[package]] @@ -2050,7 +2073,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -2146,9 +2169,9 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "schemars" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" dependencies = [ "dyn-clone", "ref-cast", @@ -2159,14 +2182,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2223,7 +2246,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2234,14 +2257,14 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -2263,9 +2286,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -2317,9 +2340,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", @@ -2362,9 +2385,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.1" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" dependencies = [ "serde", ] @@ -2449,9 +2472,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -2475,7 +2498,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2513,11 +2536,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -2533,39 +2556,41 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "tinybus" -version = "0.3.6" +version = "0.3.12" dependencies = [ "async-trait", "flate2", + "icu_casemap", "libc", + "rawzip", "serde", "serde_json", "sha2 0.10.9", "tar", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinybus-macros", "tokio", "toml", "tracing", + "unicode-normalization", "ureq", - "zip", ] [[package]] name = "tinybus-macros" -version = "0.3.6" +version = "0.3.12" dependencies = [ "proc-macro2", "quote", @@ -2574,7 +2599,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.3.6" +version = "0.3.12" dependencies = [ "async-trait", "serde", @@ -2586,7 +2611,7 @@ dependencies = [ [[package]] name = "tinychannels" -version = "0.1.45" +version = "0.2.0" dependencies = [ "anyhow", "async-imap", @@ -2605,7 +2630,7 @@ dependencies = [ "mail-parser", "parking_lot", "prost", - "rand 0.10.2", + "rand 0.10.3", "reqwest", "rusqlite", "rustls", @@ -2615,9 +2640,9 @@ dependencies = [ "serde-big-array", "serde_json", "sha1 0.11.0", - "sha2 0.11.0", + "sha2 0.11.1", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinybus", "tinychannels-bus", "tinychannels-conformance", @@ -2636,7 +2661,7 @@ dependencies = [ "urlencoding", "uuid", "wacore", - "webpki-roots 1.0.10", + "webpki-roots 1.0.11", "whatsapp-rust", "whatsapp-rust-tokio-transport", "whatsapp-rust-ureq-http-client", @@ -2645,20 +2670,31 @@ dependencies = [ [[package]] name = "tinychannels-bus" -version = "0.1.45" +version = "0.2.0" dependencies = [ "base64 0.23.1", "schemars", "serde", "serde_json", - "thiserror 2.0.20", - "tinystoragedrivers-types", + "thiserror 2.0.21", "toml", ] +[[package]] +name = "tinychannels-client" +version = "0.2.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "tinybus", + "tinychannels-bus", + "tokio", +] + [[package]] name = "tinychannels-conformance" -version = "0.1.45" +version = "0.2.0" dependencies = [ "async-trait", "serde_json", @@ -2669,7 +2705,7 @@ dependencies = [ [[package]] name = "tinychannels-module" -version = "0.1.45" +version = "0.2.0" dependencies = [ "anyhow", "async-trait", @@ -2680,7 +2716,7 @@ dependencies = [ "log", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "tempfile", "tinybus", "tinybus-module", @@ -2702,7 +2738,7 @@ dependencies = [ [[package]] name = "tinychannels-runtime" -version = "0.1.45" +version = "0.2.0" dependencies = [ "anyhow", "async-trait", @@ -2710,10 +2746,10 @@ dependencies = [ "hex", "hmac", "parking_lot", - "rand 0.10.2", + "rand 0.10.3", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "tinychannels-bus", "tokio", "tokio-util", @@ -2724,8 +2760,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-api" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "async-trait", "bytes", @@ -2738,8 +2774,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-blob" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "async-trait", "bytes", @@ -2752,8 +2788,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-bus" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "serde", "serde_json", @@ -2763,8 +2799,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-busproxy" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "async-trait", "bytes", @@ -2781,8 +2817,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-connect" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "rusqlite", "serde_json", @@ -2793,15 +2829,15 @@ dependencies = [ [[package]] name = "tinystoragedrivers-document" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "async-trait", "data-encoding", "rusqlite", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "tinystoragedrivers-api", "tinystoragedrivers-connect", "tinystoragedrivers-types", @@ -2811,8 +2847,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-kv" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "async-trait", "serde", @@ -2823,14 +2859,14 @@ dependencies = [ [[package]] name = "tinystoragedrivers-queue" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "async-trait", "data-encoding", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "tinystoragedrivers-api", "tinystoragedrivers-types", "tokio", @@ -2839,8 +2875,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-secrets" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "async-trait", "tinystoragedrivers-api", @@ -2850,8 +2886,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-testkit" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "async-trait", "bytes", @@ -2871,21 +2907,22 @@ dependencies = [ [[package]] name = "tinystoragedrivers-types" -version = "2.1.1" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97fbe6165a6edd9521659c7f6859700761" +version = "2.1.2" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -2902,14 +2939,14 @@ source = "git+https://github.com/tinyhumansai/tinyvoice?rev=cf6444424149bbc8dc23 dependencies = [ "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "tokio" -version = "1.53.3" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", @@ -2929,14 +2966,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "tokio-rustls" -version = "0.26.5" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -2972,9 +3009,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.19" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ "bytes", "futures-core", @@ -2997,7 +3034,7 @@ dependencies = [ "futures-sink", "http", "httparse", - "rand 0.10.2", + "rand 0.10.3", "ring", "rustls-pki-types", "simdutf8", @@ -3008,9 +3045,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.6+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap", "serde_core", @@ -3023,27 +3060,27 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tower" @@ -3140,7 +3177,7 @@ dependencies = [ "log", "rand 0.9.5", "sha1 0.10.7", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3154,19 +3191,13 @@ dependencies = [ "http", "httparse", "log", - "rand 0.10.2", + "rand 0.10.3", "rustls", "rustls-pki-types", "sha1 0.11.0", - "thiserror 2.0.20", + "thiserror 2.0.21", ] -[[package]] -name = "typed-path" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c" - [[package]] name = "typenum" version = "1.20.1" @@ -3185,15 +3216,24 @@ dependencies = [ [[package]] name = "unicase" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] [[package]] name = "universal-hash" @@ -3225,7 +3265,7 @@ dependencies = [ "rustls-pki-types", "ureq-proto", "utf8-zero", - "webpki-roots 1.0.10", + "webpki-roots 1.0.11", ] [[package]] @@ -3266,15 +3306,24 @@ checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" [[package]] name = "utf8_iter" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" [[package]] name = "uuid" -version = "1.28.0" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -3322,16 +3371,16 @@ dependencies = [ "log", "md5", "portable-atomic", - "rand 0.10.2", + "rand 0.10.3", "serde", "serde-big-array", "serde_json", "sha1 0.11.0", - "sha2 0.11.0", + "sha2 0.11.1", "smallvec", "smoothutf8", "subtle", - "thiserror 2.0.20", + "thiserror 2.0.21", "wacore-appstate", "wacore-binary", "wacore-derive", @@ -3356,8 +3405,8 @@ dependencies = [ "serde", "serde-big-array", "serde_json", - "sha2 0.11.0", - "thiserror 2.0.20", + "sha2 0.11.1", + "thiserror 2.0.21", "wacore-binary", "wacore-libsignal", "waproto", @@ -3389,7 +3438,7 @@ checksum = "53cba66b01df4bef777c3d131af434dea0f3c8a7da2e42d7fb74e11fd65fb094" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3413,12 +3462,12 @@ dependencies = [ "hmac", "log", "portable-atomic", - "rand 0.10.2", + "rand 0.10.3", "serde", "sha1 0.11.0", - "sha2 0.11.0", + "sha2 0.11.1", "subtle", - "thiserror 2.0.20", + "thiserror 2.0.21", "wacore-derive", "waproto", "x25519-dalek", @@ -3435,9 +3484,9 @@ dependencies = [ "bytes", "hkdf", "log", - "rand 0.10.2", - "sha2 0.11.0", - "thiserror 2.0.20", + "rand 0.10.3", + "sha2 0.11.1", + "thiserror 2.0.21", "wacore-binary", "wacore-libsignal", "waproto", @@ -3445,9 +3494,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -3464,7 +3513,7 @@ dependencies = [ "bytes", "heck", "serde", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -3484,9 +3533,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -3497,19 +3546,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.78" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3517,22 +3567,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -3552,9 +3602,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -3576,14 +3626,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.10", + "webpki-roots 1.0.11", ] [[package]] name = "webpki-roots" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] @@ -3610,12 +3660,12 @@ dependencies = [ "itoa", "log", "portable-atomic", - "rand 0.10.2", + "rand 0.10.3", "scopeguard", "serde", "serde_json", "smallvec", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "wacore", "wacore-binary", @@ -3641,7 +3691,7 @@ dependencies = [ "tokio-rustls", "tokio-websockets", "wacore", - "webpki-roots 1.0.10", + "webpki-roots 1.0.11", ] [[package]] @@ -3862,9 +3912,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3873,30 +3923,30 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.57" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.57" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -3920,15 +3970,15 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" [[package]] name = "zerotrie" @@ -3939,14 +3989,16 @@ dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -3954,27 +4006,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", -] - -[[package]] -name = "zip" -version = "9.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" -dependencies = [ - "crc32fast", - "flate2", - "indexmap", - "memchr", - "nix", - "typed-path", + "syn 3.0.9", ] [[package]] @@ -3991,18 +4029,18 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" -version = "0.13.3" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "7.3.0" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b" dependencies = [ "zstd-sys", ] diff --git a/Cargo.toml b/Cargo.toml index 843dd0812..a421b7fc3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,12 +2,14 @@ members = [ "crates/tinychannels-bus", "crates/tinychannels-module", + "crates/tinychannels-client", "crates/tinychannels-runtime", ] default-members = [ ".", "crates/tinychannels-bus", "crates/tinychannels-module", + "crates/tinychannels-client", "crates/tinychannels-runtime", ] # The bus is a submodule with its own workspace; it is a path dependency of the @@ -23,10 +25,19 @@ tinybus-module = { path = "vendor/tinybus/crates/tinybus-module" } # vendored checkout so the module and the storage client share one `Connection`. [patch."https://github.com/tinyhumansai/tinybus"] tinybus = { path = "vendor/tinybus/crates/tinybus" } +tinybus-module = { path = "vendor/tinybus/crates/tinybus-module" } + +[patch."https://github.com/senamakel/tinybus"] +tinybus = { path = "vendor/tinybus/crates/tinybus" } +tinybus-module = { path = "vendor/tinybus/crates/tinybus-module" } [workspace.package] # Releases update this sole workspace version before packaging every crate. -version = "0.1.45" +version = "0.2.0" + +[workspace.dependencies] +tinybus = { version = "=0.3.12", git = "https://github.com/senamakel/tinybus", tag = "v0.3.12", default-features = false } +tinybus-module = { version = "=0.3.12", git = "https://github.com/senamakel/tinybus", tag = "v0.3.12", default-features = false } [package] rust-version = "1.94" @@ -100,8 +111,8 @@ tinychannels-bus = { path = "crates/tinychannels-bus", features = ["storage"] } tinychannels-runtime = { path = "crates/tinychannels-runtime" } # Storage ports only: no driver crate and no database client. The host (or the # bus client in the module crate) supplies the backend. -tinystoragedrivers-api = { git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "bf1f0a97fbe6165a6edd9521659c7f6859700761" } -tinystoragedrivers-types = { git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "bf1f0a97fbe6165a6edd9521659c7f6859700761" } +tinystoragedrivers-api = { git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "6226b9484c8bd01c58741718cd89ba90a13a36bb" } +tinystoragedrivers-types = { git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "6226b9484c8bd01c58741718cd89ba90a13a36bb" } anyhow = "1" async-trait = "0.1" base64 = "0.23" @@ -152,7 +163,7 @@ bytes = { version = "1", optional = true } [dev-dependencies] bytes = "1" -tinybus = { path = "vendor/tinybus/crates/tinybus", features = ["modules"] } +tinybus = { workspace = true, features = ["modules"] } tinychannels-conformance = { path = "crates/tinychannels-conformance" } tinyvoice-bus = { git = "https://github.com/tinyhumansai/tinyvoice", rev = "cf6444424149bbc8dc2303bddbd9b366abb29933" } # Dual-declared: the optional dependency above belongs to the `lark` provider, @@ -163,5 +174,5 @@ tempfile = "3" toml = "1" wiremock = "0.6" tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "test-util"] } -tinystoragedrivers-testkit = { git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "bf1f0a97fbe6165a6edd9521659c7f6859700761" } -tinystoragedrivers-document = { git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "bf1f0a97fbe6165a6edd9521659c7f6859700761" } +tinystoragedrivers-testkit = { git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "6226b9484c8bd01c58741718cd89ba90a13a36bb" } +tinystoragedrivers-document = { git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "6226b9484c8bd01c58741718cd89ba90a13a36bb" } diff --git a/README.md b/README.md index 41ed661d2..2e19fbf22 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,9 @@ cargo test --features email,lark algorithms, history/context processing, configuration preparation and text segmentation. It re-exports the same bus DTOs. - `crates/tinychannels-module/` is the loadable TinyBus module. +- `crates/tinychannels-client/` is the host-side typed API for confidential + module calls and identity-scoped runtime leases. It binds the attested module + once, then keeps reservation and object calls on that same peer. - The module keeps its original `StartChannel`, `StopChannel`, `SendMessage`, `ListChannels` and `ChannelStatus` members, and adds relay reservation/start/ stop/status/send plus `Shutdown`, typed `SendChannelIntent`, and send-only @@ -189,6 +192,36 @@ cargo test --features email,lark the host service boundary; `src/routes.rs` and `src/runtime.rs` hold portable runtime helpers. +Hosts construct `TinyChannelsClient` from their `ModuleClient` and ask it for a +strictly attested runtime driver. The host then leases one identity-scoped +module runtime, supplies the callback object for that identity, uses the typed +object returned by the lease, and closes the lease during sign-out or shutdown: + +```rust,ignore +let client = TinyChannelsClient::new(module_client); +let driver = client.runtime_driver().await?; +let lease = runtime + .lease( + driver, + |reservation, _object| make_identity_callback(reservation), + retry_policy, + report_lease_event, + ) + .await?; + +let channels = lease.object(); +channels + .describe_configured_channels(config, proxy) + .await?; + +lease.close().await?; +``` + +The host owns `ModuleClient`, `ModuleRuntime`, callback registration, retry +policy, configuration and credentials. A lease's object methods remain bound to +the peer selected by `runtime_driver`; closing the lease stops that reservation +and releases its callback object without shutting down the shared module. + The [pure-contract relocation specification](docs/spec/pure-contract-relocation.md) records extension-trait source migrations and subsequent module-operation work. diff --git a/crates/tinychannels-bus/Cargo.toml b/crates/tinychannels-bus/Cargo.toml index be4a91bd9..3c2b03fa8 100644 --- a/crates/tinychannels-bus/Cargo.toml +++ b/crates/tinychannels-bus/Cargo.toml @@ -24,14 +24,11 @@ schemars = { version = "1", features = ["derive"] } thiserror = "2" # Serialization only: preserves PassthroughForward.body's existing Base64 wire encoding. base64 = "0.23" -# Optional (feature `storage`, off by default): pure storage collection specs. -tinystoragedrivers-types = { git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "bf1f0a97fbe6165a6edd9521659c7f6859700761", optional = true } [features] default = [] -# `storage_schema()` and its migration steps. The default dependency closure of -# this crate is unchanged without it. -storage = ["dep:tinystoragedrivers-types"] +# Retained for callers that previously enabled schema declarations by feature. +storage = [] [dev-dependencies] toml = "1" diff --git a/crates/tinychannels-bus/README.md b/crates/tinychannels-bus/README.md index 078a5eba5..df10eecf8 100644 --- a/crates/tinychannels-bus/README.md +++ b/crates/tinychannels-bus/README.md @@ -1,11 +1,25 @@ # tinychannels-bus Serialized channel vocabulary, configuration/schema declarations, interface -names, relay frames and structured errors. Normal dependencies are Serde, -JSON/schema/error derivation, and Base64 used only by the existing -`PassthroughForward.body` custom serializer (`bodyB64`). This crate has no -provider callbacks, scheduler, crypto, entropy, stateful locks, transport, -session derivation or delivery algorithms. +names, relay frames and structured errors. It also declares the async provider +trait and host-facing call shapes without implementing a provider or executing +host callbacks. `context` contains shared DTOs and pure per-sender history +helpers over an in-memory `Arc>`. The optional `storage` feature +exposes serde-only collection declarations through `storage::collections()`. +The storage implementation adapts these DTOs to TinyStorageDrivers types at +the point where it provisions collections. + +The default normal/build closure contains Serde/JSON/schema/error derivation, +Base64 for the existing +`PassthroughForward.body` custom serializer (`bodyB64`), `anyhow`, `async-trait`, +and Tokio's `sync` channel type. The optional `storage` feature adds only +`tinystoragedrivers-types` for collection declarations. It contains no TinyBus, +storage client/runtime or backend driver, HTTP/socket transport, database, +task executor, scheduler, crypto/entropy implementation, or concrete provider. + +Storage collection declarations and serialized record DTOs remain contract +data here. The `tinychannels` implementation owns owner-local migrations, +legacy decoding, and conversions between wire records and stored DTOs. DTOs are defined once here. `tinychannels-runtime` and `tinychannels` re-export these same Rust types. Hosts should name wire values through this crate and @@ -13,9 +27,13 @@ execute behavior through the compiled module. The root library compatibility facade is for existing implementation consumers, not a host dependency shortcut. `python3 scripts/check-bus-purity.py --offline` audits independent default and -all-feature normal/build dependency closures and rejects runtime source seams. -The approved 21-package closure includes Base64 solely for serialization; -crypto and UUID generation remain implementation dependencies. +all-feature normal/build closures. It rejects storage clients, runtime buses, +and engine drivers from this contract boundary. Its +source guard allows async trait signatures, Tokio sync carriers, in-memory +history locking, and static metadata while rejecting concrete I/O, task, +database, and provider execution. The CI feature-test lane runs this gate and +its Python negative controls. Crypto and UUID generation remain implementation +dependencies. The original module and host callback names, method arities, JSON field names, optional fields and persisted configuration values are unchanged. See the diff --git a/crates/tinychannels-bus/src/channel/content.rs b/crates/tinychannels-bus/src/channel/content.rs new file mode 100644 index 000000000..253d49a2d --- /dev/null +++ b/crates/tinychannels-bus/src/channel/content.rs @@ -0,0 +1,6 @@ +//! Compatibility paths to the authoritative channel content definitions. + +pub use super::envelope::{ + ContentPart, VoiceNote, VoiceTranscript, VoiceTranscriptSource, VoiceTranscriptState, +}; +pub use super::intent::{OutboundContentPart, OutboundVoice}; diff --git a/crates/tinychannels-bus/src/channel/content_tests.rs b/crates/tinychannels-bus/src/channel/content_tests.rs new file mode 100644 index 000000000..c1c36111c --- /dev/null +++ b/crates/tinychannels-bus/src/channel/content_tests.rs @@ -0,0 +1,46 @@ +use super::super::envelope::MediaKind; +use super::*; + +#[test] +fn content_parts_round_trip_typed_voice_and_media() { + let part = ContentPart::VoiceNote { + voice_note: VoiceNote { + media: MediaReference { + opaque_id: Some("note-1".into()), + file_name: Some("note.ogg".into()), + kind: MediaKind::Audio, + ..Default::default() + }, + transcript: Some(VoiceTranscript { + text: "hello".into(), + language: Some("en".into()), + state: VoiceTranscriptState::Complete, + source: VoiceTranscriptSource::HostStt, + }), + ..Default::default() + }, + }; + let json = serde_json::to_value(&part).unwrap(); + assert_eq!( + serde_json::from_value::(json.clone()).unwrap(), + part + ); + assert_eq!(json["kind"], "voice_note"); + assert_eq!(json["voice_note"]["media"]["fileName"], "note.ogg"); +} + +#[test] +fn outbound_voice_keeps_media_metadata_typed() { + let voice = OutboundContentPart::Voice { + voice: OutboundVoice::Media { + media: MediaReference::default(), + codec: Some("opus".into()), + duration_ms: Some(800), + }, + }; + assert_eq!( + serde_json::from_slice::(&serde_json::to_vec(&voice).unwrap()) + .unwrap(), + voice + ); +} diff --git a/crates/tinychannels-bus/src/channel/event.rs b/crates/tinychannels-bus/src/channel/event.rs index 86c801093..1c2aa1b6d 100644 --- a/crates/tinychannels-bus/src/channel/event.rs +++ b/crates/tinychannels-bus/src/channel/event.rs @@ -143,3 +143,7 @@ pub struct ChannelReaction { /// Provider-specific stable custom-emoji identity. pub custom_emoji_id: Option, } + +#[cfg(test)] +#[path = "event_tests.rs"] +mod tests; diff --git a/crates/tinychannels-bus/src/channel/event_tests.rs b/crates/tinychannels-bus/src/channel/event_tests.rs new file mode 100644 index 000000000..54d30c6da --- /dev/null +++ b/crates/tinychannels-bus/src/channel/event_tests.rs @@ -0,0 +1,125 @@ +use super::*; + +fn event_with_payload(event: ChannelEventKind) -> ChannelEvent { + ChannelEvent { + version: 2, + event_id: "event-1".into(), + source_event_id: Some("provider-event-1".into()), + source_timestamp: Some(12), + channel: ChannelRef { + id: "telegram".into(), + account_id: None, + }, + conversation: Some(ConversationRef { + id: "chat-1".into(), + ..Default::default() + }), + event, + } +} + +#[test] +fn provider_event_retains_target_message_and_event_identity() { + let event = event_with_payload(ChannelEventKind::ReactionAdded { + message_id: "message-2".into(), + provider_message_id: Some("provider-message-2".into()), + reaction: ChannelReaction { + value: "👍".into(), + custom_emoji_id: None, + }, + actor: SenderRef { + id: "user-3".into(), + ..Default::default() + }, + }); + let value = serde_json::to_value(&event).unwrap(); + assert_eq!(value["version"], 2); + assert_eq!(value["type"], "reaction_added"); + assert_eq!(value["eventId"], "event-1"); + assert_eq!(value["sourceEventId"], "provider-event-1"); + assert_eq!(value["sourceTimestamp"], 12); + assert_eq!(value["messageId"], "message-2"); + assert_eq!(value["providerMessageId"], "provider-message-2"); + assert_eq!(value["actor"]["id"], "user-3"); + assert_eq!( + serde_json::from_value::(value).unwrap(), + event + ); +} + +#[test] +fn read_receipt_identity_and_custom_emoji_survive_round_trip() { + let mut event = event_with_payload(ChannelEventKind::ReadReceipt { + message_id: "message-1".into(), + actor: Some(SenderRef { + id: "reader-1".into(), + ..Default::default() + }), + timestamp: Some(42), + }); + event.event_id = "event-read".into(); + event.conversation = None; + event.source_timestamp = Some(43); + let encoded = serde_json::to_value(&event).unwrap(); + assert_eq!(encoded["actor"]["id"], "reader-1"); + assert_eq!(encoded["timestamp"], 42); + assert_eq!(encoded["sourceTimestamp"], 43); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + event + ); + + let reaction = ChannelReaction { + value: "fire".into(), + custom_emoji_id: Some("provider-emoji-9".into()), + }; + let encoded = serde_json::to_value(&reaction).unwrap(); + assert_eq!(encoded["customEmojiId"], "provider-emoji-9"); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + reaction + ); + + let unicode = serde_json::json!({"value":"👍"}); + assert_eq!( + serde_json::from_value::(unicode).unwrap(), + ChannelReaction { + value: "👍".into(), + custom_emoji_id: None, + } + ); +} + +#[test] +fn event_wire_preserves_absent_and_zero_timestamps_and_false_states() { + for timestamp in [None, Some(0)] { + let mut event = event_with_payload(ChannelEventKind::ReadReceipt { + message_id: "message-1".into(), + actor: None, + timestamp, + }); + event.source_timestamp = timestamp; + let value = serde_json::to_value(&event).unwrap(); + assert_eq!(value["timestamp"], serde_json::json!(timestamp)); + assert_eq!(value["sourceTimestamp"], serde_json::json!(timestamp)); + assert_eq!( + serde_json::from_value::(value).unwrap(), + event + ); + } + let event = event_with_payload(ChannelEventKind::Typing { + actor: SenderRef { + id: "user-3".into(), + ..Default::default() + }, + active: false, + expires_at: Some(0), + }); + let value = serde_json::to_value(&event).unwrap(); + assert_eq!(value["active"], false); + assert_eq!(value["expiresAt"], 0); + assert_eq!( + serde_json::from_value::(value).unwrap(), + event + ); +} diff --git a/crates/tinychannels-bus/src/channel/intent.rs b/crates/tinychannels-bus/src/channel/intent.rs index 537980219..3a057ed54 100644 --- a/crates/tinychannels-bus/src/channel/intent.rs +++ b/crates/tinychannels-bus/src/channel/intent.rs @@ -1,5 +1,8 @@ //! Outbound channel intent types. +// Preserve the earlier Rust import path without declaring a second outcome type. +pub use super::receipt::ChannelSendOutcome; + use crate::channel::envelope::{ContentPart, MediaReference}; use crate::channel::types::{ChannelRef, ConversationRef}; use schemars::JsonSchema; diff --git a/crates/tinychannels-bus/src/channel/mod.rs b/crates/tinychannels-bus/src/channel/mod.rs index 0431c5015..e27a97eda 100644 --- a/crates/tinychannels-bus/src/channel/mod.rs +++ b/crates/tinychannels-bus/src/channel/mod.rs @@ -2,6 +2,8 @@ pub mod adapter; pub mod capabilities; +/// Compatibility paths to the canonical content vocabulary. +pub mod content; pub mod envelope; pub mod error; pub mod event; diff --git a/crates/tinychannels-bus/src/controllers/definitions.rs b/crates/tinychannels-bus/src/controllers/definitions.rs index 6223924e4..faa6c3fbf 100644 --- a/crates/tinychannels-bus/src/controllers/definitions.rs +++ b/crates/tinychannels-bus/src/controllers/definitions.rs @@ -4,10 +4,11 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// Which authentication mode a channel connection uses. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub enum ChannelAuthMode { /// User provides an API key or access token. #[serde(rename = "api_key")] + #[default] ApiKey, /// User provides a bot token (e.g. Telegram BotFather token). #[serde(rename = "bot_token")] diff --git a/crates/tinychannels-bus/src/lib.rs b/crates/tinychannels-bus/src/lib.rs index d907bbab6..703122173 100644 --- a/crates/tinychannels-bus/src/lib.rs +++ b/crates/tinychannels-bus/src/lib.rs @@ -36,14 +36,16 @@ pub mod module_supervision; pub mod module_web_output; pub mod names; pub mod relay; -#[cfg(feature = "storage")] pub mod storage; +pub mod storage_names; #[allow( deprecated, reason = "v21 DTO definitions and adapters remain for one release" )] pub mod traits; pub mod version; +/// Compatibility paths to the canonical host voice vocabulary. +pub mod voice; pub mod web; pub use capabilities::{ AccountCapabilities, ActionCapabilities, AddressingCapabilities, diff --git a/crates/tinychannels-bus/src/module_inbound.rs b/crates/tinychannels-bus/src/module_inbound.rs index e3592fd4f..92f096d85 100644 --- a/crates/tinychannels-bus/src/module_inbound.rs +++ b/crates/tinychannels-bus/src/module_inbound.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; #[serde(tag = "kind", rename_all = "snake_case")] pub enum ChannelInboundSource { Legacy { - message: ChannelMessage, + message: Box, #[serde(default)] envelope: Option, }, diff --git a/crates/tinychannels-bus/src/module_inbound_tests.rs b/crates/tinychannels-bus/src/module_inbound_tests.rs index c6af5b497..d5284253e 100644 --- a/crates/tinychannels-bus/src/module_inbound_tests.rs +++ b/crates/tinychannels-bus/src/module_inbound_tests.rs @@ -7,7 +7,7 @@ fn inbound_vocabulary_preserves_optional_sender_and_existing_message_wire_shape( assert_eq!(serde_json::to_value(request).unwrap(), wire); let request = PrepareChannelInboundRequest { source: ChannelInboundSource::Legacy { - message: ChannelMessage::default(), + message: Box::new(ChannelMessage::default()), envelope: None, }, namespace: "tenant".into(), diff --git a/crates/tinychannels-bus/src/module_interaction.rs b/crates/tinychannels-bus/src/module_interaction.rs index 0d3cb17e5..c21c3801f 100644 --- a/crates/tinychannels-bus/src/module_interaction.rs +++ b/crates/tinychannels-bus/src/module_interaction.rs @@ -41,7 +41,7 @@ pub enum ChannelInteraction { /// Full-scope draft creation or edit through a configured v2 adapter. UpsertDraft { previous: Option, - intent: crate::channel::ChannelOutboundIntent, + intent: Box, }, UpdateDraft { recipient: String, @@ -62,8 +62,8 @@ pub enum ChannelInteraction { remove: bool, }, EditMessage { - receipt: crate::channel::MessageReceipt, - intent: crate::channel::ChannelOutboundIntent, + receipt: Box, + intent: Box, }, DeleteMessage { receipt: crate::channel::MessageReceipt, diff --git a/crates/tinychannels-bus/src/module_interaction_tests.rs b/crates/tinychannels-bus/src/module_interaction_tests.rs index ba06c800a..6b8a94dc1 100644 --- a/crates/tinychannels-bus/src/module_interaction_tests.rs +++ b/crates/tinychannels-bus/src/module_interaction_tests.rs @@ -90,7 +90,7 @@ fn typed_draft_interaction_round_trips_full_account_and_topic_scope() { platform_message_ids: vec!["91".into()], ..Default::default() }), - intent: ChannelOutboundIntent { + intent: Box::new(ChannelOutboundIntent { idempotency_key: "draft-op".into(), channel_id: "telegram".into(), conversation_id: "-100".into(), @@ -107,11 +107,16 @@ fn typed_draft_interaction_round_trips_full_account_and_topic_scope() { text: "progress".into(), }, ..Default::default() - }, + }), }, }; + let ChannelInteraction::UpsertDraft { intent, .. } = &request.interaction else { + panic!("wrong interaction") + }; + let expected_intent = serde_json::to_value(intent.as_ref()).unwrap(); let value = serde_json::to_value(&request).unwrap(); assert_eq!(value["interaction"]["operation"], "upsert_draft"); + assert_eq!(value["interaction"]["request"]["intent"], expected_intent); let decoded: ChannelInteractionRequest = serde_json::from_value(value).unwrap(); let ChannelInteraction::UpsertDraft { previous, intent } = decoded.interaction else { panic!("wrong interaction") @@ -236,3 +241,83 @@ fn outbound_voice_uses_a_typed_media_reference_without_legacy_payload_fields() { .unwrap(); assert!(without.get("waveform").is_none()); } + +#[test] +fn configured_interaction_v2_retains_legacy_provider_facts() { + let info = ChannelInteractionInfoV2 { + capabilities: ChannelCapabilities { + account: crate::AccountCapabilities { + proactive_target: Some("chat-7".into()), + ..Default::default() + }, + reactions: true, + progressive_edits: true, + ..Default::default() + }, + }; + let value = serde_json::to_value(&info).unwrap(); + assert_eq!(value["capabilities"]["version"], 2); + let restored: ChannelInteractionInfoV2 = serde_json::from_value(value).unwrap(); + assert_eq!( + restored.capabilities.account.proactive_target.as_deref(), + Some("chat-7") + ); + assert!(restored.capabilities.reactions); + assert!(restored.capabilities.progressive_edits); + let legacy = LegacyChannelCapabilities::from(&restored.capabilities); + assert!(legacy.progressive_edits); +} + +#[test] +fn boxed_edit_receipt_preserves_full_intent_and_provider_delivery_identity() { + let receipt = crate::channel::MessageReceipt { + channel: Some(crate::channel::ChannelRef { + id: "telegram".into(), + account_id: Some("telegram:account-a".into()), + }), + conversation: Some(crate::channel::ConversationRef { + id: "chat".into(), + topic_id: Some("topic".into()), + ..Default::default() + }), + primary_platform_message_id: Some("message-1".into()), + platform_message_ids: vec!["message-1".into()], + ..Default::default() + }; + let expected_receipt = serde_json::to_value(&receipt).unwrap(); + let intent = ChannelOutboundIntent { + idempotency_key: "edit-1".into(), + channel_id: "telegram".into(), + conversation_id: "chat".into(), + channel_ref: receipt.channel.clone(), + conversation_ref: receipt.conversation.clone(), + reply_to_id: Some("parent-1".into()), + payload: crate::OutboundPayload::Text { + text: "edited response".into(), + }, + ..Default::default() + }; + let expected_intent = serde_json::to_value(&intent).unwrap(); + let wire = serde_json::to_value(ChannelInteraction::EditMessage { + receipt: Box::new(receipt), + intent: Box::new(intent), + }) + .unwrap(); + assert_eq!(wire["operation"], "edit_message"); + assert_eq!(wire["request"]["receipt"], expected_receipt); + assert_eq!(wire["request"]["intent"], expected_intent); + let restored: ChannelInteraction = serde_json::from_value(wire).unwrap(); + let ChannelInteraction::EditMessage { receipt, intent } = restored else { + panic!("wrong interaction") + }; + assert_eq!( + receipt.primary_platform_message_id.as_deref(), + Some("message-1") + ); + assert_eq!(intent.reply_to_id.as_deref(), Some("parent-1")); + assert_eq!( + intent.conversation_ref.unwrap().topic_id.as_deref(), + Some("topic") + ); + assert_eq!(intent.idempotency_key, "edit-1"); +} diff --git a/crates/tinychannels-bus/src/module_remote.rs b/crates/tinychannels-bus/src/module_remote.rs index e5b1552c3..ceeccbc43 100644 --- a/crates/tinychannels-bus/src/module_remote.rs +++ b/crates/tinychannels-bus/src/module_remote.rs @@ -54,7 +54,7 @@ pub struct RemoteSessionScope { pub enum RemoteOperation { Execute { workspace_dir: String, - message: crate::ChannelMessage, + message: Box, route: RemoteRoute, history_len: usize, session_scope: Option, diff --git a/crates/tinychannels-bus/src/module_runtime.rs b/crates/tinychannels-bus/src/module_runtime.rs index 7b60fde83..f420d7bf0 100644 --- a/crates/tinychannels-bus/src/module_runtime.rs +++ b/crates/tinychannels-bus/src/module_runtime.rs @@ -36,6 +36,35 @@ pub struct ChannelRuntimeReservation { pub host_object_path: String, } +impl ChannelRuntimeReservation { + /// Check that the opaque handle and both private callback paths agree with + /// the TinyChannels bus contract. + /// + /// The error is deliberately content-free so callers can report it without + /// exposing a supplied handle or object path. + pub fn validate(&self) -> crate::Result<()> { + const INVALID: &str = "invalid channel runtime reservation"; + + if self.handle.len() != 32 + || !self + .handle + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(crate::TinyChannelsError::new(INVALID)); + } + + let runtime_suffix = format!("/runtime_{}", self.handle); + if self.object_path != format!("{}{}", crate::OBJECT_PATH, runtime_suffix) + || self.host_object_path != format!("{}{}", crate::HOST_OBJECT_PATH, runtime_suffix) + { + return Err(crate::TinyChannelsError::new(INVALID)); + } + + Ok(()) + } +} + /// Opt-in turn dispatch for a reserved runtime. Legacy `StartRuntime` retains /// serial `DeliverInbound` envelope notifications; this policy uses `ProcessInbound` /// callbacks with module-owned bounded concurrency and stop/drain ownership. @@ -82,7 +111,7 @@ pub enum ChannelMessagePreparation { /// Preserve the existing typed channel send and its idempotency algorithm. Typed(crate::SendMessage), /// Render an already prepared intent without changing its key or durability. - Intent(crate::ChannelOutboundIntent), + Intent(Box), } /// An intent and the corresponding legacy backend JSON prepared inside the module. diff --git a/crates/tinychannels-bus/src/module_runtime_tests.rs b/crates/tinychannels-bus/src/module_runtime_tests.rs index 55eec38fe..c3cf29429 100644 --- a/crates/tinychannels-bus/src/module_runtime_tests.rs +++ b/crates/tinychannels-bus/src/module_runtime_tests.rs @@ -22,6 +22,85 @@ fn runtime_reservation_rejects_unrecognized_identity_fields() { ); } +#[test] +fn runtime_reservation_validation_accepts_only_its_exact_private_paths() { + const HANDLE: &str = "0123456789abcdef0123456789abcdef"; + let reservation = ChannelRuntimeReservation { + handle: HANDLE.into(), + object_path: format!("{}/runtime_{HANDLE}", crate::OBJECT_PATH), + host_object_path: format!("{}/runtime_{HANDLE}", crate::HOST_OBJECT_PATH), + }; + + assert!(reservation.validate().is_ok()); +} + +#[test] +fn runtime_reservation_validation_rejects_each_bad_identity_component_sanitely() { + const HANDLE: &str = "0123456789abcdef0123456789abcdef"; + let reservation = + |handle: String, object_path: String, host_object_path: String| ChannelRuntimeReservation { + handle, + object_path, + host_object_path, + }; + let module_path = |handle: &str| format!("{}/runtime_{handle}", crate::OBJECT_PATH); + let host_path = |handle: &str| format!("{}/runtime_{handle}", crate::HOST_OBJECT_PATH); + let cases = [ + // Length, uppercase ASCII, and non-hex ASCII are independent handle failures; + // each path still agrees with its handle so path validation does not mask them. + reservation( + HANDLE[..31].into(), + module_path(&HANDLE[..31]), + host_path(&HANDLE[..31]), + ), + { + let handle = "0123456789ABCDEf0123456789abcdef"; + reservation(handle.into(), module_path(handle), host_path(handle)) + }, + { + let handle = "g123456789abcdef0123456789abcdef"; + reservation(handle.into(), module_path(handle), host_path(handle)) + }, + // The handle is valid and each path mismatch is isolated. + reservation( + HANDLE.into(), + format!("{}/runtime_object-path-supplied-marker", crate::OBJECT_PATH), + host_path(HANDLE), + ), + reservation( + HANDLE.into(), + module_path(HANDLE), + format!( + "{}/runtime_host-path-supplied-marker", + crate::HOST_OBJECT_PATH + ), + ), + ]; + + let mut sanitized_reason = None; + for invalid in cases { + let error = invalid + .validate() + .expect_err("invalid reservation identity must be rejected"); + assert_eq!( + error, + crate::TinyChannelsError::new("invalid channel runtime reservation") + ); + let reason = error.to_string(); + assert!(!reason.contains(&invalid.handle)); + assert!(!reason.contains("object-path-supplied-marker")); + assert!(!reason.contains("host-path-supplied-marker")); + if let Some(expected) = &sanitized_reason { + assert_eq!( + &reason, expected, + "all malformed identities share one closed reason" + ); + } else { + sanitized_reason = Some(reason); + } + } +} + #[test] fn configured_catalog_and_message_preparation_keep_wire_vocabulary() { let catalog = serde_json::json!({ diff --git a/crates/tinychannels-bus/src/names_tests.rs b/crates/tinychannels-bus/src/names_tests.rs index 0c42be2a9..bf910e071 100644 --- a/crates/tinychannels-bus/src/names_tests.rs +++ b/crates/tinychannels-bus/src/names_tests.rs @@ -16,8 +16,10 @@ fn every_declared_member_appears_in_its_method_table() { methods::CHANNEL_STATUS, methods::RESERVE_RELAY, methods::START_RELAY, + methods::START_RELAY_V2, methods::STOP_RELAY, methods::RELAY_STATUS, + methods::RELAY_CAPABILITIES_V2, methods::SEND_RELAY_INTENT, methods::SHUTDOWN, methods::SEND_CHANNEL_INTENT, @@ -44,6 +46,13 @@ fn every_declared_member_appears_in_its_method_table() { methods::PREPARE_CHANNEL_CONTROL, methods::START_RUNTIME_WITH_DISPATCH, methods::PREPARE_RUNTIME_COMMAND, + methods::BEGIN_MEDIA_UPLOAD, + methods::APPEND_MEDIA_UPLOAD_CHUNK, + methods::FINISH_MEDIA_UPLOAD, + methods::ABORT_MEDIA_UPLOAD, + methods::BEGIN_MEDIA_READ, + methods::READ_MEDIA_CHUNK, + methods::ABORT_MEDIA_READ, methods::BEGIN_WEB_TURN, methods::PROJECT_WEB_OUTPUT, methods::END_WEB_TURN, @@ -58,6 +67,7 @@ fn every_declared_member_appears_in_its_method_table() { host_methods::TRANSCRIBE_VOICE, host_methods::SYNTHESIZE_SPEECH, host_methods::STORE_VOICE_MEDIA, + host_methods::READ_VOICE_MEDIA, host_methods::GET_VOICE_REPLY_POLICY, host_methods::PUBLISH_EVENT, host_methods::DISPATCH_CONTROLLER_BACKEND, @@ -127,10 +137,16 @@ fn additive_method_table_preserves_legacy_order_and_lists_all_new_members() { /// A duplicate silently shadows one member with another's handler. #[test] fn member_names_are_unique_within_each_object() { - let mut sorted = METHODS.to_vec(); - sorted.sort_unstable(); - sorted.dedup(); - assert_eq!(sorted.len(), METHODS.len(), "duplicate member in METHODS"); + for names in [&METHODS[..], &HOST_METHODS[..]] { + let mut sorted = names.to_vec(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!( + sorted.len(), + names.len(), + "duplicate member in method table" + ); + } } /// The two objects must not collide: the module serves one, the host the diff --git a/crates/tinychannels-bus/src/storage/migrate_parse_tests.rs b/crates/tinychannels-bus/src/storage/migrate_parse_tests.rs deleted file mode 100644 index d3b6444e9..000000000 --- a/crates/tinychannels-bus/src/storage/migrate_parse_tests.rs +++ /dev/null @@ -1,20 +0,0 @@ -use super::parse_rfc3339_ms; - -#[test] -fn rfc3339_timestamps_parse_to_unix_milliseconds() { - assert_eq!(parse_rfc3339_ms("1970-01-01T00:00:00Z"), Some(0)); - assert_eq!( - parse_rfc3339_ms("2026-09-29T15:04:00Z"), - Some(1_790_694_240_000) - ); - assert_eq!( - parse_rfc3339_ms("2026-09-29T17:04:00.250+02:00"), - Some(1_790_694_240_250) - ); - assert_eq!( - parse_rfc3339_ms("2024-02-29T00:00:00-01:00"), - Some(1_709_168_400_000) - ); - assert_eq!(parse_rfc3339_ms("not a time"), None); - assert_eq!(parse_rfc3339_ms("2026-13-01T00:00:00Z"), None); -} diff --git a/crates/tinychannels-bus/src/storage/mod.rs b/crates/tinychannels-bus/src/storage/mod.rs index 8e8e1cb39..e8998877b 100644 --- a/crates/tinychannels-bus/src/storage/mod.rs +++ b/crates/tinychannels-bus/src/storage/mod.rs @@ -9,4 +9,5 @@ pub mod schema; pub use records::ChatBinding; pub use schema::collections; +pub use schema::{CollectionSpec, IndexSpec, SearchSpec}; pub use schema::{MEDIA_BLOB_PREFIX, MEDIA_METADATA, media_metadata_spec}; diff --git a/crates/tinychannels-bus/src/storage/records.rs b/crates/tinychannels-bus/src/storage/records.rs index 3b9b6f26b..358931e80 100644 --- a/crates/tinychannels-bus/src/storage/records.rs +++ b/crates/tinychannels-bus/src/storage/records.rs @@ -1,18 +1,10 @@ -//! The stored (v2) form of the delivery records. +//! Serialized record DTOs stored by TinyChannels implementations. //! -//! [`QueuedDelivery`] and [`DeliveryTerminalResult`] are bus wire types: they -//! are camelCase, carry `*UnixMs` times and travel unchanged between a host and -//! the module. What the document store holds is a separate shape: snake_case -//! bodies, one `*_ms` field per instant, no field that repeats the document id. -//! These DTOs are the only place the two meet. +//! These types describe the persisted document shapes. Conversion between +//! channel messages and these records belongs to the `tinychannels` crate. -use crate::channel::{ - ChannelOutboundIntent, ChannelRef, ConversationRef, DeliveryDurability, MessageReceipt, - OutboundPayload, -}; -use crate::module_delivery::{ - DeliveryQueueEntry as QueuedDelivery, DeliveryRecoveryState, DeliveryTerminalResult, -}; +use crate::channel::{DeliveryDurability, MessageReceipt, OutboundPayload}; +use crate::module_delivery::DeliveryRecoveryState; use serde::{Deserialize, Serialize}; /// A chat's binding to a host conversation thread. When it was last bound is @@ -27,27 +19,17 @@ pub struct ChatBinding { pub title: Option, } -/// A wire millisecond count as the stored `i64` (saturating, never negative). -pub fn to_stored_ms(ms: u64) -> i64 { - i64::try_from(ms).unwrap_or(i64::MAX) -} - -/// A stored millisecond count as the wire `u64` (negative counts read as 0). -pub fn from_stored_ms(ms: i64) -> u64 { - u64::try_from(ms).unwrap_or(0) -} - -/// The outbound intent as stored. The payload is already snake_case on the -/// wire, so only the five camelCase fields differ. +/// The outbound intent's persisted snake_case shape. Payload list keys retain +/// their historical storage names while typed v2 content keeps its wire shape. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct StoredIntent { pub idempotency_key: String, pub channel_id: String, pub conversation_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub channel_ref: Option, + pub channel_ref: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub conversation_ref: Option, + pub conversation_ref: Option, pub reply_to_id: Option, pub thread_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -57,40 +39,6 @@ pub struct StoredIntent { pub payload: OutboundPayload, } -impl From<&ChannelOutboundIntent> for StoredIntent { - fn from(intent: &ChannelOutboundIntent) -> Self { - Self { - idempotency_key: intent.idempotency_key.clone(), - channel_id: intent.channel_id.clone(), - conversation_id: intent.conversation_id.clone(), - channel_ref: intent.channel_ref.clone(), - conversation_ref: intent.conversation_ref.clone(), - reply_to_id: intent.reply_to_id.clone(), - thread_id: intent.thread_id.clone(), - turn_id: intent.turn_id.clone(), - durability: intent.durability, - payload: intent.payload.clone(), - } - } -} - -impl From for ChannelOutboundIntent { - fn from(stored: StoredIntent) -> Self { - Self { - idempotency_key: stored.idempotency_key, - channel_id: stored.channel_id, - conversation_id: stored.conversation_id, - channel_ref: stored.channel_ref, - conversation_ref: stored.conversation_ref, - reply_to_id: stored.reply_to_id, - thread_id: stored.thread_id, - turn_id: stored.turn_id, - durability: stored.durability, - payload: stored.payload, - } - } -} - /// A pending or failed delivery as stored. The entry id is the intent's /// idempotency key, so the body does not repeat it. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -111,41 +59,9 @@ pub struct StoredDelivery { pub partial_receipt: Option, } -impl From<&QueuedDelivery> for StoredDelivery { - fn from(entry: &QueuedDelivery) -> Self { - Self { - intent: StoredIntent::from(&entry.intent), - enqueued_ms: to_stored_ms(entry.enqueued_at_unix_ms), - retry_count: entry.retry_count, - last_attempt_ms: entry.last_attempt_at_unix_ms.map(to_stored_ms), - last_error: entry.last_error.clone(), - platform_send_started_ms: entry.platform_send_started_at_unix_ms.map(to_stored_ms), - recovery_state: entry.recovery_state, - partial_receipt: entry.partial_receipt.clone(), - } - } -} - -impl From for QueuedDelivery { - fn from(stored: StoredDelivery) -> Self { - let intent = ChannelOutboundIntent::from(stored.intent); - Self { - id: intent.idempotency_key.clone(), - intent, - enqueued_at_unix_ms: from_stored_ms(stored.enqueued_ms), - retry_count: stored.retry_count, - last_attempt_at_unix_ms: stored.last_attempt_ms.map(from_stored_ms), - last_error: stored.last_error, - platform_send_started_at_unix_ms: stored.platform_send_started_ms.map(from_stored_ms), - recovery_state: stored.recovery_state, - partial_receipt: stored.partial_receipt, - } - } -} - /// A terminal result as stored. Its lifetime is the record's `expires_ms` -/// metadata, not a body field. The id is kept because a long idempotency key -/// makes a hashed document id that cannot be turned back into it. +/// metadata, not a body field. The id is retained because a long idempotency +/// key may have a hashed storage document id. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct StoredResult { pub id: String, @@ -155,28 +71,6 @@ pub struct StoredResult { pub receipt: Option, } -impl From<&DeliveryTerminalResult> for StoredResult { - fn from(result: &DeliveryTerminalResult) -> Self { - Self { - id: result.id.clone(), - intent_fingerprint: result.intent_fingerprint.clone(), - completed_ms: to_stored_ms(result.completed_at_unix_ms), - receipt: result.receipt.clone(), - } - } -} - -impl From for DeliveryTerminalResult { - fn from(stored: StoredResult) -> Self { - Self { - id: stored.id, - intent_fingerprint: stored.intent_fingerprint, - completed_at_unix_ms: from_stored_ms(stored.completed_ms), - receipt: stored.receipt, - } - } -} - /// Keep the document-store payload's list keys snake_case while TinyBus uses /// camelCase. Typed v2 payload fields otherwise retain their contract shape. mod stored_payload { diff --git a/crates/tinychannels-bus/src/storage/records_tests.rs b/crates/tinychannels-bus/src/storage/records_tests.rs index 6b4fb3e76..9e1e6b9b5 100644 --- a/crates/tinychannels-bus/src/storage/records_tests.rs +++ b/crates/tinychannels-bus/src/storage/records_tests.rs @@ -1,58 +1,39 @@ use super::*; -use crate::channel::{ChannelRef, ConversationKind, ConversationRef, MessageReceipt}; -use crate::module_delivery::{DeliveryQueueEntry, DeliveryTerminalResult}; #[test] -fn stored_delivery_round_trip_preserves_v22_scope_and_partial_receipt() { - let receipt = MessageReceipt { - primary_platform_message_id: Some("part-1".into()), - platform_message_ids: vec!["part-1".into()], - ..MessageReceipt::default() - }; - let entry = DeliveryQueueEntry { - intent: ChannelOutboundIntent { - turn_id: Some("turn-42".into()), - channel_ref: Some(ChannelRef { - id: "telegram".into(), - account_id: Some("account".into()), - }), - conversation_ref: Some(ConversationRef { - kind: ConversationKind::Thread, - id: "room".into(), - thread_id: Some("thread".into()), - ..ConversationRef::default() - }), - ..ChannelOutboundIntent::default() - }, - partial_receipt: Some(receipt.clone()), - ..DeliveryQueueEntry::default() - }; - - let stored = StoredDelivery::from(&entry); - let restored = DeliveryQueueEntry::from(stored); - - assert_eq!(restored.intent.channel_ref, entry.intent.channel_ref); +fn stored_intent_preserves_snake_case_payload_list_keys() { + let stored: StoredIntent = serde_json::from_value(serde_json::json!({ + "idempotency_key": "key", "channel_id": "channel", "conversation_id": "room", + "reply_to_id": null, "thread_id": null, "durability": "best_effort", + "payload": {"kind": "media", "media_urls": ["https://example.test/image.png"]} + })) + .unwrap(); + let value = serde_json::to_value(&stored).unwrap(); assert_eq!( - restored.intent.conversation_ref, - entry.intent.conversation_ref + value["payload"]["media_urls"], + serde_json::json!(["https://example.test/image.png"]) ); - assert_eq!(restored.partial_receipt, Some(receipt)); - assert_eq!(restored.intent.turn_id.as_deref(), Some("turn-42")); + assert!(value["payload"].get("mediaUrls").is_none()); } #[test] -fn stored_terminal_result_round_trip_preserves_receipt() { - let result = DeliveryTerminalResult { - id: "key".into(), - intent_fingerprint: "digest".into(), - completed_at_unix_ms: 42, - receipt: Some(MessageReceipt { - primary_platform_message_id: Some("message".into()), - ..MessageReceipt::default() - }), - }; - - let restored = DeliveryTerminalResult::from(StoredResult::from(&result)); - - assert_eq!(restored, result); +fn historical_delivery_records_default_optional_scope_and_receipts() { + let stored: StoredDelivery = serde_json::from_value(serde_json::json!({ + "intent": { + "idempotency_key": "key", "channel_id": "channel", "conversation_id": "room", + "reply_to_id": null, "thread_id": null, "durability": "required", + "payload": {"kind": "text", "text": "hello"} + }, + "enqueued_ms": 42 + })) + .unwrap(); + assert!(stored.intent.channel_ref.is_none()); + assert!(stored.intent.conversation_ref.is_none()); + assert!(stored.intent.turn_id.is_none()); + assert!(stored.partial_receipt.is_none()); + let result: StoredResult = serde_json::from_value(serde_json::json!({ + "id": "key", "intent_fingerprint": "digest", "completed_ms": 42 + })) + .unwrap(); + assert!(result.receipt.is_none()); } diff --git a/crates/tinychannels-bus/src/storage/schema.rs b/crates/tinychannels-bus/src/storage/schema.rs index 120f721bf..7320e32e3 100644 --- a/crates/tinychannels-bus/src/storage/schema.rs +++ b/crates/tinychannels-bus/src/storage/schema.rs @@ -1,6 +1,47 @@ //! Pure declarations for collections owned by TinyChannels. -use tinystoragedrivers_types::CollectionSpec; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Pure index declaration for a TinyChannels-owned collection. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct IndexSpec { + pub name: String, + pub fields: Vec, + pub unique: bool, +} + +/// Pure full-text search declaration for a TinyChannels-owned collection. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct SearchSpec { + pub fields: Vec, +} + +/// Serde-only description of one TinyChannels-owned collection. +/// +/// Storage driver types and setup operations belong to the implementation and +/// host composition layer; this DTO keeps the bus contract independent of +/// those runtime dependencies. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct CollectionSpec { + pub name: String, + pub indexes: Vec, + pub ttl_field: Option, + pub search: Option, +} + +impl CollectionSpec { + #[must_use] + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + ..Default::default() + } + } +} /// Body version used by the current TinyChannels record layout. pub const SCHEMA_VERSION: u32 = 2; diff --git a/crates/tinychannels-bus/src/storage/schema_tests.rs b/crates/tinychannels-bus/src/storage/schema_tests.rs index 5a34c887e..01480f1b6 100644 --- a/crates/tinychannels-bus/src/storage/schema_tests.rs +++ b/crates/tinychannels-bus/src/storage/schema_tests.rs @@ -1,10 +1,29 @@ use super::*; #[test] -fn collections_is_pure_and_declares_each_owned_collection_once() { +fn collections_is_a_unique_complete_owner_inventory() { let specs = collections(); let names: Vec<_> = specs.iter().map(|spec| spec.name.as_str()).collect(); assert_eq!(names, COLLECTIONS); + assert_eq!(names.len(), 30); + let unique: std::collections::HashSet<_> = names.iter().collect(); + assert_eq!(unique.len(), names.len()); + for legacy in MIGRATION_SOURCE_COLLECTIONS { + assert!(names.contains(&legacy)); + } + for name in [MEDIA_METADATA, MIGRATIONS] { + assert!(names.contains(&name)); + } assert!(specs.iter().all(|spec| spec.indexes.is_empty())); assert_eq!(media_metadata_spec().name, MEDIA_METADATA); } + +#[test] +fn storage_collection_contract_is_a_serde_only_data_shape() { + let spec = CollectionSpec::new("channels.media_metadata"); + let value = serde_json::to_value(&spec).unwrap(); + assert_eq!(value["name"], "channels.media_metadata"); + assert_eq!(value["indexes"], serde_json::json!([])); + assert!(value["ttl_field"].is_null()); + assert!(value["search"].is_null()); +} diff --git a/crates/tinychannels-bus/src/storage_names.rs b/crates/tinychannels-bus/src/storage_names.rs new file mode 100644 index 000000000..47120a542 --- /dev/null +++ b/crates/tinychannels-bus/src/storage_names.rs @@ -0,0 +1,62 @@ +//! Canonical collection names shared by the schema and module storage request. + +/// Entries awaiting a terminal outcome. +pub const DELIVERY_PENDING: &str = "channels.delivery_pending"; +/// Permanently failed entries. +pub const DELIVERY_FAILED: &str = "channels.delivery_failed"; +/// Idempotent terminal results (they expire through record metadata). +pub const DELIVERY_RESULTS: &str = "channels.delivery_results"; + +/// Chat to thread bindings for remote control. +pub const REMOTE_BINDINGS: &str = "channels.remote_bindings"; +/// Chats with a turn in flight. +pub const REMOTE_BUSY: &str = "channels.remote_busy"; +/// The one-time legacy import marker. +pub const REMOTE_META: &str = "channels.remote_meta"; + +/// The linked device record. +pub const WHATSAPP_DEVICE: &str = "channels.whatsapp_device"; +/// Signal identity keys. +pub const WHATSAPP_IDENTITY: &str = "channels.whatsapp_identity"; +/// Signal sessions. +pub const WHATSAPP_SESSION: &str = "channels.whatsapp_session"; +/// One-time pre-keys. +pub const WHATSAPP_PREKEY: &str = "channels.whatsapp_prekey"; +/// Signed pre-keys. +pub const WHATSAPP_SIGNED_PREKEY: &str = "channels.whatsapp_signed_prekey"; +/// Group sender keys. +pub const WHATSAPP_SENDER_KEY: &str = "channels.whatsapp_sender_key"; +/// App-state sync keys. +pub const WHATSAPP_SYNC_KEY: &str = "channels.whatsapp_sync_key"; +/// LID to phone-number mappings. +pub const WHATSAPP_LID_MAPPING: &str = "channels.whatsapp_lid_mapping"; + +/// Every WhatsApp Web collection, in declaration order. +pub const WHATSAPP_COLLECTIONS: [&str; 8] = [ + WHATSAPP_DEVICE, + WHATSAPP_IDENTITY, + WHATSAPP_SESSION, + WHATSAPP_PREKEY, + WHATSAPP_SIGNED_PREKEY, + WHATSAPP_SENDER_KEY, + WHATSAPP_SYNC_KEY, + WHATSAPP_LID_MAPPING, +]; + +/// Every collection the `channels` owner declares, in declaration order. +pub const COLLECTIONS: [&str; 14] = [ + DELIVERY_PENDING, + DELIVERY_FAILED, + DELIVERY_RESULTS, + REMOTE_BINDINGS, + REMOTE_BUSY, + REMOTE_META, + WHATSAPP_DEVICE, + WHATSAPP_IDENTITY, + WHATSAPP_SESSION, + WHATSAPP_PREKEY, + WHATSAPP_SIGNED_PREKEY, + WHATSAPP_SENDER_KEY, + WHATSAPP_SYNC_KEY, + WHATSAPP_LID_MAPPING, +]; diff --git a/crates/tinychannels-bus/src/traits.rs b/crates/tinychannels-bus/src/traits.rs index a6f938aad..c25facd89 100644 --- a/crates/tinychannels-bus/src/traits.rs +++ b/crates/tinychannels-bus/src/traits.rs @@ -24,10 +24,20 @@ pub struct ChannelMessage { /// When set, replies should be posted as threaded responses. pub thread_ts: Option, /// Provider message identifier this inbound message replies to. - #[serde(default, skip_serializing_if = "Option::is_none", rename = "replyToId")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + rename = "replyToId", + alias = "reply_to_id" + )] pub reply_to_id: Option, /// Independent provider topic identifier where supported. - #[serde(default, skip_serializing_if = "Option::is_none", rename = "topicId")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + rename = "topicId", + alias = "topic_id" + )] pub topic_id: Option, /// The sender's display name as the platform gives it (WhatsApp push /// name, Telegram first and last name, Signal profile name, ...). Never @@ -62,10 +72,20 @@ pub struct SendMessage { /// Platform thread identifier for threaded replies (e.g. Slack `thread_ts`). pub thread_ts: Option, /// Independent provider topic identifier where supported. - #[serde(default, skip_serializing_if = "Option::is_none", rename = "topicId")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + rename = "topicId", + alias = "topic_id" + )] pub topic_id: Option, /// Source message identifier for a provider reply. - #[serde(default, skip_serializing_if = "Option::is_none", rename = "replyToId")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + rename = "replyToId", + alias = "reply_to_id" + )] pub reply_to_id: Option, /// Caller-provided or generated idempotency key for retry-safe delivery. pub idempotency_key: Option, diff --git a/crates/tinychannels-bus/src/voice.rs b/crates/tinychannels-bus/src/voice.rs new file mode 100644 index 000000000..08a78b56e --- /dev/null +++ b/crates/tinychannels-bus/src/voice.rs @@ -0,0 +1,9 @@ +//! Compatibility paths to the authoritative host voice callback definitions. + +pub use crate::host::{ + HostReadVoiceMediaRequest, HostReadVoiceMediaResult, HostSpeechSynthesisRequest, + HostSpeechSynthesisResult, HostStoreVoiceMediaRequest, HostStoreVoiceMediaResult, + HostTranscriptionRequest, HostTranscriptionResult, HostVoiceMediaDirection, HostVoiceReplyMode, + HostVoiceReplyPolicy, HostVoiceReplyPolicyRequest, HostVoiceScope, + HostVoiceTranscriptionRequest, +}; diff --git a/crates/tinychannels-bus/src/web/compatibility_tests.rs b/crates/tinychannels-bus/src/web/compatibility_tests.rs new file mode 100644 index 000000000..aea390c83 --- /dev/null +++ b/crates/tinychannels-bus/src/web/compatibility_tests.rs @@ -0,0 +1,168 @@ +use super::*; +use serde_json::Value; + +#[test] +fn tool_payload_caps_are_utf8_safe_and_bounded() { + let capped = cap_web_output("🦀".repeat(MAX_WEB_TOOL_PAYLOAD_BYTES)); + assert!(capped.len() <= MAX_WEB_TOOL_PAYLOAD_BYTES); + assert!(capped.ends_with("of tool output]")); + assert!(cap_web_args(Some(serde_json::json!({"ok": true}))).is_some()); +} + +#[test] +fn web_event_round_trip_preserves_addressing_context() { + let event = WebChannelEvent::TextDelta { + context: WebEventContext { + client_id: "c".into(), + request_id: "r".into(), + ..Default::default() + }, + delta: Some("hello".into()), + delta_kind: Some("text".into()), + round: Some(1), + }; + assert_eq!(event.event_name(), "text_delta"); + assert_eq!(event.context().request_id, "r"); + assert_eq!( + serde_json::from_slice::(&serde_json::to_vec(&event).unwrap()).unwrap(), + event + ); +} + +#[test] +fn compatibility_web_contract_round_trips_request_metadata_and_error_details() { + let metadata = ChatRequestMetadata { + source: Some("ptt".into()), + speak_reply: Some(true), + session_id: Some(93), + agent_id: Some("agent-a".into()), + hold_text_stream: true, + }; + assert_eq!(metadata.speak_reply, Some(true)); + assert_eq!(metadata.session_id, Some(93)); + assert_eq!(metadata.agent_id.as_deref(), Some("agent-a")); + assert!(metadata.hold_text_stream); + let request = WebChatStart { + speak_reply: metadata.speak_reply, + source: metadata.source, + ..Default::default() + }; + let encoded = serde_json::to_value(&request).unwrap(); + assert_eq!(encoded["speakReply"], true); + assert_eq!(encoded["source"], "ptt"); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + request + ); + + let event = WebChannelEvent::ChatError { + context: WebEventContext::default(), + message: Some("blocked".into()), + error_type: Some("guardrail".into()), + error_source: Some("prompt_guard".into()), + error_retryable: Some(false), + error_retry_after_ms: None, + error_provider: None, + error_fallback_available: Some(false), + copy_key: Some("chat.error.blocked".into()), + copy_params: None, + guardrail: Some(GuardrailPayload { + verdict: "block".into(), + score: 0.9, + reasons: vec![GuardrailReason { + code: "prompt_injection".into(), + message: "blocked".into(), + }], + }), + }; + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["error_source"], "prompt_guard"); + assert_eq!(json["guardrail"]["reasons"][0]["code"], "prompt_injection"); + assert_eq!( + serde_json::from_value::(json).unwrap(), + event + ); +} + +#[test] +fn timing_snapshot_tracks_first_tool_and_round_one_narration() { + let mut state = TurnTimingState::default(); + state.text_delta(12, "hello", 1); + state.text_delta(20, " again", 2); + state.tool_call(32); + state.tool_call(44); + let snapshot = state.snapshot(1_000); + assert_eq!(snapshot.first_token_ms, Some(12)); + assert_eq!(snapshot.first_tool_ms, Some(32)); + assert_eq!(snapshot.round_one_narration_chars, 5); + + let payload = snapshot.into_payload(Some(40)); + assert_eq!(payload.total_ms, Some(1_000)); + assert_eq!(payload.tokens_per_second, Some(40.0)); + assert_eq!(payload.first_tool_ms, Some(32)); +} + +#[test] +fn projected_tool_call_omits_empty_name_and_null_arguments() { + let mut event = WebChannelEvent::ToolCall { + round: None, + skill_id: None, + tool_display_label: None, + tool_display_detail: None, + context: WebEventContext::default(), + tool_name: Some(String::new()), + tool_call_id: Some("call-1".into()), + args: Some(Value::Null), + }; + event.normalize_projected_wire_fields(); + let wire = serde_json::to_value(event).unwrap(); + assert!(wire.get("tool_name").is_none()); + assert!(wire.get("args").is_none()); +} + +#[test] +fn absent_web_event_fields_are_omitted_and_present_false_values_survive() { + for name in [ + "chat_error", + "artifact_pending", + "artifact_ready", + "artifact_failed", + "approval_request", + "plan_review_decided", + ] { + let input = serde_json::json!({ + "event": name, "client_id": "client", "thread_id": "thread", "request_id": "request" + }); + let event: WebChannelEvent = serde_json::from_value(input).unwrap(); + let wire = serde_json::to_value(&event).unwrap(); + assert!( + wire.as_object() + .unwrap() + .values() + .all(|value| !value.is_null()), + "{name} must omit absent optional fields: {wire}" + ); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + event + ); + } + for (name, field) in [ + ("approval_request", "detached"), + ("chat_error", "error_fallback_available"), + ] { + let input = serde_json::json!({ + "event": name, "client_id": "client", "thread_id": "thread", "request_id": "request", + field: false, "seq": 0, "ts": 0 + }); + let event: WebChannelEvent = serde_json::from_value(input).unwrap(); + let wire = serde_json::to_value(&event).unwrap(); + assert_eq!(wire[field], false); + assert_eq!(wire["seq"], 0); + assert_eq!(wire["ts"], 0); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + event + ); + } +} diff --git a/crates/tinychannels-bus/src/web/mod.rs b/crates/tinychannels-bus/src/web/mod.rs index 76c1773f8..babf6dcce 100644 --- a/crates/tinychannels-bus/src/web/mod.rs +++ b/crates/tinychannels-bus/src/web/mod.rs @@ -38,3 +38,7 @@ pub type SubagentProgressDetail = WebSubagentProgressDetail; #[cfg(test)] #[path = "web_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "compatibility_tests.rs"] +mod compatibility_tests; diff --git a/crates/tinychannels-bus/src/wire_tests.rs b/crates/tinychannels-bus/src/wire_tests.rs index 44338e784..2a45b4271 100644 --- a/crates/tinychannels-bus/src/wire_tests.rs +++ b/crates/tinychannels-bus/src/wire_tests.rs @@ -745,3 +745,17 @@ fn inbound_voice_note_keeps_audio_transcript_source_and_waveform_typed() { let decoded: ChannelInboundEnvelope = serde_json::from_value(wire).unwrap(); assert_eq!(decoded, envelope); } + +#[test] +#[allow(deprecated)] +fn legacy_send_accepts_snake_case_topic_and_reply_with_canonical_output() { + let message: crate::SendMessage = serde_json::from_value(serde_json::json!({ + "content": "hello", "recipient": "conversation", "topic_id": "topic", "reply_to_id": "reply" + })) + .unwrap(); + assert_eq!(message.topic_id.as_deref(), Some("topic")); + assert_eq!(message.reply_to_id.as_deref(), Some("reply")); + let wire = serde_json::to_value(message).unwrap(); + assert_eq!(wire["topicId"], "topic"); + assert_eq!(wire["replyToId"], "reply"); +} diff --git a/crates/tinychannels-client/Cargo.toml b/crates/tinychannels-client/Cargo.toml new file mode 100644 index 000000000..d2ec03ca4 --- /dev/null +++ b/crates/tinychannels-client/Cargo.toml @@ -0,0 +1,44 @@ +[package] +rust-version = "1.94" +name = "tinychannels-client" +version.workspace = true +edition = "2024" +license = "GPL-3.0-only" +description = "Typed host calls to the TinyChannels TinyBus module." +repository = "https://github.com/tinyhumansai/tinychannels" +publish = false + +[dependencies] +async-trait = "0.1" +tinybus = { workspace = true } +tinychannels-bus = { path = "../tinychannels-bus", features = ["storage"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[features] +default = [] +native-e2e = [] + +[dev-dependencies] +tinybus = { workspace = true, features = ["macros", "modules"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "test-util"] } + +[[test]] +name = "native_e2e" +path = "tests/native_e2e.rs" +required-features = ["native-e2e"] + +[[test]] +name = "runtime_native_e2e" +path = "tests/runtime_native_e2e.rs" +required-features = ["native-e2e"] + +[[test]] +name = "runtime_readiness_e2e" +path = "tests/runtime_readiness_e2e.rs" +required-features = ["native-e2e"] + +[[test]] +name = "native_module_runtime_e2e" +path = "tests/native_module_runtime_e2e.rs" +required-features = ["native-e2e"] diff --git a/crates/tinychannels-client/src/client.rs b/crates/tinychannels-client/src/client.rs new file mode 100644 index 000000000..d2e3ea5e1 --- /dev/null +++ b/crates/tinychannels-client/src/client.rs @@ -0,0 +1,186 @@ +//! Typed top-level module operations. + +use serde::de::DeserializeOwned; +use tinybus::module::{ModuleCallError, ModuleCallFailure, ModuleClient}; +use tinychannels_bus::{ + ChannelControllerRequest, ChannelControllerResult, ChannelRuntimeDispatchPolicy, + ChannelsConfig, HttpProxyPolicy, MODULE_ID, + controllers::{BotPermissionCheck, DiscordGuild, DiscordTextChannel}, + methods, +}; + +use crate::runtime::RuntimeStart; +use crate::{ChannelRuntimeDriver, ChannelRuntimeError, ChannelRuntimeOperation}; + +#[derive(Clone, Copy)] +pub(super) enum DiscordReply { + Guilds, + Channels, + Permissions, +} + +/// Typed host client for the TinyChannels module. +/// +/// The host supplies the module registry and lifecycle through `ModuleClient`. +/// Calls that contain credentials or proxy policy use TinyBus's strict +/// attested confidential route and never retry as ordinary calls. +#[derive(Clone)] +pub struct TinyChannelsClient { + module: ModuleClient, +} + +impl TinyChannelsClient { + /// Create a client over the host's TinyBus module access. + #[must_use] + pub fn new(module: ModuleClient) -> Self { + Self { module } + } + + /// Bind one attested TinyChannels peer for an identity-scoped runtime. + /// + /// The returned driver keeps its root calls and every later object call on + /// the same unique peer, even if the well-known module name changes owners. + /// OpenHuman supplies the callback implementation, retry policy, reporting, + /// configuration and credentials when it passes this driver to + /// `ModuleRuntime::lease`. + pub async fn runtime_driver(&self) -> Result { + self.bind_runtime_driver(RuntimeStart::Serial).await + } + + /// Bind a runtime whose inbound dispatch explicitly uses this host-selected + /// concurrency policy. [`Self::runtime_driver`] retains serial dispatch. + pub async fn runtime_driver_with_dispatch( + &self, + policy: ChannelRuntimeDispatchPolicy, + ) -> Result { + self.bind_runtime_driver(RuntimeStart::Dispatch(policy)) + .await + } + + async fn bind_runtime_driver( + &self, + start: RuntimeStart, + ) -> Result { + let bound = self + .module + .bind_attested(MODULE_ID) + .await + .map_err(|failure| { + ChannelRuntimeError::module(ChannelRuntimeOperation::Bind, failure.kind) + })?; + Ok(ChannelRuntimeDriver::new(bound, start)) + } + + /// List Discord guilds visible to the configured bot. + /// + /// `bot_token` and `proxy` are sent only after the module proves the + /// registry-pinned release archive and extracted library on the same + /// unique bus peer. Remote failure details are untrusted and may contain + /// sensitive text; the returned error's `Debug` and `Display` are redacted. + pub async fn discord_list_guilds( + &self, + config: ChannelsConfig, + bot_token: String, + proxy: HttpProxyPolicy, + ) -> Result, ModuleCallFailure> { + let request = ChannelControllerRequest::DiscordListGuilds { + config, + bot_token, + proxy, + }; + self.discord(request, DiscordReply::Guilds).await + } + + /// List Discord channels visible in a guild. + pub async fn discord_list_channels( + &self, + config: ChannelsConfig, + guild_id: String, + bot_token: String, + proxy: HttpProxyPolicy, + ) -> Result, ModuleCallFailure> { + let request = ChannelControllerRequest::DiscordListChannels { + config, + guild_id, + bot_token, + proxy, + }; + self.discord(request, DiscordReply::Channels).await + } + + /// Check the configured Discord bot's permissions in a channel. + pub async fn discord_check_permissions( + &self, + config: ChannelsConfig, + guild_id: String, + channel_id: String, + bot_token: String, + proxy: HttpProxyPolicy, + ) -> Result { + let request = ChannelControllerRequest::DiscordCheckPermissions { + config, + guild_id, + channel_id, + bot_token, + proxy, + }; + self.discord(request, DiscordReply::Permissions).await + } + + /// Check provider health using the timeout budget chosen by the host. + /// + /// The call is confidential because provider configuration and proxy + /// policy can contain sensitive operational details. + pub async fn check_health( + &self, + config: ChannelsConfig, + proxy: HttpProxyPolicy, + timeout_ms: u64, + ) -> Result, ModuleCallFailure> { + self.module + .call_attested_detailed( + MODULE_ID, + methods::CHECK_CHANNEL_HEALTH, + (config, proxy, timeout_ms), + ) + .await + } + + async fn discord( + &self, + request: ChannelControllerRequest, + expected: DiscordReply, + ) -> Result { + let response: ChannelControllerResult = self + .module + .call_attested_detailed(MODULE_ID, methods::CHANNEL_CONTROLLER, (request,)) + .await?; + Self::decode_discord(response, expected).ok_or_else(|| self.malformed_reply()) + } + + fn malformed_reply(&self) -> ModuleCallFailure { + self.module.report_malformed_reply(MODULE_ID); + ModuleCallFailure { + kind: ModuleCallError::ModuleFault, + wire_name: "ai.tinyhumans.tinybus.Error.Failed".into(), + message: "malformed TinyChannels module response".into(), + } + } + + pub(super) fn decode_discord( + response: ChannelControllerResult, + expected: DiscordReply, + ) -> Option { + let raw = match (expected, response) { + (DiscordReply::Guilds, ChannelControllerResult::DiscordGuilds(result)) => result.raw, + (DiscordReply::Channels, ChannelControllerResult::DiscordChannels(result)) => { + result.raw + } + (DiscordReply::Permissions, ChannelControllerResult::DiscordPermissions(result)) => { + result.raw + } + _ => None, + }; + raw.and_then(|value| serde_json::from_value(value).ok()) + } +} diff --git a/crates/tinychannels-client/src/lib.rs b/crates/tinychannels-client/src/lib.rs new file mode 100644 index 000000000..2c7f72213 --- /dev/null +++ b/crates/tinychannels-client/src/lib.rs @@ -0,0 +1,13 @@ +//! Typed host calls to confidential TinyChannels module operations. + +mod client; +mod runtime; +mod types; + +pub use client::TinyChannelsClient; +pub use runtime::{ChannelRuntimeClient, ChannelRuntimeDriver}; +pub use types::{ChannelRuntimeError, ChannelRuntimeErrorKind, ChannelRuntimeOperation}; + +#[cfg(test)] +#[path = "lib_tests.rs"] +mod tests; diff --git a/crates/tinychannels-client/src/lib_runtime_tests.rs b/crates/tinychannels-client/src/lib_runtime_tests.rs new file mode 100644 index 000000000..4bdd44692 --- /dev/null +++ b/crates/tinychannels-client/src/lib_runtime_tests.rs @@ -0,0 +1,39 @@ +use super::*; + +use tinybus::module::RuntimeLeaseDriver; + +#[test] +fn runtime_driver_public_type_implements_the_sdk_lease_contract() { + fn assert_driver() {} + + assert_driver::(); +} + +#[tokio::test] +async fn runtime_driver_builders_bind_strictly_before_any_runtime_call() { + let (client, calls, control) = unattested_client().await; + + // The peer is alive and ordinary calls work, so refusing the runtime API + // proves strict binding rather than a missing module or bus connection. + let ready: bool = control + .call("FixtureReady", ()) + .await + .expect("ordinary readiness control succeeds on the live peer"); + assert!(ready); + let before = calls.lock().expect("call log").clone(); + assert_eq!(before, ["FixtureReady"]); + + assert!(client.runtime_driver().await.is_err()); + assert!( + client + .runtime_driver_with_dispatch(tinychannels_bus::ChannelRuntimeDispatchPolicy { + listener_count: 1 + },) + .await + .is_err() + ); + + // Failed proof must occur before ReserveRuntime or any business method is + // delivered. In particular, availability must never trigger fallback. + assert_eq!(*calls.lock().expect("call log after refusal"), before); +} diff --git a/crates/tinychannels-client/src/lib_tests.rs b/crates/tinychannels-client/src/lib_tests.rs new file mode 100644 index 000000000..54193199e --- /dev/null +++ b/crates/tinychannels-client/src/lib_tests.rs @@ -0,0 +1,317 @@ +use super::*; +use crate::client::DiscordReply; + +use std::sync::{Arc, Mutex}; + +use serde_json::json; +use tinybus::{ + Connection, InterfaceName, MemberName, ObjectPath, Proxy, + broker::Broker, + module::{ + AccessFuture, FailureReport, FailureReporter, FailureSink, ModuleAccess, ModuleCallError, + ModuleCallFailure, ModuleClient, ProxyFailure, ReleaseAsset, ReleaseRecord, + }, + service::Interface, + transport::memory::MemoryBus, +}; +use tinychannels_bus::{ + ChannelControllerRequest, ChannelControllerResult, ChannelsConfig, HttpProxyPolicy, + controllers::DiscordGuild, +}; + +#[path = "lib_runtime_tests.rs"] +mod runtime_tests; + +const MODULE_ID: &str = tinychannels_bus::MODULE_ID; +type Calls = Arc>>; + +struct NoFailures; + +impl FailureSink for NoFailures { + fn capture(&self, _report: &FailureReport<'_>) {} +} + +struct RecordingModule(Calls); + +#[async_trait::async_trait] +impl Interface for RecordingModule { + fn name(&self) -> InterfaceName { + InterfaceName::new(tinychannels_bus::BUS_NAME).expect("valid bus interface") + } + + fn members(&self) -> Vec { + tinychannels_bus::METHODS + .iter() + .copied() + .chain(["FixtureReady"]) + .map(|name| MemberName::new(name).expect("valid TinyChannels method")) + .collect() + } + + async fn call( + &self, + member: &MemberName, + _args: serde_json::Value, + ) -> tinybus::Result { + self.0 + .lock() + .map_err(|_| tinybus::Error::failed("call log poisoned"))? + .push(member.as_str().to_owned()); + if member.as_str() == "FixtureReady" { + return Ok(json!(true)); + } + Ok(json!({"result":"discord_guilds","value":{"raw":[]}})) + } +} + +struct TestAccess { + record: &'static ReleaseRecord, + _service: Connection, + connection: Connection, + reporter: FailureReporter, +} + +impl ModuleAccess for TestAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + (id == self.record.id).then_some(self.record) + } + + fn enabled(&self) -> bool { + true + } + + fn reporter(&self) -> &FailureReporter { + &self.reporter + } + + fn ensure_loaded<'a>(&'a self, _id: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async { Ok(()) }) + } + + fn prepare_attested<'a>(&'a self, _id: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async { Ok(()) }) + } + + fn proxy<'a>( + &'a self, + bus_name: &'a str, + object_path: &'a str, + ) -> AccessFuture<'a, Result> { + Box::pin(async move { + self.connection + .proxy(bus_name, object_path, bus_name) + .map_err(|_| ProxyFailure::Contract) + }) + } +} + +async fn unattested_client() -> (TinyChannelsClient, Calls, Proxy) { + let host_key = Box::leak( + tinybus::module::platform::host_candidates() + .into_iter() + .next() + .expect("supported TinyBus host") + .into_boxed_str(), + ); + let archive = Box::leak("fixture.tar.gz".to_owned().into_boxed_str()); + let archive_sha = Box::leak("a".repeat(64).into_boxed_str()); + let library_sha = Box::leak("b".repeat(64).into_boxed_str()); + let assets = Box::leak( + vec![ReleaseAsset { + host_key, + archive, + sha256: archive_sha, + library_sha256: Some(library_sha), + }] + .into_boxed_slice(), + ); + let record = Box::leak(Box::new(ReleaseRecord { + id: MODULE_ID, + bus_name: tinychannels_bus::BUS_NAME, + object_path: tinychannels_bus::OBJECT_PATH, + version: "0.2.0", + release_url: "https://example.invalid/tinychannels", + assets, + })); + + let calls = Arc::new(Mutex::new(Vec::new())); + let bus = MemoryBus::new(); + let _broker = Broker::new().spawn(bus.clone()); + let service = Connection::connect(bus.connect().await.expect("service transport")) + .await + .expect("service connection"); + service + .serve_at( + ObjectPath::new(tinychannels_bus::OBJECT_PATH).expect("valid module path"), + RecordingModule(Arc::clone(&calls)), + ) + .await + .expect("serve fixture"); + service + .request_name(tinychannels_bus::BUS_NAME) + .await + .expect("own module name"); + let caller = Connection::connect(bus.connect().await.expect("caller transport")) + .await + .expect("caller connection"); + let control = caller + .proxy( + tinychannels_bus::BUS_NAME, + tinychannels_bus::OBJECT_PATH, + tinychannels_bus::BUS_NAME, + ) + .expect("ordinary fixture control proxy"); + let module = ModuleClient::new(TestAccess { + record, + _service: service, + connection: caller, + reporter: FailureReporter::new(Arc::new(NoFailures)), + }); + (TinyChannelsClient::new(module), calls, control) +} + +#[test] +fn discord_replies_decode_only_the_matching_raw_projection() { + let response: ChannelControllerResult = serde_json::from_value(json!({ + "result":"discord_guilds", + "value":{"raw":[{"id":"guild-1","name":"Server","icon":null}]} + })) + .unwrap(); + let guilds: Vec = + TinyChannelsClient::decode_discord(response, DiscordReply::Guilds).unwrap(); + assert_eq!(guilds[0].id, "guild-1"); + + let wrong_projection: ChannelControllerResult = serde_json::from_value(json!({ + "result":"discord_channels", + "value":{"raw":[]} + })) + .unwrap(); + assert!( + TinyChannelsClient::decode_discord::>( + wrong_projection, + DiscordReply::Guilds + ) + .is_none() + ); + + let malformed: ChannelControllerResult = serde_json::from_value(json!({ + "result":"discord_guilds", + "value":{"raw":[{"id":"guild-1"}]} + })) + .unwrap(); + assert!( + TinyChannelsClient::decode_discord::>(malformed, DiscordReply::Guilds) + .is_none() + ); +} + +#[test] +fn discord_requests_keep_the_confidential_controller_wire_shape() { + let config = ChannelsConfig::default(); + let proxy = HttpProxyPolicy::default(); + let token = "private-token"; + + let requests = [ + ( + ChannelControllerRequest::DiscordListGuilds { + config: config.clone(), + bot_token: token.into(), + proxy: proxy.clone(), + }, + json!({ + "operation":"discord_list_guilds", + "request":{"config":config,"bot_token":token,"proxy":proxy} + }), + ), + ( + ChannelControllerRequest::DiscordListChannels { + config: ChannelsConfig::default(), + guild_id: "guild-1".into(), + bot_token: token.into(), + proxy: HttpProxyPolicy::default(), + }, + json!({ + "operation":"discord_list_channels", + "request":{"config":ChannelsConfig::default(),"guild_id":"guild-1","bot_token":token,"proxy":HttpProxyPolicy::default()} + }), + ), + ( + ChannelControllerRequest::DiscordCheckPermissions { + config: ChannelsConfig::default(), + guild_id: "guild-1".into(), + channel_id: "channel-1".into(), + bot_token: token.into(), + proxy: HttpProxyPolicy::default(), + }, + json!({ + "operation":"discord_check_permissions", + "request":{"config":ChannelsConfig::default(),"guild_id":"guild-1","channel_id":"channel-1","bot_token":token,"proxy":HttpProxyPolicy::default()} + }), + ), + ]; + + for (request, expected) in requests { + assert_eq!(serde_json::to_value((request,)).unwrap(), json!([expected])); + } +} + +#[tokio::test] +async fn discord_credentials_never_fall_back_when_peer_proof_is_missing() { + let (client, calls, _) = unattested_client().await; + let guilds = client + .discord_list_guilds( + ChannelsConfig::default(), + "fixture-private-token".into(), + HttpProxyPolicy::default(), + ) + .await; + let channels = client + .discord_list_channels( + ChannelsConfig::default(), + "fixture-guild".into(), + "fixture-private-token".into(), + HttpProxyPolicy::default(), + ) + .await; + let permissions = client + .discord_check_permissions( + ChannelsConfig::default(), + "fixture-guild".into(), + "fixture-channel".into(), + "fixture-private-token".into(), + HttpProxyPolicy::default(), + ) + .await; + let health = client + .check_health(ChannelsConfig::default(), HttpProxyPolicy::default(), 1_000) + .await; + + for error in [ + guilds.expect_err("unattested guild call must be rejected"), + channels.expect_err("unattested channel call must be rejected"), + permissions.expect_err("unattested permission call must be rejected"), + health.expect_err("unattested health call must be rejected"), + ] { + assert_eq!(error.kind, ModuleCallError::Unavailable); + assert!(!format!("{error:?}").contains("fixture-private-token")); + assert!(!error.to_string().contains("fixture-private-token")); + } + assert!(calls.lock().unwrap().is_empty()); +} + +#[test] +fn detailed_remote_invalid_input_is_available_only_in_explicit_fields() { + let failure = ModuleCallFailure { + kind: ModuleCallError::ModuleFault, + wire_name: "ai.tinyhumans.tinychannels.Error.InvalidInput".into(), + message: "invalid request containing fixture-private-token".into(), + }; + + assert_eq!( + failure.wire_name, + "ai.tinyhumans.tinychannels.Error.InvalidInput" + ); + assert!(failure.message.contains("fixture-private-token")); + assert!(!format!("{failure:?}").contains("fixture-private-token")); + assert!(!failure.to_string().contains("fixture-private-token")); +} diff --git a/crates/tinychannels-client/src/runtime.rs b/crates/tinychannels-client/src/runtime.rs new file mode 100644 index 000000000..1e88f993d --- /dev/null +++ b/crates/tinychannels-client/src/runtime.rs @@ -0,0 +1,355 @@ +//! Reservation-scoped runtime driver and typed object operations. + +use async_trait::async_trait; +use serde::de::DeserializeOwned; +use tinybus::{ + BusName, ObjectPath, + module::{ + BoundModuleClient, ModuleObjectClient, RuntimeLeaseDriver, StartOutcome, StopOutcome, + }, +}; +use tinychannels_bus::{ + ChannelInteractionRequest, ChannelInteractionResult, ChannelOutboundIntent, + ChannelRuntimeDispatchPolicy, ChannelRuntimeReservation, ChannelSupervisionPolicy, + ChannelsConfig, HttpProxyPolicy, + controllers::ChannelSendMessageResult, + methods, + module_control::{ChannelControlRequest, ChannelControlResult}, + module_progressive::{ProgressiveCommand, ProgressiveResult}, + module_relay::RelayReservation, +}; + +use crate::{ChannelRuntimeError, ChannelRuntimeErrorKind, ChannelRuntimeOperation}; + +#[derive(Clone, Copy)] +pub(super) enum RuntimeStart { + Serial, + Dispatch(ChannelRuntimeDispatchPolicy), +} + +/// Runtime protocol driver bound to one attested TinyChannels peer. +/// +/// Its bound client stays private so callers cannot bypass the typed protocol +/// projection or resolve the module alias again. +#[derive(Clone)] +pub struct ChannelRuntimeDriver { + bound: BoundModuleClient, + start: RuntimeStart, +} + +impl ChannelRuntimeDriver { + pub(super) fn new(bound: BoundModuleClient, start: RuntimeStart) -> Self { + Self { bound, start } + } +} + +#[async_trait] +impl RuntimeLeaseDriver for ChannelRuntimeDriver { + type Reservation = ChannelRuntimeReservation; + type Object = ChannelRuntimeClient; + type Error = ChannelRuntimeError; + + async fn reserve(&self) -> Result { + self.bound + .call_confidential(methods::RESERVE_RUNTIME, ()) + .await + .map_err(|error| ChannelRuntimeError::module(ChannelRuntimeOperation::Reserve, error)) + } + + fn validate(&self, reservation: &Self::Reservation) -> Result<(), Self::Error> { + reservation.validate().map_err(|_| { + ChannelRuntimeError::contract( + ChannelRuntimeOperation::ValidateReservation, + ChannelRuntimeErrorKind::InvalidReservation, + ) + }) + } + + fn callback_address( + &self, + reservation: &Self::Reservation, + _object: &Self::Object, + ) -> Result<(BusName, ObjectPath), Self::Error> { + self.validate(reservation)?; + let name = BusName::new(tinychannels_bus::HOST_BUS_NAME).map_err(|_| { + ChannelRuntimeError::contract( + ChannelRuntimeOperation::CallbackAddress, + ChannelRuntimeErrorKind::InvalidCallbackAddress, + ) + })?; + let path = ObjectPath::new(&reservation.host_object_path).map_err(|_| { + ChannelRuntimeError::contract( + ChannelRuntimeOperation::CallbackAddress, + ChannelRuntimeErrorKind::InvalidCallbackAddress, + ) + })?; + Ok((name, path)) + } + + async fn object(&self, reservation: &Self::Reservation) -> Result { + let object = self + .bound + .object_client(&reservation.object_path) + .map_err(|error| ChannelRuntimeError::module(ChannelRuntimeOperation::Object, error))?; + Ok(ChannelRuntimeClient { object }) + } + + async fn start(&self, reservation: &Self::Reservation) -> StartOutcome { + let result = match self.start { + RuntimeStart::Serial => { + self.bound + .call_confidential::<()>(methods::START_RUNTIME, (reservation.handle.clone(),)) + .await + } + RuntimeStart::Dispatch(policy) => { + self.bound + .call_confidential::<()>( + methods::START_RUNTIME_WITH_DISPATCH, + (reservation.handle.clone(), policy), + ) + .await + } + }; + match result { + Ok(()) => StartOutcome::Started, + Err(error) => StartOutcome::Unacknowledged(ChannelRuntimeError::module( + ChannelRuntimeOperation::Start, + error, + )), + } + } + + async fn stop(&self, reservation: &Self::Reservation) -> StopOutcome { + match self + .bound + .call_confidential::<()>(methods::STOP_RUNTIME, (reservation.handle.clone(),)) + .await + { + Ok(()) => StopOutcome::Acknowledged, + Err(error) => StopOutcome::Unacknowledged(ChannelRuntimeError::module( + ChannelRuntimeOperation::Stop, + error, + )), + } + } + + fn failed(&self, _reservation: &Self::Reservation) -> bool { + // The current protocol has no authoritative terminal-fault observation. + false + } +} + +/// Typed methods on one reservation-owned TinyChannels object. +/// +/// The underlying SDK object client remains private. Each method preserves the +/// bus contract's argument order, response type, and delivery classification. +#[derive(Clone)] +pub struct ChannelRuntimeClient { + object: ModuleObjectClient, +} + +impl ChannelRuntimeClient { + async fn confidential( + &self, + operation: ChannelRuntimeOperation, + member: &str, + args: impl serde::Serialize, + ) -> Result { + self.object + .call_confidential(member, args) + .await + .map_err(|error| ChannelRuntimeError::module(operation, error)) + } + + async fn ordinary( + &self, + operation: ChannelRuntimeOperation, + member: &str, + args: impl serde::Serialize, + ) -> Result { + self.object + .call(member, args) + .await + .map_err(|error| ChannelRuntimeError::module(operation, error)) + } + + /// Describe available providers without starting them. + pub async fn describe_configured_channels( + &self, + config: ChannelsConfig, + proxy: HttpProxyPolicy, + ) -> Result, ChannelRuntimeError> + { + self.confidential( + ChannelRuntimeOperation::DescribeConfiguredChannels, + methods::DESCRIBE_CONFIGURED_CHANNELS, + (config, proxy), + ) + .await + } + + /// Start one supervised listener provider with host-selected retry timing. + pub async fn start_channel_supervised( + &self, + name: String, + config: ChannelsConfig, + proxy: HttpProxyPolicy, + policy: ChannelSupervisionPolicy, + ) -> Result<(), ChannelRuntimeError> { + self.confidential( + ChannelRuntimeOperation::StartChannelSupervised, + methods::START_CHANNEL_SUPERVISED, + (name, config, proxy, policy), + ) + .await + } + + /// Start one external-webhook provider for outbound operations. + pub async fn start_outbound_channel( + &self, + name: String, + config: ChannelsConfig, + proxy: HttpProxyPolicy, + ) -> Result<(), ChannelRuntimeError> { + self.confidential( + ChannelRuntimeOperation::StartOutboundChannel, + methods::START_OUTBOUND_CHANNEL, + (name, config, proxy), + ) + .await + } + + /// Prepare a host-visible channel control response. + pub async fn prepare_channel_control( + &self, + request: ChannelControlRequest, + ) -> Result { + self.confidential( + ChannelRuntimeOperation::PrepareChannelControl, + methods::PREPARE_CHANNEL_CONTROL, + (request,), + ) + .await + } + + /// Execute one progressive reply operation. + pub async fn execute_progressive( + &self, + command: ProgressiveCommand, + ) -> Result { + self.confidential( + ChannelRuntimeOperation::ExecuteProgressive, + methods::EXECUTE_PROGRESSIVE, + (command,), + ) + .await + } + + /// Prepare one outbound intent inside TinyChannels. + pub async fn prepare_outbound_intent( + &self, + channel: String, + preparation: tinychannels_bus::module_runtime::ChannelMessagePreparation, + ) -> Result { + self.confidential( + ChannelRuntimeOperation::PrepareOutboundIntent, + methods::PREPARE_OUTBOUND_INTENT, + (channel, preparation), + ) + .await + } + + /// Run a typed interaction on one configured provider. + pub async fn channel_interaction( + &self, + request: ChannelInteractionRequest, + ) -> Result { + self.confidential( + ChannelRuntimeOperation::ChannelInteraction, + methods::CHANNEL_INTERACTION, + (request,), + ) + .await + } + + /// Start a relay and return the module's typed capability descriptor. + pub async fn start_relay( + &self, + reservation: RelayReservation, + config: tinychannels_bus::config::RelayRuntimeConfig, + ) -> Result { + self.confidential( + ChannelRuntimeOperation::StartRelay, + methods::START_RELAY, + (reservation, config), + ) + .await + } + + /// Reserve an outbound relay handle. + pub async fn reserve_relay(&self) -> Result { + self.ordinary( + ChannelRuntimeOperation::ReserveRelay, + methods::RESERVE_RELAY, + (), + ) + .await + } + + /// Stop one reserved relay. + pub async fn stop_relay( + &self, + reservation: RelayReservation, + ) -> Result<(), ChannelRuntimeError> { + self.ordinary( + ChannelRuntimeOperation::StopRelay, + methods::STOP_RELAY, + (reservation,), + ) + .await + } + + /// Read one provider's status. + pub async fn channel_status(&self, name: String) -> Result { + self.ordinary( + ChannelRuntimeOperation::ChannelStatus, + methods::CHANNEL_STATUS, + (name,), + ) + .await + } + + /// Send one typed intent through a configured provider. + pub async fn send_channel_intent( + &self, + name: String, + intent: ChannelOutboundIntent, + ) -> Result { + self.ordinary( + ChannelRuntimeOperation::SendChannelIntent, + methods::SEND_CHANNEL_INTENT, + (name, intent), + ) + .await + } + + /// Send one typed intent through a reserved relay. + pub async fn send_relay_intent( + &self, + reservation: RelayReservation, + intent: ChannelOutboundIntent, + ) -> Result { + self.ordinary( + ChannelRuntimeOperation::SendRelayIntent, + methods::SEND_RELAY_INTENT, + (reservation, intent), + ) + .await + } + + /// Shut down provider and relay services owned by this runtime object. + pub async fn shutdown(&self) -> Result<(), ChannelRuntimeError> { + self.ordinary(ChannelRuntimeOperation::Shutdown, methods::SHUTDOWN, ()) + .await + } +} diff --git a/crates/tinychannels-client/src/types.rs b/crates/tinychannels-client/src/types.rs new file mode 100644 index 000000000..d7ed7d3b0 --- /dev/null +++ b/crates/tinychannels-client/src/types.rs @@ -0,0 +1,132 @@ +//! Public, content-free runtime error types shared by the typed client. + +use std::fmt; + +use tinybus::module::ModuleCallError; + +/// A closed, content-free classification for one runtime operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ChannelRuntimeOperation { + /// Bind to the registered, attested TinyChannels peer. + Bind, + /// Reserve one module-owned runtime. + Reserve, + /// Validate the module-generated reservation. + ValidateReservation, + /// Construct the reservation's private module object. + Object, + /// Construct the returned callback name and path. + CallbackAddress, + /// Start the reserved runtime. + Start, + /// Stop the reserved runtime. + Stop, + /// Describe configured channel providers. + DescribeConfiguredChannels, + /// Start a supervised listener provider. + StartChannelSupervised, + /// Start an outbound-only provider. + StartOutboundChannel, + /// Prepare a channel control response. + PrepareChannelControl, + /// Execute a progressive reply operation. + ExecuteProgressive, + /// Prepare a typed outbound intent. + PrepareOutboundIntent, + /// Run an interaction on a configured provider. + ChannelInteraction, + /// Start a reserved relay and read its capability descriptor. + StartRelay, + /// Reserve an outbound relay. + ReserveRelay, + /// Stop a reserved relay. + StopRelay, + /// Read one provider's status. + ChannelStatus, + /// Send one provider intent. + SendChannelIntent, + /// Send one relay intent. + SendRelayIntent, + /// Shut down module-owned channel services on this object. + Shutdown, +} + +/// A closed error kind. It carries SDK classifications, never peer-provided text. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ChannelRuntimeErrorKind { + /// The SDK classified the operation failure. + Module(ModuleCallError), + /// The reservation decoded but failed the bus contract's validation. + InvalidReservation, + /// The module-returned callback address is not a valid TinyBus address. + InvalidCallbackAddress, +} + +/// A runtime operation error that retains its typed cause without remote text. +#[derive(Clone, Copy, Eq, PartialEq)] +pub struct ChannelRuntimeError { + operation: ChannelRuntimeOperation, + kind: ChannelRuntimeErrorKind, +} + +impl ChannelRuntimeError { + pub(super) fn module(operation: ChannelRuntimeOperation, kind: ModuleCallError) -> Self { + Self { + operation, + kind: ChannelRuntimeErrorKind::Module(kind), + } + } + + pub(super) fn contract( + operation: ChannelRuntimeOperation, + kind: ChannelRuntimeErrorKind, + ) -> Self { + Self { operation, kind } + } + + /// The operation that failed. + #[must_use] + pub fn operation(self) -> ChannelRuntimeOperation { + self.operation + } + + /// The closed SDK or contract classification. + #[must_use] + pub fn kind(self) -> ChannelRuntimeErrorKind { + self.kind + } +} + +impl fmt::Debug for ChannelRuntimeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ChannelRuntimeError") + .field("operation", &self.operation) + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for ChannelRuntimeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "TinyChannels runtime {:?} failed ({:?})", + self.operation, self.kind + ) + } +} + +impl std::error::Error for ChannelRuntimeError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self.kind { + ChannelRuntimeErrorKind::Module(error) => Some(error), + ChannelRuntimeErrorKind::InvalidReservation + | ChannelRuntimeErrorKind::InvalidCallbackAddress => None, + } + } +} + +#[cfg(test)] +#[path = "types_tests.rs"] +mod tests; diff --git a/crates/tinychannels-client/src/types_tests.rs b/crates/tinychannels-client/src/types_tests.rs new file mode 100644 index 000000000..d9d06ade0 --- /dev/null +++ b/crates/tinychannels-client/src/types_tests.rs @@ -0,0 +1,38 @@ +use super::*; + +use tinybus::module::ModuleCallError; + +#[test] +fn runtime_errors_keep_typed_module_causes_without_remote_payloads() { + let error = + ChannelRuntimeError::module(ChannelRuntimeOperation::Stop, ModuleCallError::ModuleFault); + + assert_eq!(error.operation(), ChannelRuntimeOperation::Stop); + assert_eq!( + error.kind(), + ChannelRuntimeErrorKind::Module(ModuleCallError::ModuleFault) + ); + assert!(std::error::Error::source(&error).is_some()); + assert!(!format!("{error:?}").contains("private remote payload")); + assert!(!error.to_string().contains("private remote payload")); +} + +#[test] +fn runtime_contract_errors_keep_their_kind_without_an_error_source() { + for kind in [ + ChannelRuntimeErrorKind::InvalidReservation, + ChannelRuntimeErrorKind::InvalidCallbackAddress, + ] { + let error = + ChannelRuntimeError::contract(ChannelRuntimeOperation::ValidateReservation, kind); + + assert_eq!( + error.operation(), + ChannelRuntimeOperation::ValidateReservation + ); + assert_eq!(error.kind(), kind); + assert!(std::error::Error::source(&error).is_none()); + assert!(!format!("{error:?}").contains("private remote payload")); + assert!(!error.to_string().contains("private remote payload")); + } +} diff --git a/crates/tinychannels-client/test-fixture/Cargo.lock b/crates/tinychannels-client/test-fixture/Cargo.lock new file mode 100644 index 000000000..b07d7a347 --- /dev/null +++ b/crates/tinychannels-client/test-fixture/Cargo.lock @@ -0,0 +1,1189 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_casemap" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a6f143e9349267b80ae071a6ec25d4797c45756d8b2671a8b24b7b25b81ab94" +dependencies = [ + "icu_casemap_data", + "icu_collections", + "icu_locale_core", + "icu_properties", + "icu_provider", + "potential_utf", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_casemap_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71a5c321208537711696af55bc2ab5dad939e4f6e0aef92dd6a2555deceedf3a" + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "serde", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rawzip" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9999039f74cd73c03158cddfe4362991703c74efe5ce5c8073026f80a5e48c" + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.9", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinybus" +version = "0.3.12" +dependencies = [ + "async-trait", + "flate2", + "icu_casemap", + "libc", + "rawzip", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "thiserror", + "tinybus-macros", + "tokio", + "toml", + "tracing", + "unicode-normalization", + "ureq", +] + +[[package]] +name = "tinybus-macros" +version = "0.3.12" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tinybus-module" +version = "0.3.12" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "tinybus", + "tokio", + "tracing", +] + +[[package]] +name = "tinychannels-bus" +version = "0.2.0" +dependencies = [ + "base64", + "schemars", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "tinychannels-client-test-fixture" +version = "0.2.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "tinybus", + "tinybus-module", + "tinychannels-bus", + "tokio", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[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.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/tinychannels-client/test-fixture/Cargo.toml b/crates/tinychannels-client/test-fixture/Cargo.toml new file mode 100644 index 000000000..5c80a560c --- /dev/null +++ b/crates/tinychannels-client/test-fixture/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "tinychannels-client-test-fixture" +version = "0.2.0" +edition = "2024" +rust-version = "1.94" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +async-trait = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tinybus = { version = "0.3.12", path = "../../../vendor/tinybus/crates/tinybus", default-features = false, features = ["macros", "modules"] } +tinybus-module = { version = "0.3.12", path = "../../../vendor/tinybus/crates/tinybus-module" } +tinychannels-bus = { path = "../../tinychannels-bus", features = ["storage"] } +tokio = { version = "1", features = ["sync"] } + +[workspace] diff --git a/crates/tinychannels-client/test-fixture/src/lib.rs b/crates/tinychannels-client/test-fixture/src/lib.rs new file mode 100644 index 000000000..a60ac7889 --- /dev/null +++ b/crates/tinychannels-client/test-fixture/src/lib.rs @@ -0,0 +1,778 @@ +//! Native test-only module for checking the public TinyChannels client routes. + +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, +}; + +use async_trait::async_trait; +use serde::{Deserialize, de::DeserializeOwned}; +use serde_json::{Value, json}; +use tinybus::{ + CallContext, Connection, Error, Interface, InterfaceName, MemberName, ObjectPath, Result, +}; +use tinychannels_bus::{ + ChannelControllerRequest, ChannelControllerResult, ChannelInteractionRequest, + ChannelInteractionResult, ChannelOutboundIntent, ChannelRuntimeDispatchPolicy, + ChannelRuntimeReservation, ChannelSupervisionPolicy, ChannelsConfig, HttpProxyPolicy, + controllers::{ + ChannelSendMessageResult, DiscordChannelListResult, DiscordGuildListResult, + DiscordPermissionCheckResult, + }, + methods, + module_control::{ChannelControlRequest, ChannelControlResult}, + module_progressive::{ProgressiveCommand, ProgressiveResult}, + module_relay::RelayReservation, +}; + +const BUS_NAME: &str = tinychannels_bus::BUS_NAME; +const OBJECT_PATH: &str = tinychannels_bus::OBJECT_PATH; +const TOKEN: &str = "fixture-call-secret"; +const CONFIG_TOKEN: &str = "fixture-config-secret"; +const INVALID_INPUT: &str = "ai.tinyhumans.tinychannels.Error.InvalidInput"; +const READINESS_GATE_PATH: &str = "/ai/tinyhumans/tinychannels/ChannelsHost/test_fixture_ready_gate"; + +#[derive(Deserialize)] +struct FixtureConfig { + #[serde(default)] + hold_ready: bool, +} + +struct FixtureService { + connection: Connection, + calls: Arc, + runtime_controls: Arc, +} + +struct RuntimeControls { + fail_start: AtomicBool, + fail_stop: AtomicBool, + malformed_reservation: AtomicBool, + malformed_status: AtomicBool, + pause_next_reserve: AtomicBool, + reserve_entered: tokio::sync::Semaphore, + reserve_release: tokio::sync::Semaphore, + delivery_flags: Mutex>, + stop_calls: AtomicUsize, + next_handle: AtomicU64, +} + +impl Default for RuntimeControls { + fn default() -> Self { + Self { + fail_start: AtomicBool::new(false), + fail_stop: AtomicBool::new(false), + malformed_reservation: AtomicBool::new(false), + malformed_status: AtomicBool::new(false), + pause_next_reserve: AtomicBool::new(false), + reserve_entered: tokio::sync::Semaphore::new(0), + reserve_release: tokio::sync::Semaphore::new(0), + delivery_flags: Mutex::new(Vec::new()), + stop_calls: AtomicUsize::new(0), + next_handle: AtomicU64::new(0), + } + } +} + +#[async_trait] +impl Interface for FixtureService { + fn name(&self) -> InterfaceName { + InterfaceName::new(BUS_NAME).expect("TinyChannels bus name is valid") + } + + fn members(&self) -> Vec { + tinychannels_bus::METHODS + .into_iter() + .chain([ + "FixtureCallCount", + "FixtureReady", + "FixtureReleaseAlias", + "FixtureDisconnect", + "FixtureFailStart", + "FixtureFailStop", + "FixtureStopCallCount", + "FixtureMalformedReservation", + "FixtureMalformedStatus", + "FixturePauseReserve", + "FixtureWaitReserve", + "FixtureReleaseReserve", + "FixtureTakeDeliveryFlags", + ]) + .map(|method| MemberName::new(method).expect("TinyChannels member is valid")) + .collect() + } + + fn requires_confidential(&self, member: &MemberName) -> bool { + matches!( + member.as_str(), + methods::CHANNEL_CONTROLLER + | methods::CHECK_CHANNEL_HEALTH + | methods::RESERVE_RUNTIME + | methods::START_RUNTIME + | methods::START_RUNTIME_WITH_DISPATCH + | methods::STOP_RUNTIME + | methods::DESCRIBE_CONFIGURED_CHANNELS + | methods::START_CHANNEL_SUPERVISED + | methods::START_OUTBOUND_CHANNEL + | methods::PREPARE_CHANNEL_CONTROL + | methods::EXECUTE_PROGRESSIVE + | methods::PREPARE_OUTBOUND_INTENT + | methods::CHANNEL_INTERACTION + | methods::START_RELAY + ) + } + + async fn call(&self, member: &MemberName, args: Value) -> Result { + if member.as_str() == "FixtureCallCount" { + return Ok(json!(self.calls.load(Ordering::SeqCst))); + } + if member.as_str() == "FixtureStopCallCount" { + return Ok(json!( + self.runtime_controls.stop_calls.load(Ordering::SeqCst) + )); + } + self.calls.fetch_add(1, Ordering::SeqCst); + match member.as_str() { + "FixtureReady" => Ok(json!(true)), + "FixtureFailStart" => { + self.runtime_controls + .fail_start + .store(true, Ordering::SeqCst); + Ok(Value::Null) + } + "FixtureFailStop" => { + self.runtime_controls + .fail_stop + .store(true, Ordering::SeqCst); + Ok(Value::Null) + } + "FixtureMalformedReservation" => { + self.runtime_controls + .malformed_reservation + .store(true, Ordering::SeqCst); + Ok(Value::Null) + } + "FixtureMalformedStatus" => { + self.runtime_controls + .malformed_status + .store(true, Ordering::SeqCst); + Ok(Value::Null) + } + "FixturePauseReserve" => { + self.runtime_controls + .pause_next_reserve + .store(true, Ordering::SeqCst); + Ok(Value::Null) + } + "FixtureWaitReserve" => { + self.runtime_controls + .reserve_entered + .acquire() + .await + .map_err(|_| Error::failed("reservation gate closed"))? + .forget(); + Ok(Value::Null) + } + "FixtureReleaseReserve" => { + self.runtime_controls.reserve_release.add_permits(1); + Ok(Value::Null) + } + "FixtureTakeDeliveryFlags" => { + let flags = std::mem::take( + &mut *self + .runtime_controls + .delivery_flags + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + serde_json::to_value(flags) + .map_err(|_| Error::failed("delivery flags could not serialize")) + } + "FixtureReleaseAlias" => { + self.connection.release_name(BUS_NAME).await?; + Ok(Value::Null) + } + "FixtureDisconnect" => { + self.connection.close().await?; + Ok(Value::Null) + } + methods::RESERVE_RUNTIME => { + arguments(&args, 0)?; + self.reserve_runtime().await + } + methods::START_RUNTIME => self.start_runtime(args, false).await, + methods::START_RUNTIME_WITH_DISPATCH => self.start_runtime(args, true).await, + methods::STOP_RUNTIME => { + self.runtime_controls + .stop_calls + .fetch_add(1, Ordering::SeqCst); + let handle = first_string(&args)?; + if handle.len() != 32 { + return Err(Error::failed("runtime stop handle mismatch")); + } + if self + .runtime_controls + .fail_stop + .swap(false, Ordering::SeqCst) + { + return Err(Error::failed("cleanup pending")); + } + let (object_path, _) = runtime_paths(handle); + self.connection + .unserve(&ObjectPath::new(&object_path)?) + .await; + Ok(Value::Null) + } + methods::CHANNEL_CONTROLLER => self.channel_controller(args), + methods::CHECK_CHANNEL_HEALTH => self.check_health(args), + _ => Err(Error::failed("unknown test fixture method")), + } + } + + async fn call_with_context( + &self, + member: &MemberName, + args: Value, + context: &CallContext, + ) -> Result { + if audited_delivery_member(member.as_str()) { + self.runtime_controls + .delivery_flags + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push((member.as_str().to_owned(), context.is_confidential())); + } + self.call(member, args).await + } +} + +impl FixtureService { + async fn reserve_runtime(&self) -> Result { + if self + .runtime_controls + .pause_next_reserve + .swap(false, Ordering::SeqCst) + { + self.runtime_controls.reserve_entered.add_permits(1); + self.runtime_controls + .reserve_release + .acquire() + .await + .map_err(|_| Error::failed("reservation gate closed"))? + .forget(); + } + if self + .runtime_controls + .malformed_reservation + .swap(false, Ordering::SeqCst) + { + return Ok(json!({ + "handle": 17, + "object_path": "invalid-runtime-object", + "host_object_path": "invalid-host-object" + })); + } + let handle = format!( + "{:032x}", + self.runtime_controls + .next_handle + .fetch_add(1, Ordering::SeqCst) + + 1 + ); + let (object_path, host_object_path) = runtime_paths(&handle); + let reservation = ChannelRuntimeReservation { + handle, + object_path: object_path.clone(), + host_object_path, + }; + self.connection + .serve_at( + ObjectPath::new(&object_path)?, + FixtureObject { + calls: Arc::clone(&self.calls), + runtime_controls: Arc::clone(&self.runtime_controls), + }, + ) + .await?; + serde_json::to_value(reservation).map_err(|_| Error::failed("fixture serialization failed")) + } + + async fn start_runtime(&self, args: Value, with_dispatch: bool) -> Result { + let values = arguments(&args, if with_dispatch { 2 } else { 1 })?; + let Some(handle) = values.first().and_then(Value::as_str) else { + return Err(Error::failed("runtime start handle mismatch")); + }; + if handle.len() != 32 { + return Err(Error::failed("runtime start handle mismatch")); + } + if with_dispatch { + let policy: ChannelRuntimeDispatchPolicy = decode(values, 1)?; + if policy.listener_count != 2 { + return Err(Error::failed("runtime dispatch policy mismatch")); + } + } + if self + .runtime_controls + .fail_start + .swap(false, Ordering::SeqCst) + { + return Err(Error::failed("cleanup pending")); + } + let (_, callback_path) = runtime_paths(handle); + let callback = self + .connection + .proxy( + tinychannels_bus::HOST_BUS_NAME, + &callback_path, + tinychannels_bus::HOST_BUS_NAME, + )? + .call::("FixtureCallback", ()) + .await?; + if callback != "callback-live" { + return Err(Error::failed("runtime callback was not registered")); + } + Ok(Value::Null) + } + + fn channel_controller(&self, args: Value) -> Result { + let request = args + .get(0) + .cloned() + .and_then(|value| serde_json::from_value::(value).ok()) + .ok_or_else(|| Error::failed("invalid test fixture request"))?; + match request { + ChannelControllerRequest::DiscordListGuilds { + config, + bot_token, + proxy, + } => { + validate_request(&config, &bot_token, &proxy, None, None)?; + if proxy.service_selectors == ["test:invalid-input"] { + return Err(invalid_input()); + } + let result = match scenario(&proxy) { + "test:wrong-variant" => { + ChannelControllerResult::DiscordChannels(DiscordChannelListResult { + raw: Some(json!([])), + ..Default::default() + }) + } + "test:missing-raw" => { + ChannelControllerResult::DiscordGuilds(DiscordGuildListResult::default()) + } + "test:malformed-payload" => { + ChannelControllerResult::DiscordGuilds(DiscordGuildListResult { + raw: Some(json!({"private":"fixture"})), + ..Default::default() + }) + } + _ => ChannelControllerResult::DiscordGuilds(DiscordGuildListResult { + raw: Some(json!([{"id":"guild-1","name":"Fixture guild","icon":null}])), + ..Default::default() + }), + }; + serde_json::to_value(result) + .map_err(|_| Error::failed("fixture serialization failed")) + } + ChannelControllerRequest::DiscordListChannels { + config, + guild_id, + bot_token, + proxy, + } => { + validate_request(&config, &bot_token, &proxy, Some(&guild_id), None)?; + let result = match scenario(&proxy) { + "test:wrong-variant" => { + ChannelControllerResult::DiscordGuilds(DiscordGuildListResult { + raw: Some(json!([])), + ..Default::default() + }) + } + "test:missing-raw" => { + ChannelControllerResult::DiscordChannels(DiscordChannelListResult::default()) + } + _ => ChannelControllerResult::DiscordChannels(DiscordChannelListResult { + raw: Some( + json!([{"id":"channel-1","name":"general","type":0,"position":11,"parent_id":"category-1"}]), + ), + ..Default::default() + }), + }; + serde_json::to_value(result) + .map_err(|_| Error::failed("fixture serialization failed")) + } + ChannelControllerRequest::DiscordCheckPermissions { + config, + guild_id, + channel_id, + bot_token, + proxy, + } => { + validate_request( + &config, + &bot_token, + &proxy, + Some(&guild_id), + Some(&channel_id), + )?; + let result = match scenario(&proxy) { + "test:wrong-variant" => { + ChannelControllerResult::DiscordGuilds(DiscordGuildListResult { + raw: Some(json!([])), + ..Default::default() + }) + } + "test:missing-raw" => ChannelControllerResult::DiscordPermissions( + DiscordPermissionCheckResult::default(), + ), + "test:malformed-payload" => { + ChannelControllerResult::DiscordPermissions(DiscordPermissionCheckResult { + raw: Some(json!(false)), + ..Default::default() + }) + } + _ => { + ChannelControllerResult::DiscordPermissions(DiscordPermissionCheckResult { + raw: Some( + json!({"can_view_channel":true,"can_send_messages":false,"can_read_message_history":true,"missing_permissions":["SEND_MESSAGES"]}), + ), + ..Default::default() + }) + } + }; + serde_json::to_value(result) + .map_err(|_| Error::failed("fixture serialization failed")) + } + _ => Err(Error::failed("unexpected controller operation")), + } + } + + fn check_health(&self, args: Value) -> Result { + let Some(values) = args.as_array() else { + return Err(Error::failed("invalid health fixture arguments")); + }; + let config = values + .first() + .cloned() + .and_then(|value| serde_json::from_value::(value).ok()) + .ok_or_else(|| Error::failed("invalid health config"))?; + let proxy = values + .get(1) + .cloned() + .and_then(|value| serde_json::from_value::(value).ok()) + .ok_or_else(|| Error::failed("invalid health proxy"))?; + let timeout = values.get(2).and_then(Value::as_u64); + validate_request(&config, TOKEN, &proxy, None, None)?; + if timeout != Some(12_345) { + return Err(invalid_input()); + } + match scenario(&proxy) { + "test:malformed-payload" => Ok(json!([{"channel":17,"state":"healthy"}])), + "test:invalid-input" => Err(invalid_input()), + _ => Ok(json!([ + {"channel":"discord","state":"healthy"}, + {"channel":"telegram","state":"timeout"} + ])), + } + } +} + +struct FixtureObject { + calls: Arc, + runtime_controls: Arc, +} + +#[async_trait] +impl Interface for FixtureObject { + fn name(&self) -> InterfaceName { + InterfaceName::new(BUS_NAME).expect("TinyChannels bus name is valid") + } + + fn members(&self) -> Vec { + tinychannels_bus::METHODS + .into_iter() + .map(|method| MemberName::new(method).expect("TinyChannels member is valid")) + .collect() + } + + fn requires_confidential(&self, member: &MemberName) -> bool { + matches!( + member.as_str(), + methods::DESCRIBE_CONFIGURED_CHANNELS + | methods::START_CHANNEL_SUPERVISED + | methods::START_OUTBOUND_CHANNEL + | methods::PREPARE_CHANNEL_CONTROL + | methods::EXECUTE_PROGRESSIVE + | methods::PREPARE_OUTBOUND_INTENT + | methods::CHANNEL_INTERACTION + | methods::START_RELAY + ) + } + + async fn call(&self, member: &MemberName, args: Value) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + match member.as_str() { + methods::DESCRIBE_CONFIGURED_CHANNELS => { + let values = arguments(&args, 2)?; + let _: ChannelsConfig = decode(values, 0)?; + let _: HttpProxyPolicy = decode(values, 1)?; + Ok(json!([])) + } + methods::START_CHANNEL_SUPERVISED => { + let values = arguments(&args, 4)?; + let _: String = decode(values, 0)?; + let _: ChannelsConfig = decode(values, 1)?; + let _: HttpProxyPolicy = decode(values, 2)?; + let _: ChannelSupervisionPolicy = decode(values, 3)?; + Ok(Value::Null) + } + methods::START_OUTBOUND_CHANNEL => { + let values = arguments(&args, 3)?; + let _: String = decode(values, 0)?; + let _: ChannelsConfig = decode(values, 1)?; + let _: HttpProxyPolicy = decode(values, 2)?; + Ok(Value::Null) + } + methods::PREPARE_CHANNEL_CONTROL => { + let values = arguments(&args, 1)?; + let _: ChannelControlRequest = decode(values, 0)?; + serde_json::to_value(ChannelControlResult::RuntimeLimits { + timeout_secs: 47, + max_in_flight: 8, + }) + .map_err(|_| Error::failed("fixture serialization failed")) + } + methods::EXECUTE_PROGRESSIVE => { + let values = arguments(&args, 1)?; + let _: ProgressiveCommand = decode(values, 0)?; + serde_json::to_value(ProgressiveResult::Closed) + .map_err(|_| Error::failed("fixture serialization failed")) + } + methods::PREPARE_OUTBOUND_INTENT => { + let values = arguments(&args, 2)?; + let _: String = decode(values, 0)?; + let _: tinychannels_bus::module_runtime::ChannelMessagePreparation = + decode(values, 1)?; + serde_json::to_value(tinychannels_bus::module_runtime::PreparedChannelIntent { + intent: ChannelOutboundIntent::default(), + legacy_message: Value::Null, + }) + .map_err(|_| Error::failed("fixture serialization failed")) + } + methods::CHANNEL_INTERACTION => { + let values = arguments(&args, 1)?; + let _: ChannelInteractionRequest = decode(values, 0)?; + serde_json::to_value(ChannelInteractionResult::Completed) + .map_err(|_| Error::failed("fixture serialization failed")) + } + methods::START_RELAY => { + let values = arguments(&args, 2)?; + let _: RelayReservation = decode(values, 0)?; + let _: tinychannels_bus::config::RelayRuntimeConfig = decode(values, 1)?; + serde_json::to_value(tinychannels_bus::relay::CapabilityDescriptor::default()) + .map_err(|_| Error::failed("fixture serialization failed")) + } + methods::RESERVE_RELAY => { + arguments(&args, 0)?; + serde_json::to_value(RelayReservation("fixture-relay".into())) + .map_err(|_| Error::failed("fixture serialization failed")) + } + methods::STOP_RELAY => { + let values = arguments(&args, 1)?; + let _: RelayReservation = decode(values, 0)?; + Ok(Value::Null) + } + methods::CHANNEL_STATUS => { + let values = arguments(&args, 1)?; + let _: String = decode(values, 0)?; + if self + .runtime_controls + .malformed_status + .swap(false, Ordering::SeqCst) + { + return Ok(json!({"unexpected":"object reply"})); + } + Ok(json!("peer-a")) + } + methods::SEND_CHANNEL_INTENT => { + let values = arguments(&args, 2)?; + let _: String = decode(values, 0)?; + let _: ChannelOutboundIntent = decode(values, 1)?; + serde_json::to_value(ChannelSendMessageResult::default()) + .map_err(|_| Error::failed("fixture serialization failed")) + } + methods::SEND_RELAY_INTENT => { + let values = arguments(&args, 2)?; + let _: RelayReservation = decode(values, 0)?; + let _: ChannelOutboundIntent = decode(values, 1)?; + serde_json::to_value(ChannelSendMessageResult::default()) + .map_err(|_| Error::failed("fixture serialization failed")) + } + methods::SHUTDOWN => { + arguments(&args, 0)?; + Ok(Value::Null) + } + _ => Err(Error::failed("unexpected TinyChannels object method")), + } + } + + async fn call_with_context( + &self, + member: &MemberName, + args: Value, + context: &CallContext, + ) -> Result { + if audited_delivery_member(member.as_str()) { + self.runtime_controls + .delivery_flags + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push((member.as_str().to_owned(), context.is_confidential())); + } + self.call(member, args).await + } +} + +fn arguments(value: &Value, count: usize) -> Result<&[Value]> { + let values = value + .as_array() + .ok_or_else(|| Error::failed("fixture arguments are not positional"))?; + if values.len() != count { + return Err(Error::failed("fixture argument count mismatch")); + } + Ok(values) +} + +fn decode(values: &[Value], index: usize) -> Result { + values + .get(index) + .cloned() + .and_then(|value| serde_json::from_value(value).ok()) + .ok_or_else(|| Error::failed("fixture argument has the wrong type")) +} + +fn first_string(args: &Value) -> Result<&str> { + args.as_array() + .and_then(|values| values.first()) + .and_then(Value::as_str) + .ok_or_else(|| Error::failed("fixture runtime handle is missing")) +} + +fn runtime_paths(handle: &str) -> (String, String) { + ( + format!("{OBJECT_PATH}/runtime_{handle}"), + format!("{}/runtime_{handle}", tinychannels_bus::HOST_OBJECT_PATH), + ) +} + +fn audited_delivery_member(member: &str) -> bool { + tinychannels_bus::METHODS.contains(&member) +} + +fn validate_request( + config: &ChannelsConfig, + bot_token: &str, + proxy: &HttpProxyPolicy, + guild_id: Option<&str>, + channel_id: Option<&str>, +) -> Result<()> { + if serde_json::to_value(config).ok() != serde_json::to_value(expected_config()).ok() + || bot_token != TOKEN + || guild_id.is_some_and(|actual| actual != "guild-1") + || channel_id.is_some_and(|actual| actual != "channel-1") + || !valid_proxy(proxy) + { + return Err(Error::MethodFailed { + name: INVALID_INPUT.into(), + message: "test fixture request did not match expected fields".into(), + }); + } + Ok(()) +} + +fn valid_proxy(proxy: &HttpProxyPolicy) -> bool { + proxy.http_proxy.as_deref() == Some("http://proxy.example.test:8080") + && proxy.https_proxy.as_deref() == Some("https://proxy.example.test:8443") + && proxy.all_proxy.as_deref() == Some("socks5://proxy.example.test:1080") + && proxy.no_proxy == ["localhost", ".internal"] + && (proxy.service_selectors == ["channel.discord"] || scenario(proxy).starts_with("test:")) +} + +fn scenario(proxy: &HttpProxyPolicy) -> &str { + proxy.service_selectors.first().map_or("", String::as_str) +} + +fn expected_config() -> ChannelsConfig { + ChannelsConfig { + cli: false, + message_timeout_secs: 47, + active_channel: Some("discord".into()), + discord: Some(tinychannels_bus::config::DiscordConfig { + bot_token: CONFIG_TOKEN.into(), + guild_id: Some("config-guild".into()), + channel_id: Some("config-channel".into()), + allowed_users: vec!["member-1".into()], + listen_to_bots: true, + mention_only: true, + }), + ..Default::default() + } +} + +fn invalid_input() -> Error { + Error::MethodFailed { + name: INVALID_INPUT.into(), + message: "request contained fixture-private-token; input is invalid".into(), + } +} + +async fn setup(connection: Connection, config: FixtureConfig) -> Result<()> { + let calls = Arc::new(AtomicUsize::new(0)); + let runtime_controls = Arc::new(RuntimeControls::default()); + connection + .serve_at( + ObjectPath::new(OBJECT_PATH)?, + FixtureService { + connection: connection.clone(), + calls, + runtime_controls, + }, + ) + .await?; + connection.request_name(BUS_NAME).await?; + if config.hold_ready { + connection + .proxy( + tinychannels_bus::HOST_BUS_NAME, + READINESS_GATE_PATH, + tinychannels_bus::HOST_BUS_NAME, + )? + .call::<()>("WaitBeforeReady", ()) + .await?; + } + Ok(()) +} + +tinybus_module::module_export! { + setup = setup, + config = FixtureConfig, + worker_threads = 1, + provides = ["ai.tinyhumans.tinychannels.Channels"], + methods = [ + "ChannelController", "CheckChannelHealth", "FixtureCallCount", + "FixtureReady", "FixtureReleaseAlias", "FixtureDisconnect", + "FixtureFailStart", "FixtureFailStop", "FixtureStopCallCount", + "FixtureMalformedReservation", "FixtureMalformedStatus", + "FixturePauseReserve", "FixtureWaitReserve", "FixtureReleaseReserve", + "FixtureTakeDeliveryFlags", + "ReserveRuntime", "StartRuntime", "StartRuntimeWithDispatch", "StopRuntime", + "DescribeConfiguredChannels", "StartChannelSupervised", "StartOutboundChannel", + "PrepareChannelControl", "ExecuteProgressive", "PrepareOutboundIntent", + "ChannelInteraction", "StartRelay", "ReserveRelay", "StopRelay", "ChannelStatus", + "SendChannelIntent", "SendRelayIntent", "Shutdown", + ], + signals = [], + requires = [], + optional = [], + lazy = false, +} diff --git a/crates/tinychannels-client/tests/native_e2e.rs b/crates/tinychannels-client/tests/native_e2e.rs new file mode 100644 index 000000000..eed0368b8 --- /dev/null +++ b/crates/tinychannels-client/tests/native_e2e.rs @@ -0,0 +1,340 @@ +//! Public client routes over a strictly admitted cached native fixture. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +mod support; + +use tinybus::module::{ModuleCallError, ModuleCallFailure}; +use tinychannels_bus::{ + ChannelHealthState, ChannelsConfig, HttpProxyPolicy, config::DiscordConfig, +}; + +const TOKEN: &str = "fixture-call-secret"; + +fn config() -> ChannelsConfig { + ChannelsConfig { + cli: false, + message_timeout_secs: 47, + active_channel: Some("discord".into()), + discord: Some(DiscordConfig { + bot_token: "fixture-config-secret".into(), + guild_id: Some("config-guild".into()), + channel_id: Some("config-channel".into()), + allowed_users: vec!["member-1".into()], + listen_to_bots: true, + mention_only: true, + }), + ..Default::default() + } +} + +fn proxy(selector: &str) -> HttpProxyPolicy { + HttpProxyPolicy { + http_proxy: Some("http://proxy.example.test:8080".into()), + https_proxy: Some("https://proxy.example.test:8443".into()), + all_proxy: Some("socks5://proxy.example.test:1080".into()), + no_proxy: vec!["localhost".into(), ".internal".into()], + service_selectors: vec![selector.into()], + } +} + +fn assert_one_module_fault(reports: &support::Reports) { + assert_eq!( + reports.entries(), + [("tinychannels".into(), "0.2.0".into(), "module_fault".into())] + ); +} + +fn assert_sanitized_failure(failure: &ModuleCallFailure) { + assert_eq!(failure.kind, ModuleCallError::ModuleFault); + assert!(!format!("{failure:?}").contains("fixture-private-token")); + assert!(!failure.to_string().contains("fixture-private-token")); +} + +#[tokio::test] +async fn all_public_wrappers_route_to_the_pinned_peer_and_decode_typed_results() { + let fixture = support::fixture().await; + let record = support::record(support::Pin::Correct); + let before = support::calls(&fixture.probe).await; + // Observe the admitted peer's public proof metadata; each actual operation + // below still traverses TinyChannelsClient's strict ModuleClient call. + let peer = fixture + .probe + .name_owner(record.bus_name) + .await + .expect("module owner lookup") + .expect("fixture is serving"); + assert!(peer.is_unique()); + let proof = fixture + .probe + .attestation(peer) + .await + .expect("public peer proof lookup") + .expect("cached release is attested"); + assert_eq!(proof.name.as_str(), record.bus_name); + assert_eq!(proof.sha256, record.assets[0].sha256); + assert_eq!( + proof.library_sha256.as_deref(), + record.assets[0].library_sha256 + ); + + let guilds = fixture + .client + .discord_list_guilds(config(), TOKEN.into(), proxy("channel.discord")) + .await + .expect("guild list from the pinned native fixture"); + assert_eq!(guilds.len(), 1); + assert_eq!(guilds[0].id, "guild-1"); + assert_eq!(guilds[0].name, "Fixture guild"); + assert_eq!(guilds[0].icon, None); + + let channels = fixture + .client + .discord_list_channels( + config(), + "guild-1".into(), + TOKEN.into(), + proxy("channel.discord"), + ) + .await + .expect("channel list from the pinned native fixture"); + assert_eq!(channels.len(), 1); + assert_eq!(channels[0].id, "channel-1"); + assert_eq!(channels[0].name, "general"); + assert_eq!(channels[0].channel_type, 0); + assert_eq!(channels[0].position, 11); + assert_eq!(channels[0].parent_id.as_deref(), Some("category-1")); + + let permissions = fixture + .client + .discord_check_permissions( + config(), + "guild-1".into(), + "channel-1".into(), + TOKEN.into(), + proxy("channel.discord"), + ) + .await + .expect("permission check from the pinned native fixture"); + assert!(permissions.can_view_channel); + assert!(!permissions.can_send_messages); + assert!(permissions.can_read_message_history); + assert_eq!(permissions.missing_permissions, ["SEND_MESSAGES"]); + + let health = fixture + .client + .check_health(config(), proxy("channel.discord"), 12_345) + .await + .expect("health response from the pinned native fixture"); + assert_eq!(health[0].channel, "discord"); + assert_eq!(health[0].state, ChannelHealthState::Healthy); + assert_eq!(health[1].channel, "telegram"); + assert_eq!(health[1].state, ChannelHealthState::Timeout); + assert_eq!(support::calls(&fixture.probe).await, before + 4); + + // The fixture validates full config, bot token, IDs, every proxy field, + // and the non-default timeout before returning these typed results. + assert_eq!(record.version, "0.2.0"); +} + +#[tokio::test] +async fn malformed_public_responses_report_one_sanitized_module_fault() { + let fixture = support::fixture().await; + + fixture.reports.clear(); + let guilds = fixture + .client + .discord_list_guilds(config(), TOKEN.into(), proxy("test:wrong-variant")) + .await + .expect_err("wrong result variant is malformed"); + assert_sanitized_failure(&guilds); + assert_one_module_fault(&fixture.reports); + + fixture.reports.clear(); + let channels = fixture + .client + .discord_list_channels( + config(), + "guild-1".into(), + TOKEN.into(), + proxy("test:missing-raw"), + ) + .await + .expect_err("missing raw projection is malformed"); + assert_sanitized_failure(&channels); + assert_one_module_fault(&fixture.reports); + + fixture.reports.clear(); + let permissions = fixture + .client + .discord_check_permissions( + config(), + "guild-1".into(), + "channel-1".into(), + TOKEN.into(), + proxy("test:malformed-payload"), + ) + .await + .expect_err("wrong permission payload shape is malformed"); + assert_sanitized_failure(&permissions); + assert_one_module_fault(&fixture.reports); + + fixture.reports.clear(); + let health = fixture + .client + .check_health(config(), proxy("test:malformed-payload"), 12_345) + .await + .expect_err("wrong health payload shape is malformed"); + assert_eq!(health.kind, ModuleCallError::IncompatibleContract); + assert!(!format!("{health:?}").contains("fixture-private-token")); + assert!(!health.to_string().contains("fixture-private-token")); + assert_eq!( + fixture.reports.entries(), + [( + "tinychannels".into(), + "0.2.0".into(), + "incompatible_contract".into() + )] + ); +} + +#[tokio::test] +async fn detailed_invalid_input_keeps_its_wire_classification_and_redacts_rendering() { + let fixture = support::fixture().await; + fixture.reports.clear(); + let failure = fixture + .client + .discord_list_guilds(config(), TOKEN.into(), proxy("test:invalid-input")) + .await + .expect_err("fixture emits an InvalidInput remote failure"); + + assert_eq!(failure.kind, ModuleCallError::ModuleFault); + assert_eq!( + failure.wire_name, + "ai.tinyhumans.tinychannels.Error.InvalidInput" + ); + assert!(failure.message.contains("fixture-private-token")); + assert!(!format!("{failure:?}").contains("fixture-private-token")); + assert!(!failure.to_string().contains("fixture-private-token")); + assert_one_module_fault(&fixture.reports); +} + +#[tokio::test] +async fn wrong_release_pins_and_unattested_peers_are_refused_before_business_calls() { + let fixture = support::fixture().await; + let before = support::calls(&fixture.probe).await; + + for client in [&fixture.wrong_archive_client, &fixture.wrong_library_client] { + let error = client + .discord_list_guilds(config(), TOKEN.into(), proxy("channel.discord")) + .await + .expect_err("a mismatched half of the paired pin must be refused"); + support::assert_unavailable(error); + assert_eq!(support::calls(&fixture.probe).await, before); + + let error = client + .discord_list_channels( + config(), + "guild-1".into(), + TOKEN.into(), + proxy("channel.discord"), + ) + .await + .expect_err("a mismatched pin must refuse channel listing"); + support::assert_unavailable(error); + assert_eq!(support::calls(&fixture.probe).await, before); + + let error = client + .discord_check_permissions( + config(), + "guild-1".into(), + "channel-1".into(), + TOKEN.into(), + proxy("channel.discord"), + ) + .await + .expect_err("a mismatched pin must refuse permission checks"); + support::assert_unavailable(error); + assert_eq!(support::calls(&fixture.probe).await, before); + + let error = client + .check_health(config(), proxy("channel.discord"), 12_345) + .await + .expect_err("a mismatched pin must refuse health checks"); + support::assert_unavailable(error); + assert_eq!(support::calls(&fixture.probe).await, before); + } + + let unattested = support::unattested_fixture().await; + let owner = support::assert_unattested_peer_live(&unattested.probe).await; + let error = unattested + .client + .discord_list_guilds(config(), TOKEN.into(), proxy("channel.discord")) + .await + .expect_err("unique but unattested peer must be refused"); + assert_eq!(error.kind, ModuleCallError::Unavailable); + assert_eq!( + unattested.calls.load(std::sync::atomic::Ordering::SeqCst), + 0 + ); + assert_eq!( + support::assert_unattested_peer_live(&unattested.probe).await, + owner + ); + + let error = unattested + .client + .discord_list_channels( + config(), + "guild-1".into(), + TOKEN.into(), + proxy("channel.discord"), + ) + .await + .expect_err("unique but unattested peer must refuse channel listing"); + assert_eq!(error.kind, ModuleCallError::Unavailable); + assert_eq!( + unattested.calls.load(std::sync::atomic::Ordering::SeqCst), + 0 + ); + assert_eq!( + support::assert_unattested_peer_live(&unattested.probe).await, + owner + ); + + let error = unattested + .client + .discord_check_permissions( + config(), + "guild-1".into(), + "channel-1".into(), + TOKEN.into(), + proxy("channel.discord"), + ) + .await + .expect_err("unique but unattested peer must refuse permission checks"); + assert_eq!(error.kind, ModuleCallError::Unavailable); + assert_eq!( + unattested.calls.load(std::sync::atomic::Ordering::SeqCst), + 0 + ); + assert_eq!( + support::assert_unattested_peer_live(&unattested.probe).await, + owner + ); + + let error = unattested + .client + .check_health(config(), proxy("channel.discord"), 12_345) + .await + .expect_err("unique but unattested peer must refuse health checks"); + assert_eq!(error.kind, ModuleCallError::Unavailable); + assert_eq!( + unattested.calls.load(std::sync::atomic::Ordering::SeqCst), + 0 + ); + assert_eq!( + support::assert_unattested_peer_live(&unattested.probe).await, + owner + ); +} diff --git a/crates/tinychannels-client/tests/native_module_runtime_e2e.rs b/crates/tinychannels-client/tests/native_module_runtime_e2e.rs new file mode 100644 index 000000000..13a69d436 --- /dev/null +++ b/crates/tinychannels-client/tests/native_module_runtime_e2e.rs @@ -0,0 +1,109 @@ +//! Provider-free composition with the actual TinyChannels module cdylib. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; + +mod support; + +use tinybus::{Interface, InterfaceName, MemberName, module::RetryPolicy}; +use tinychannels_bus::{ChannelRuntimeReservation, ChannelsConfig, HttpProxyPolicy}; + +#[derive(Clone)] +struct FixtureCallback; + +#[async_trait::async_trait] +impl Interface for FixtureCallback { + fn name(&self) -> InterfaceName { + InterfaceName::new(tinychannels_bus::HOST_BUS_NAME) + .expect("TinyChannels host callback interface") + } + + fn members(&self) -> Vec { + vec![MemberName::new("FixtureCallback").expect("fixture callback method")] + } + + async fn call( + &self, + _: &MemberName, + _: serde_json::Value, + ) -> tinybus::Result { + Ok(serde_json::json!("callback-live")) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn actual_native_module_runs_provider_free_runtime_and_releases_callback_on_ack() { + // This test process owns one ModuleRuntime. The fixture artifact is the + // locally built tinychannels-module cdylib with exact archive/library + // hashes; no provider configuration or external network is used. + let fixture = support::native_module_runtime_fixture().await; + let driver = fixture + .client + .runtime_driver() + .await + .expect("strictly bind the locally built TinyChannels module artifact"); + let callback_path = Arc::new(Mutex::new(None::)); + let captured_callback_path = Arc::clone(&callback_path); + let lease = fixture + .runtime + .lease( + driver, + move |reservation: &ChannelRuntimeReservation, _| { + *captured_callback_path + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(reservation.host_object_path.clone()); + Ok(FixtureCallback) + }, + RetryPolicy::new(Duration::from_secs(2), vec![]).expect("valid stop policy"), + |_| {}, + ) + .await + .expect("real module reserves, creates its private object, and starts"); + + let _configured = lease + .object() + .describe_configured_channels(ChannelsConfig::default(), HttpProxyPolicy::default()) + .await + .expect("real module's typed provider description with empty configuration"); + let path = callback_path + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + .expect("callback factory received the module-returned path"); + assert_eq!( + fixture + .runtime + .connection() + .proxy( + tinychannels_bus::HOST_BUS_NAME, + &path, + tinychannels_bus::HOST_BUS_NAME, + ) + .expect("registered host callback proxy") + .call::("FixtureCallback", ()) + .await + .expect("registered callback is reachable at the returned path"), + "callback-live" + ); + assert!(lease.close().await.is_ok(), "real STOP unit reply is ACK"); + assert!( + fixture + .runtime + .connection() + .proxy( + tinychannels_bus::HOST_BUS_NAME, + &path, + tinychannels_bus::HOST_BUS_NAME, + ) + .expect("callback probe after STOP") + .call::("FixtureCallback", ()) + .await + .is_err(), + "the real STOP ACK releases the callback registration" + ); +} diff --git a/crates/tinychannels-client/tests/runtime_native_e2e.rs b/crates/tinychannels-client/tests/runtime_native_e2e.rs new file mode 100644 index 000000000..9c359ae50 --- /dev/null +++ b/crates/tinychannels-client/tests/runtime_native_e2e.rs @@ -0,0 +1,744 @@ +//! Identity-scoped client and SDK lease lifecycle over one pinned native fixture. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +mod support; + +use tinybus::{ + BusName, Error, Interface, InterfaceName, MemberName, ObjectPath, + module::{ModuleCallError, RetryPolicy, RuntimeLeaseDriver}, +}; +use tinychannels_bus::{ + ChannelInteraction, ChannelInteractionRequest, ChannelInteractionResult, ChannelOutboundIntent, + ChannelRuntimeDispatchPolicy, ChannelSupervisionPolicy, ChannelsConfig, HttpProxyPolicy, + module_control::{ChannelControlRequest, ChannelControlResult}, + module_progressive::{ProgressiveCommand, ProgressiveResult}, + module_relay::RelayReservation, + module_runtime::ChannelMessagePreparation, +}; +use tinychannels_client::{ChannelRuntimeClient, ChannelRuntimeErrorKind}; + +const FIRST_RUNTIME_HANDLE: u64 = 1; +const LAST_RUNTIME_HANDLE: u64 = 4; + +#[derive(Clone)] +struct FixtureCallback; + +#[async_trait::async_trait] +impl Interface for FixtureCallback { + fn name(&self) -> InterfaceName { + InterfaceName::new(tinychannels_bus::HOST_BUS_NAME) + .expect("TinyChannels host callback interface") + } + + fn members(&self) -> Vec { + vec![MemberName::new("FixtureCallback").expect("fixture callback method")] + } + + async fn call( + &self, + _: &MemberName, + _: serde_json::Value, + ) -> tinybus::Result { + Ok(serde_json::json!("callback-live")) + } +} + +struct UnattestedReplacementRoot(Arc); + +#[async_trait::async_trait] +impl Interface for UnattestedReplacementRoot { + fn name(&self) -> InterfaceName { + InterfaceName::new(tinychannels_bus::BUS_NAME).expect("TinyChannels module interface") + } + + fn members(&self) -> Vec { + vec![MemberName::new("FixtureReady").expect("readiness method")] + } + + async fn call( + &self, + member: &MemberName, + _: serde_json::Value, + ) -> tinybus::Result { + self.0.fetch_add(1, Ordering::SeqCst); + if member.as_str() == "FixtureReady" { + Ok(serde_json::json!(true)) + } else { + Err(Error::failed("unexpected replacement root call")) + } + } +} + +struct UnattestedReplacementObject(Arc); + +#[async_trait::async_trait] +impl Interface for UnattestedReplacementObject { + fn name(&self) -> InterfaceName { + InterfaceName::new(tinychannels_bus::BUS_NAME).expect("TinyChannels module interface") + } + + fn members(&self) -> Vec { + vec![ + MemberName::new(tinychannels_bus::methods::CHANNEL_STATUS) + .expect("channel status method"), + ] + } + + async fn call( + &self, + member: &MemberName, + _: serde_json::Value, + ) -> tinybus::Result { + self.0.fetch_add(1, Ordering::SeqCst); + if member.as_str() == tinychannels_bus::methods::CHANNEL_STATUS { + Ok(serde_json::json!("peer-b")) + } else { + Err(Error::failed("unexpected replacement object call")) + } + } +} + +fn runtime_handle(index: u64) -> String { + format!("{index:032x}") +} + +fn runtime_object_path(handle: u64) -> String { + format!( + "{}/runtime_{}", + tinychannels_bus::OBJECT_PATH, + runtime_handle(handle) + ) +} + +async fn callback_probe( + runtime: &'static tinybus::module::ModuleRuntime, + path: &str, +) -> tinybus::Result { + runtime + .connection() + .proxy( + tinychannels_bus::HOST_BUS_NAME, + path, + tinychannels_bus::HOST_BUS_NAME, + )? + .call("FixtureCallback", ()) + .await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn typed_driver_preserves_peer_through_dispatch_uncertainty_and_alias_handover() { + // This process starts exactly one ModuleRuntime. It loads the digest-pinned + // native A artifact and also owns the lease callback and replacement B. + let fixture = support::gated_runtime_fixture().await; + let startup_client = fixture.startup_client.clone(); + let before_startup_bind = fixture.readiness_attempts(); + let startup_bind = tokio::spawn(async move { startup_client.runtime_driver().await }); + let readiness_started = fixture.readiness_attempt_started(before_startup_bind).await; + let bind_pending_at_gate = !startup_bind.is_finished(); + fixture.release_ready(); + let startup_result = startup_bind.await; + let module_ready_after_release = fixture.module_ready(); + let startup_driver = startup_result + .expect("default-deadline cold bind task completes") + .expect("cold attested bind succeeds after setup publishes READY"); + assert!( + readiness_started, + "cold bind entered host readiness before the setup barrier was released" + ); + assert!( + bind_pending_at_gate, + "cold bind waits for host readiness while the fixture setup barrier is held" + ); + assert!( + module_ready_after_release, + "host observes READY for fixture A" + ); + drop(startup_driver); + assert!( + fixture.module_ready(), + "fixture startup readiness must complete before the two-second lease-operation deadline is applied" + ); + let serial_driver = fixture.client.runtime_driver().await.unwrap_or_else(|_| { + panic!( + "bind A before any runtime reservation; native fixture stage: {}", + fixture.bind_stage() + ) + }); + let dispatch_driver = fixture + .client + .runtime_driver_with_dispatch(ChannelRuntimeDispatchPolicy { listener_count: 2 }) + .await + .expect("bind A for an explicit dispatch opt-in"); + let start_uncertain_driver = fixture + .client + .runtime_driver() + .await + .expect("bind A for uncertain startup coverage"); + let stop_uncertain_driver = fixture + .client + .runtime_driver() + .await + .expect("bind A for uncertain shutdown coverage"); + let control = fixture + .control + .bind_attested("tinychannels") + .await + .expect("hold a test control reference to attested A"); + + let ready: bool = control + .call("FixtureReady", ()) + .await + .expect("A is live before alias handover"); + assert!(ready); + assert!( + control + .call::<()>( + tinychannels_bus::methods::START_RUNTIME, + (runtime_handle(FIRST_RUNTIME_HANDLE),), + ) + .await + .is_err(), + "the native fixture refuses an ordinary lifecycle call" + ); + fixture.reports.clear(); + control + .call::<()>("FixtureMalformedReservation", ()) + .await + .expect("configure one malformed reservation reply"); + let malformed_reservation = serial_driver + .reserve() + .await + .expect_err("malformed reservation fails closed"); + assert_eq!( + malformed_reservation.kind(), + ChannelRuntimeErrorKind::Module(ModuleCallError::IncompatibleContract) + ); + assert_eq!( + fixture.reports.entries(), + [( + "tinychannels".into(), + support::record(support::Pin::Correct).version.into(), + "incompatible_contract".into(), + )], + "the SDK reports the malformed reservation once" + ); + control + .call::<()>("FixturePauseReserve", ()) + .await + .expect("pause the next reservation reply from A"); + let serial_callback_path = Arc::new(Mutex::new(None::)); + let serial_callback_capture = Arc::clone(&serial_callback_path); + let serial_activation = fixture.runtime.lease( + serial_driver, + move |reservation, _| { + *serial_callback_capture + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(reservation.host_object_path.clone()); + Ok(FixtureCallback) + }, + RetryPolicy::new(Duration::from_secs(2), vec![]).expect("valid stop policy"), + |_| {}, + ); + let serial_activation = tokio::spawn(serial_activation); + control + .call::<()>("FixtureWaitReserve", ()) + .await + .expect("A is paused inside confidential ReserveRuntime"); + control + .call::<()>("FixtureReleaseAlias", ()) + .await + .expect("A releases its well-known name while Reserve is paused"); + + // B is intentionally an ordinary, unattested host object. It is only an + // availability/collision control and cannot receive confidential calls. + let replacement_calls = Arc::new(AtomicUsize::new(0)); + let _replacement = fixture + .runtime + .serve_host_object( + BusName::new(tinychannels_bus::BUS_NAME).expect("module bus name"), + ObjectPath::new(tinychannels_bus::OBJECT_PATH).expect("module root path"), + UnattestedReplacementRoot(Arc::clone(&replacement_calls)), + ) + .await + .expect("ordinary B can claim A's released alias"); + for handle in FIRST_RUNTIME_HANDLE..=LAST_RUNTIME_HANDLE { + fixture + .runtime + .connection() + .serve_at( + ObjectPath::new(runtime_object_path(handle)).expect("B collision path"), + UnattestedReplacementObject(Arc::clone(&replacement_calls)), + ) + .await + .expect("B serves a colliding private object path"); + } + let b_owner = fixture + .runtime + .connection() + .name_owner(tinychannels_bus::BUS_NAME) + .await + .expect("read B's owner") + .expect("B owns the alias"); + assert!(b_owner.is_unique()); + assert!( + fixture + .runtime + .connection() + .attestation(b_owner) + .await + .expect("public B attestation lookup") + .is_none() + ); + let b_ready: bool = fixture + .runtime + .connection() + .proxy( + tinychannels_bus::BUS_NAME, + tinychannels_bus::OBJECT_PATH, + tinychannels_bus::BUS_NAME, + ) + .expect("ordinary B root proxy") + .call("FixtureReady", ()) + .await + .expect("ordinary readiness proves B is available"); + assert!(b_ready); + for handle in FIRST_RUNTIME_HANDLE..=LAST_RUNTIME_HANDLE { + let b_status: String = fixture + .runtime + .connection() + .proxy( + tinychannels_bus::BUS_NAME, + runtime_object_path(handle), + tinychannels_bus::BUS_NAME, + ) + .expect("ordinary B object proxy") + .call(tinychannels_bus::methods::CHANNEL_STATUS, ("discord",)) + .await + .expect("ordinary status proves B's colliding object is live"); + assert_eq!(b_status, "peer-b"); + } + let b_calls_before_leases = replacement_calls.load(Ordering::SeqCst); + control + .call::<()>("FixtureReleaseReserve", ()) + .await + .expect("release the paused A reservation after B takes the alias"); + let serial_lease = serial_activation + .await + .expect("activation task") + .expect("legacy reserve/start/callback transaction stays on A"); + fixture.reports.clear(); + control + .call::<()>("FixtureMalformedStatus", ()) + .await + .expect("configure one malformed object response"); + let malformed_status = serial_lease + .object() + .channel_status("discord".into()) + .await + .expect_err("malformed typed object response fails closed"); + assert_eq!( + malformed_status.kind(), + ChannelRuntimeErrorKind::Module(ModuleCallError::IncompatibleContract) + ); + assert_eq!( + fixture.reports.entries(), + [( + "tinychannels".into(), + support::record(support::Pin::Correct).version.into(), + "incompatible_contract".into(), + )], + "the SDK reports the malformed object reply once" + ); + let serial_path = serial_callback_path + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + .expect("callback factory received A's returned path"); + assert_eq!( + serial_lease + .object() + .channel_status("discord".into()) + .await + .expect("positive typed object result from A after alias handover"), + "peer-a" + ); + + // Exact typed object request/response projections. The fixture decodes + // positional arguments and marks confidential members as such. + assert!( + serial_lease + .object() + .describe_configured_channels(ChannelsConfig::default(), HttpProxyPolicy::default()) + .await + .expect("confidential DescribeConfiguredChannels") + .is_empty() + ); + serial_lease + .object() + .start_channel_supervised( + "discord".into(), + ChannelsConfig::default(), + HttpProxyPolicy::default(), + ChannelSupervisionPolicy::default(), + ) + .await + .expect("confidential StartChannelSupervised"); + serial_lease + .object() + .start_outbound_channel( + "discord".into(), + ChannelsConfig::default(), + HttpProxyPolicy::default(), + ) + .await + .expect("confidential StartOutboundChannel"); + assert_eq!( + serial_lease + .object() + .prepare_channel_control(ChannelControlRequest::RuntimeLimits { + timeout_secs: 47, + listener_count: 2, + }) + .await + .expect("confidential PrepareChannelControl"), + ChannelControlResult::RuntimeLimits { + timeout_secs: 47, + max_in_flight: 8, + } + ); + assert_eq!( + serial_lease + .object() + .execute_progressive(ProgressiveCommand::Close { + handle: "progressive-fixture".into(), + }) + .await + .expect("confidential ExecuteProgressive"), + ProgressiveResult::Closed + ); + let prepared = serial_lease + .object() + .prepare_outbound_intent( + "discord".into(), + ChannelMessagePreparation::Legacy(serde_json::json!({"text":"fixture"})), + ) + .await + .expect("confidential PrepareOutboundIntent"); + assert!(prepared.legacy_message.is_null()); + assert!(matches!( + serial_lease + .object() + .channel_interaction(ChannelInteractionRequest { + channel: "discord".into(), + interaction: ChannelInteraction::Describe, + }) + .await + .expect("confidential ChannelInteraction"), + ChannelInteractionResult::Completed + )); + let relay = serial_lease + .object() + .reserve_relay() + .await + .expect("ordinary ReserveRelay"); + assert_eq!(relay, RelayReservation("fixture-relay".into())); + assert_eq!( + serial_lease + .object() + .start_relay( + relay.clone(), + tinychannels_bus::config::RelayRuntimeConfig::default() + ) + .await + .expect("confidential StartRelay response"), + tinychannels_bus::relay::CapabilityDescriptor::default() + ); + serial_lease + .object() + .stop_relay(relay.clone()) + .await + .expect("ordinary StopRelay"); + serial_lease + .object() + .send_channel_intent("discord".into(), ChannelOutboundIntent::default()) + .await + .expect("ordinary SendChannelIntent"); + serial_lease + .object() + .send_relay_intent(relay, ChannelOutboundIntent::default()) + .await + .expect("ordinary SendRelayIntent"); + serial_lease + .object() + .shutdown() + .await + .expect("ordinary Shutdown"); + + let ordinary_a_object = control + .object_client(&runtime_object_path(FIRST_RUNTIME_HANDLE)) + .expect("test-only held object on A"); + assert!( + ordinary_a_object + .call::>( + tinychannels_bus::methods::DESCRIBE_CONFIGURED_CHANNELS, + (ChannelsConfig::default(), HttpProxyPolicy::default()), + ) + .await + .is_err() + ); + assert!( + control + .call::<()>( + tinychannels_bus::methods::STOP_RUNTIME, + (runtime_handle(FIRST_RUNTIME_HANDLE),), + ) + .await + .is_err() + ); + assert!( + serial_lease.close().await.is_ok(), + "decoded STOP unit is ACK" + ); + assert!(callback_probe(fixture.runtime, &serial_path).await.is_err()); + + let dispatch_lease = fixture + .runtime + .lease( + dispatch_driver, + |_, _| Ok(FixtureCallback), + RetryPolicy::new(Duration::from_secs(2), vec![]).expect("valid dispatch stop policy"), + |_| {}, + ) + .await + .expect("explicit StartRuntimeWithDispatch path"); + assert_eq!( + dispatch_lease + .object() + .channel_status("discord".into()) + .await + .expect("dispatch object remains on A"), + "peer-a" + ); + assert!(dispatch_lease.close().await.is_ok()); + assert_eq!( + replacement_calls.load(Ordering::SeqCst), + b_calls_before_leases, + "positive A results and callback traffic show no lease call reached B" + ); + + control + .call::<()>("FixtureFailStart", ()) + .await + .expect("configure one opaque START failure"); + let uncertain_start_object = Arc::new(Mutex::new(None::)); + let uncertain_start_capture = Arc::clone(&uncertain_start_object); + let uncertain_start_path = Arc::new(Mutex::new(None::)); + let uncertain_start_path_capture = Arc::clone(&uncertain_start_path); + let start_result = fixture + .runtime + .lease( + start_uncertain_driver, + move |reservation, object| { + *uncertain_start_capture + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(object.clone()); + *uncertain_start_path_capture + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(reservation.host_object_path.clone()); + Ok(FixtureCallback) + }, + RetryPolicy::new(Duration::from_secs(2), vec![]).expect("valid start policy"), + |_| {}, + ) + .await; + assert!(start_result.is_err(), "opaque START failure is not success"); + let start_path = uncertain_start_path + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + .expect("callback factory received the returned start path"); + assert_eq!( + callback_probe(fixture.runtime, &start_path) + .await + .expect("unacknowledged START retains its callback"), + "callback-live" + ); + let uncertain_object = uncertain_start_object + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .expect("SDK retains the typed object during uncertain START") + .clone(); + assert_eq!( + uncertain_object + .channel_status("discord".into()) + .await + .expect("the captured object remains bound to A"), + "peer-a" + ); + let stops_after_uncertain_start: usize = control + .call("FixtureStopCallCount", ()) + .await + .expect("read stop count after uncertain start"); + assert_eq!( + stops_after_uncertain_start, 2, + "Unacknowledged START sends no STOP" + ); + + control + .call::<()>("FixtureFailStop", ()) + .await + .expect("configure one opaque cleanup-pending STOP failure"); + let uncertain_stop_path = Arc::new(Mutex::new(None::)); + let uncertain_stop_capture = Arc::clone(&uncertain_stop_path); + let stop_lease = fixture + .runtime + .lease( + stop_uncertain_driver, + move |reservation, _| { + *uncertain_stop_capture + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(reservation.host_object_path.clone()); + Ok(FixtureCallback) + }, + RetryPolicy::new(Duration::from_millis(100), vec![Duration::from_millis(1)]) + .expect("finite retry policy cannot retry opaque STOP"), + |_| {}, + ) + .await + .expect("start the final lease before uncertain STOP"); + assert!(stop_lease.close().await.is_err()); + assert!(stop_lease.close().await.is_err()); + let stops_after_uncertain_stop: usize = control + .call("FixtureStopCallCount", ()) + .await + .expect("read stop count after uncertain close"); + assert_eq!( + stops_after_uncertain_stop, 3, + "opaque cleanup-pending error does not authorize a second STOP" + ); + let stop_path = uncertain_stop_path + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + .expect("callback factory received the returned stop path"); + assert_eq!( + callback_probe(fixture.runtime, &stop_path) + .await + .expect("Unacknowledged STOP retains the callback"), + "callback-live" + ); + assert_eq!( + stop_lease + .object() + .channel_status("discord".into()) + .await + .expect("uncertain STOP retains the bound A object"), + "peer-a" + ); + assert_eq!( + replacement_calls.load(Ordering::SeqCst), + b_calls_before_leases, + "all positive A calls bypass the live unattested B" + ); + + let delivery_flags: Vec<(String, bool)> = control + .call("FixtureTakeDeliveryFlags", ()) + .await + .expect("read broker-authenticated call classifications"); + let confidential_methods = [ + tinychannels_bus::methods::RESERVE_RUNTIME, + tinychannels_bus::methods::START_RUNTIME, + tinychannels_bus::methods::START_RUNTIME_WITH_DISPATCH, + tinychannels_bus::methods::STOP_RUNTIME, + tinychannels_bus::methods::DESCRIBE_CONFIGURED_CHANNELS, + tinychannels_bus::methods::START_CHANNEL_SUPERVISED, + tinychannels_bus::methods::START_OUTBOUND_CHANNEL, + tinychannels_bus::methods::PREPARE_CHANNEL_CONTROL, + tinychannels_bus::methods::EXECUTE_PROGRESSIVE, + tinychannels_bus::methods::PREPARE_OUTBOUND_INTENT, + tinychannels_bus::methods::CHANNEL_INTERACTION, + tinychannels_bus::methods::START_RELAY, + ]; + let tested_methods = [ + tinychannels_bus::methods::RESERVE_RUNTIME, + tinychannels_bus::methods::START_RUNTIME, + tinychannels_bus::methods::START_RUNTIME_WITH_DISPATCH, + tinychannels_bus::methods::STOP_RUNTIME, + tinychannels_bus::methods::DESCRIBE_CONFIGURED_CHANNELS, + tinychannels_bus::methods::START_CHANNEL_SUPERVISED, + tinychannels_bus::methods::START_OUTBOUND_CHANNEL, + tinychannels_bus::methods::PREPARE_CHANNEL_CONTROL, + tinychannels_bus::methods::EXECUTE_PROGRESSIVE, + tinychannels_bus::methods::PREPARE_OUTBOUND_INTENT, + tinychannels_bus::methods::CHANNEL_INTERACTION, + tinychannels_bus::methods::START_RELAY, + tinychannels_bus::methods::RESERVE_RELAY, + tinychannels_bus::methods::STOP_RELAY, + tinychannels_bus::methods::CHANNEL_STATUS, + tinychannels_bus::methods::SEND_CHANNEL_INTENT, + tinychannels_bus::methods::SEND_RELAY_INTENT, + tinychannels_bus::methods::SHUTDOWN, + ]; + for method in tested_methods { + assert!( + delivery_flags.iter().any(|(seen, _)| seen == method), + "the fixture observed the typed wrapper call for {method}" + ); + } + for (method, confidential) in &delivery_flags { + assert_eq!( + *confidential, + confidential_methods.contains(&method.as_str()), + "delivery flag for {method}" + ); + } + + // The test-only control closes A's native service connection. Its held + // object fails while the ordinary B controls remain available. + let _ = control.call::<()>("FixtureDisconnect", ()).await; + assert!(control.call::("FixtureReady", ()).await.is_err()); + assert!( + stop_lease + .object() + .channel_status("discord".into()) + .await + .is_err() + ); + let b_ready_after_disconnect: bool = fixture + .control + .call("tinychannels", "FixtureReady", ()) + .await + .expect("ordinary B remains available after A disconnects"); + assert!(b_ready_after_disconnect); + let b_status_after_disconnect: String = fixture + .runtime + .connection() + .proxy( + tinychannels_bus::BUS_NAME, + runtime_object_path(LAST_RUNTIME_HANDLE), + tinychannels_bus::BUS_NAME, + ) + .expect("ordinary B object after A disconnect") + .call(tinychannels_bus::methods::CHANNEL_STATUS, ("discord",)) + .await + .expect("positive B object response after A disconnect"); + assert_eq!(b_status_after_disconnect, "peer-b"); + assert_eq!( + replacement_calls.load(Ordering::SeqCst), + b_calls_before_leases + 2, + "only the explicit post-disconnect ordinary B probes reached B" + ); +} diff --git a/crates/tinychannels-client/tests/runtime_readiness_e2e.rs b/crates/tinychannels-client/tests/runtime_readiness_e2e.rs new file mode 100644 index 000000000..695ba2d43 --- /dev/null +++ b/crates/tinychannels-client/tests/runtime_readiness_e2e.rs @@ -0,0 +1,73 @@ +//! Cold fixture readiness is separate from bounded runtime operations. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use std::time::Duration; + +mod support; + +use tinybus::module::ModuleCallError; +use tinychannels_client::ChannelRuntimeErrorKind; + +#[tokio::test] +async fn withheld_ready_fails_closed_within_the_client_deadline() { + // One ModuleRuntime owns the fixture and its gate for this process. The + // module serves its object and claims its name before entering the host + // callback gate, but TinyBus cannot publish READY until setup returns. + let fixture = support::gated_runtime_fixture().await; + let module_ready_before_bind = fixture.module_ready(); + + let limited_client = fixture.client.clone(); + let before_limited_bind = fixture.readiness_attempts(); + let limited_bind = tokio::spawn(async move { limited_client.runtime_driver().await }); + let readiness_started = fixture.readiness_attempt_started(before_limited_bind).await; + let bind_pending_at_gate = !limited_bind.is_finished(); + + // Native module startup and callback-gate entry above use real time. Pause + // only after preparation is blocked at READY, so advancing the host clock + // exercises the existing caller deadline without advancing the DLL clock. + tokio::time::pause(); + tokio::time::advance(Duration::from_secs(2)).await; + let bind_outcome = limited_bind.await; + let module_ready_after_timeout = fixture.module_ready(); + tokio::time::resume(); + + // Always release and drain the foreign setup callback before asserting the + // captured result, so a failed expectation cannot strand the DLL worker. + fixture.release_ready(); + let gate_callback_returned = fixture.wait_until_gate_callback_returns().await; + let _cleanup_result = fixture + .runtime + .host() + .ensure_ready(tinychannels_bus::BUS_NAME) + .await; + + let error = match bind_outcome { + Ok(Err(error)) => Some(error), + _ => None, + }; + assert!( + !module_ready_before_bind, + "host must not report gated A as READY" + ); + assert!( + readiness_started, + "client entered host readiness while native setup remained gated" + ); + assert!( + bind_pending_at_gate, + "a held READY barrier must keep the attested bind pending" + ); + assert!(gate_callback_returned, "released host callback must drain"); + assert!( + !module_ready_after_timeout, + "timed-out bind must not accept the withheld READY module" + ); + assert_eq!( + error.map(|error| error.kind()), + Some(ChannelRuntimeErrorKind::Module( + ModuleCallError::Unavailable + )), + "withheld READY must fail closed at the existing two-second client deadline" + ); +} diff --git a/crates/tinychannels-client/tests/support/mod.rs b/crates/tinychannels-client/tests/support/mod.rs new file mode 100644 index 000000000..76829fef9 --- /dev/null +++ b/crates/tinychannels-client/tests/support/mod.rs @@ -0,0 +1,922 @@ +//! Cached native module fixtures for the public client integration tests. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] +#![allow( + dead_code, + reason = "the native fixtures are shared by separate integration targets" +)] + +use std::{ + path::Path, + sync::{ + Arc, Mutex, OnceLock, + atomic::{AtomicU8, AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use tinybus::{ + Connection, Error, Interface, InterfaceName, MemberName, ObjectPath, Proxy, + broker::Broker, + module::{ + AccessFuture, CachedRelease, FailureReport, FailureReporter, FailureSink, HostObjectGuard, + ModuleAccess, ModuleCallError, ModuleCallFailure, ModuleClient, ModuleHost, ModuleRuntime, + ModuleState, ProxyFailure, ReleaseAsset, ReleaseRecord, RuntimeNames, + }, + transport::memory::MemoryBus, +}; +use tinychannels_client::TinyChannelsClient; +use tokio::sync::OnceCell; + +const MODULE_ID: &str = tinychannels_bus::MODULE_ID; +const RELEASE_URL: &str = "https://github.com/tinyhumansai/tinychannels/releases/tag/v0.2.0"; +const READINESS_GATE_PATH: &str = + "/ai/tinyhumans/tinychannels/ChannelsHost/test_fixture_ready_gate"; + +static RECORD: OnceLock = OnceLock::new(); +static NATIVE_MODULE_RECORD: OnceLock = OnceLock::new(); +static WRONG_ARCHIVE_RECORD: OnceLock = OnceLock::new(); +static WRONG_LIBRARY_RECORD: OnceLock = OnceLock::new(); + +#[derive(Default)] +pub(crate) struct Reports(Mutex>); + +impl FailureSink for Reports { + fn capture(&self, report: &FailureReport<'_>) { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(( + report.module.to_owned(), + report.version.to_owned(), + report.reason.code().to_owned(), + )); + } +} + +impl Reports { + pub(crate) fn clear(&self) { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + } + + pub(crate) fn entries(&self) -> Vec<(String, String, String)> { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +pub(crate) struct Access { + host: Arc, + connection: Connection, + loaded: Arc>, + record: &'static ReleaseRecord, + release: CachedRelease<'static>, + reporter: FailureReporter, +} + +impl ModuleAccess for Access { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + (id == self.record.id).then_some(self.record) + } + + fn enabled(&self) -> bool { + true + } + + fn reporter(&self) -> &FailureReporter { + &self.reporter + } + + fn ensure_loaded<'a>(&'a self, _id: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async move { + self.loaded + .get_or_try_init(|| async { + self.host + .load_github_release_cached(&self.release, serde_json::json!({})) + .map(|_| ()) + .map_err(|error| { + format!("the cached test module could not be admitted: {error}") + }) + }) + .await + .map(|_| ()) + }) + } + + fn prepare_attested<'a>(&'a self, id: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async move { + self.ensure_loaded(id).await?; + self.host + .ensure_ready(self.record.bus_name) + .await + .map_err(|_| "the cached test module did not become ready".to_owned()) + }) + } + + fn proxy<'a>( + &'a self, + bus_name: &'a str, + object_path: &'a str, + ) -> AccessFuture<'a, Result> { + Box::pin(async move { + self.connection + .proxy(bus_name, object_path, bus_name) + .map_err(|_| ProxyFailure::Contract) + }) + } +} + +struct SharedAccess(Arc); + +impl ModuleAccess for SharedAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + self.0.find(id) + } + + fn enabled(&self) -> bool { + self.0.enabled() + } + + fn reporter(&self) -> &FailureReporter { + self.0.reporter() + } + + fn ensure_loaded<'a>(&'a self, id: &'a str) -> AccessFuture<'a, Result<(), String>> { + self.0.ensure_loaded(id) + } + + fn prepare_attested<'a>(&'a self, id: &'a str) -> AccessFuture<'a, Result<(), String>> { + self.0.prepare_attested(id) + } + + fn proxy<'a>( + &'a self, + bus_name: &'a str, + object_path: &'a str, + ) -> AccessFuture<'a, Result> { + self.0.proxy(bus_name, object_path) + } +} + +struct WrongPinAccess { + access: Arc, + record: &'static ReleaseRecord, +} + +impl ModuleAccess for WrongPinAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + (id == self.record.id).then_some(self.record) + } + + fn enabled(&self) -> bool { + self.access.enabled() + } + + fn reporter(&self) -> &FailureReporter { + self.access.reporter() + } + + fn ensure_loaded<'a>(&'a self, id: &'a str) -> AccessFuture<'a, Result<(), String>> { + self.access.ensure_loaded(id) + } + + fn prepare_attested<'a>(&'a self, id: &'a str) -> AccessFuture<'a, Result<(), String>> { + self.access.prepare_attested(id) + } + + fn proxy<'a>( + &'a self, + bus_name: &'a str, + object_path: &'a str, + ) -> AccessFuture<'a, Result> { + self.access.proxy(bus_name, object_path) + } +} + +pub(crate) struct Fixture { + pub(crate) client: TinyChannelsClient, + pub(crate) wrong_archive_client: TinyChannelsClient, + pub(crate) wrong_library_client: TinyChannelsClient, + pub(crate) probe: Connection, + pub(crate) reports: Arc, + _host: Arc, + _broker_task: tokio::task::JoinHandle>, +} + +impl Drop for Fixture { + fn drop(&mut self) { + self._broker_task.abort(); + } +} + +pub(crate) async fn fixture() -> Fixture { + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.clone().spawn(bus.clone()); + let host = Arc::new(ModuleHost::new(broker)); + let probe = Connection::connect(bus.connect().await.expect("probe transport")) + .await + .expect("probe connection"); + let release_record = record(Pin::Correct); + let release = cached_release(); + let reports = Arc::new(Reports::default()); + let access = Arc::new(Access { + host: Arc::clone(&host), + connection: probe.clone(), + loaded: Arc::new(OnceCell::new()), + record: release_record, + release, + reporter: FailureReporter::new(reports.clone()), + }); + access + .ensure_loaded(MODULE_ID) + .await + .expect("cached pinned module should load without network access"); + host.ensure_ready(release_record.bus_name) + .await + .expect("cached pinned module should become ready"); + + let wrong_archive_client = + TinyChannelsClient::new(tinybus::module::ModuleClient::new(WrongPinAccess { + access: Arc::clone(&access), + record: record(Pin::WrongArchive), + })); + let wrong_library_client = + TinyChannelsClient::new(tinybus::module::ModuleClient::new(WrongPinAccess { + access: Arc::clone(&access), + record: record(Pin::WrongLibrary), + })); + Fixture { + client: TinyChannelsClient::new(tinybus::module::ModuleClient::new(SharedAccess(access))), + wrong_archive_client, + wrong_library_client, + probe, + reports, + _host: host, + _broker_task: broker_task, + } +} + +/// A locally cached native module loaded on the same broker used by SDK leases. +pub(crate) struct RuntimeFixture { + pub(crate) client: TinyChannelsClient, + pub(crate) startup_client: TinyChannelsClient, + pub(crate) control: ModuleClient, + pub(crate) runtime: &'static ModuleRuntime, + pub(crate) reports: Arc, + bind_stage: Arc, + readiness_attempts: Arc, + access: Arc, + readiness_gate: Option>, + _readiness_gate_guard: Option, +} + +#[derive(Clone, Copy)] +#[repr(u8)] +enum BindStage { + PreparationNotStarted, + CachedLoadInProgress, + CachedLoadFailed, + CachedLoadComplete, + ReadinessInProgress, + ReadinessFailed, + ReadyBeforePeerResolution, +} + +impl BindStage { + fn from_byte(stage: u8) -> Self { + match stage { + 1 => Self::CachedLoadInProgress, + 2 => Self::CachedLoadFailed, + 3 => Self::CachedLoadComplete, + 4 => Self::ReadinessInProgress, + 5 => Self::ReadinessFailed, + 6 => Self::ReadyBeforePeerResolution, + _ => Self::PreparationNotStarted, + } + } + + fn label(self) -> &'static str { + match self { + Self::PreparationNotStarted => "preparation_not_started", + Self::CachedLoadInProgress => "cached_load_in_progress", + Self::CachedLoadFailed => "cached_load_failed", + Self::CachedLoadComplete => "cached_load_complete", + Self::ReadinessInProgress => "readiness_in_progress", + Self::ReadinessFailed => "readiness_failed", + Self::ReadyBeforePeerResolution => "ready_before_peer_resolution", + } + } +} + +impl RuntimeFixture { + pub(crate) fn bind_stage(&self) -> &'static str { + BindStage::from_byte(self.bind_stage.load(Ordering::SeqCst)).label() + } + + pub(crate) fn readiness_attempts(&self) -> usize { + self.readiness_attempts.load(Ordering::SeqCst) + } + + pub(crate) fn module_ready(&self) -> bool { + self.runtime.host().list().iter().any(|module| { + module.manifest.bus_name.as_str() == tinychannels_bus::BUS_NAME + && matches!(module.state, ModuleState::Ready | ModuleState::Serving) + }) + } + + pub(crate) async fn wait_until_setup_is_held_before_ready(&self) { + self.readiness_gate + .as_ref() + .expect("gated fixture has a readiness gate") + .entered + .acquire() + .await + .expect("readiness gate stays open") + .forget(); + } + + pub(crate) fn release_ready(&self) { + self.readiness_gate + .as_ref() + .expect("gated fixture has a readiness gate") + .release + .add_permits(1); + } + + pub(crate) async fn wait_until_gate_callback_returns(&self) -> bool { + let Some(gate) = self.readiness_gate.as_ref() else { + return false; + }; + match tokio::time::timeout(Duration::from_secs(5), gate.returned.acquire()).await { + Ok(Ok(permit)) => { + permit.forget(); + true + } + Ok(Err(_)) | Err(_) => false, + } + } + + pub(crate) async fn readiness_attempt_started(&self, previous: usize) -> bool { + for _ in 0..10_000 { + if self.readiness_attempts() > previous { + return self.bind_stage() == "readiness_in_progress"; + } + tokio::task::yield_now().await; + } + false + } +} + +struct ReadinessGate { + entered: tokio::sync::Semaphore, + release: tokio::sync::Semaphore, + returned: tokio::sync::Semaphore, +} + +struct ReadinessGateHost(Arc); + +#[async_trait::async_trait] +impl Interface for ReadinessGateHost { + fn name(&self) -> InterfaceName { + InterfaceName::new(tinychannels_bus::HOST_BUS_NAME).expect("TinyChannels host bus name") + } + + fn members(&self) -> Vec { + vec![MemberName::new("WaitBeforeReady").expect("fixture readiness gate method")] + } + + async fn call( + &self, + _: &MemberName, + _: serde_json::Value, + ) -> tinybus::Result { + self.0.entered.add_permits(1); + self.0 + .release + .acquire() + .await + .map_err(|_| Error::failed("fixture readiness gate closed"))? + .forget(); + self.0.returned.add_permits(1); + Ok(serde_json::Value::Null) + } +} + +struct RuntimeAccess { + runtime: &'static ModuleRuntime, + loaded: OnceCell<()>, + record: &'static ReleaseRecord, + release: CachedRelease<'static>, + reporter: FailureReporter, + bind_stage: Arc, + readiness_attempts: Arc, + hold_ready: bool, +} + +struct SharedRuntimeAccess(Arc); + +impl ModuleAccess for SharedRuntimeAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + self.0.find(id) + } + + fn enabled(&self) -> bool { + self.0.enabled() + } + + fn reporter(&self) -> &FailureReporter { + self.0.reporter() + } + + fn ensure_loaded<'a>(&'a self, id: &'a str) -> AccessFuture<'a, Result<(), String>> { + self.0.ensure_loaded(id) + } + + fn prepare_attested<'a>(&'a self, id: &'a str) -> AccessFuture<'a, Result<(), String>> { + self.0.prepare_attested(id) + } + + fn proxy<'a>( + &'a self, + bus_name: &'a str, + object_path: &'a str, + ) -> AccessFuture<'a, Result> { + self.0.proxy(bus_name, object_path) + } +} + +impl ModuleAccess for RuntimeAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + (id == self.record.id).then_some(self.record) + } + + fn enabled(&self) -> bool { + true + } + + fn reporter(&self) -> &FailureReporter { + &self.reporter + } + + fn ensure_loaded<'a>(&'a self, _: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async move { + let loaded = self + .loaded + .get_or_try_init(|| async { + self.bind_stage + .store(BindStage::CachedLoadInProgress as u8, Ordering::SeqCst); + let result = self + .runtime + .host() + .load_github_release_cached( + &self.release, + if self.hold_ready { + serde_json::json!({ "hold_ready": true }) + } else { + serde_json::json!({}) + }, + ) + .map(|_| ()); + if result.is_err() { + self.bind_stage + .store(BindStage::CachedLoadFailed as u8, Ordering::SeqCst); + } + result.map_err(|error| { + format!("the locally pinned native fixture could not load: {error}") + }) + }) + .await + .map(|_| ()); + if loaded.is_ok() { + self.bind_stage + .store(BindStage::CachedLoadComplete as u8, Ordering::SeqCst); + } + loaded + }) + } + + fn prepare_attested<'a>(&'a self, id: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async move { + self.ensure_loaded(id).await?; + self.readiness_attempts.fetch_add(1, Ordering::SeqCst); + self.bind_stage + .store(BindStage::ReadinessInProgress as u8, Ordering::SeqCst); + self.runtime + .host() + .ensure_ready(self.record.bus_name) + .await + .map(|()| { + self.bind_stage + .store(BindStage::ReadyBeforePeerResolution as u8, Ordering::SeqCst) + }) + .map_err(|_| { + self.bind_stage + .store(BindStage::ReadinessFailed as u8, Ordering::SeqCst); + "the locally pinned native fixture did not become ready".to_owned() + }) + }) + } + + fn proxy<'a>( + &'a self, + bus_name: &'a str, + object_path: &'a str, + ) -> AccessFuture<'a, Result> { + Box::pin(async move { + self.runtime + .connection() + .proxy(bus_name, object_path, bus_name) + .map_err(|_| ProxyFailure::Contract) + }) + } +} + +pub(crate) async fn gated_runtime_fixture() -> RuntimeFixture { + runtime_fixture_with_readiness().await +} + +async fn runtime_fixture_with_readiness() -> RuntimeFixture { + let runtime = Box::leak(Box::new( + ModuleRuntime::start(&RuntimeNames::default(), None) + .expect("start one runtime for module and host callbacks"), + )); + let reports = Arc::new(Reports::default()); + let bind_stage = Arc::new(AtomicU8::new(0)); + let readiness_attempts = Arc::new(AtomicUsize::new(0)); + let readiness_gate = Arc::new(ReadinessGate { + entered: tokio::sync::Semaphore::new(0), + release: tokio::sync::Semaphore::new(0), + returned: tokio::sync::Semaphore::new(0), + }); + let readiness_gate_guard = runtime + .serve_host_object( + tinybus::BusName::new(tinychannels_bus::HOST_BUS_NAME) + .expect("TinyChannels host bus name"), + ObjectPath::new(READINESS_GATE_PATH).expect("fixture readiness gate path"), + ReadinessGateHost(Arc::clone(&readiness_gate)), + ) + .await + .expect("serve fixture readiness gate before loading the module"); + let access = Arc::new(RuntimeAccess { + runtime, + loaded: OnceCell::new(), + record: record(Pin::Correct), + release: cached_release(), + reporter: FailureReporter::new(reports.clone()), + bind_stage: Arc::clone(&bind_stage), + readiness_attempts: Arc::clone(&readiness_attempts), + hold_ready: true, + }); + let startup_client = + TinyChannelsClient::new(ModuleClient::new(SharedRuntimeAccess(Arc::clone(&access)))); + let control = ModuleClient::new(SharedRuntimeAccess(Arc::clone(&access))) + .with_timeout(Duration::from_secs(2)); + let client = TinyChannelsClient::new(control.clone()); + let fixture = RuntimeFixture { + client, + startup_client, + control, + runtime, + reports, + bind_stage, + readiness_attempts, + access, + readiness_gate: Some(readiness_gate), + _readiness_gate_guard: Some(readiness_gate_guard), + }; + fixture + .access + .ensure_loaded(MODULE_ID) + .await + .expect("gated native fixture loads before waiting at the READY barrier"); + fixture.wait_until_setup_is_held_before_ready().await; + fixture +} + +/// The actual TinyChannels module cdylib, staged locally with both exact hashes. +pub(crate) async fn native_module_runtime_fixture() -> RuntimeFixture { + let runtime = Box::leak(Box::new( + ModuleRuntime::start(&RuntimeNames::default(), None) + .expect("start one runtime for native module and host callbacks"), + )); + let reports = Arc::new(Reports::default()); + let bind_stage = Arc::new(AtomicU8::new(0)); + let readiness_attempts = Arc::new(AtomicUsize::new(0)); + let access = Arc::new(RuntimeAccess { + runtime, + loaded: OnceCell::new(), + record: native_module_record(), + release: cached_release_with_prefix( + "TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST", + env_prefixed("TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST", "RELEASE_URL"), + ), + reporter: FailureReporter::new(reports.clone()), + bind_stage: Arc::clone(&bind_stage), + readiness_attempts: Arc::clone(&readiness_attempts), + hold_ready: false, + }); + let control = ModuleClient::new(SharedRuntimeAccess(Arc::clone(&access))); + RuntimeFixture { + client: TinyChannelsClient::new(control.clone()), + startup_client: TinyChannelsClient::new(control.clone()), + control, + runtime, + reports, + bind_stage, + readiness_attempts, + access, + readiness_gate: None, + _readiness_gate_guard: None, + } +} + +pub(crate) struct UnattestedFixture { + pub(crate) client: TinyChannelsClient, + pub(crate) calls: Arc, + pub(crate) probe: Connection, + _service: Connection, + _broker_task: tokio::task::JoinHandle>, +} + +pub(crate) async fn unattested_fixture() -> UnattestedFixture { + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct Unattested(Arc); + + #[async_trait::async_trait] + impl Interface for Unattested { + fn name(&self) -> InterfaceName { + InterfaceName::new(tinychannels_bus::BUS_NAME).expect("valid module name") + } + + fn members(&self) -> Vec { + ["ChannelController", "CheckChannelHealth", "FixtureReady"] + .into_iter() + .map(|method| MemberName::new(method).expect("valid method")) + .collect() + } + + fn requires_confidential(&self, member: &MemberName) -> bool { + matches!(member.as_str(), "ChannelController" | "CheckChannelHealth") + } + + async fn call( + &self, + member: &MemberName, + _args: serde_json::Value, + ) -> tinybus::Result { + if member.as_str() == "FixtureReady" { + return Ok(serde_json::json!(true)); + } + self.0.fetch_add(1, Ordering::SeqCst); + Ok(serde_json::json!({"result":"discord_guilds","value":{"raw":[]}})) + } + } + + let calls = Arc::new(AtomicUsize::new(0)); + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let service = Connection::connect(bus.connect().await.expect("service transport")) + .await + .expect("service connection"); + service + .serve_at( + ObjectPath::new(tinychannels_bus::OBJECT_PATH).expect("valid object path"), + Unattested(Arc::clone(&calls)), + ) + .await + .expect("serve unattested peer"); + service + .request_name(tinychannels_bus::BUS_NAME) + .await + .expect("own untrusted well-known name"); + let client_connection = Connection::connect(bus.connect().await.expect("client transport")) + .await + .expect("client connection"); + let owner = client_connection + .name_owner(tinychannels_bus::BUS_NAME) + .await + .expect("query peer owner") + .expect("peer owns the module name"); + assert!(owner.is_unique()); + + let access = UnattestedAccess { + connection: client_connection.clone(), + record: record(Pin::Correct), + reporter: FailureReporter::new(Arc::new(Reports::default())), + }; + UnattestedFixture { + client: TinyChannelsClient::new(tinybus::module::ModuleClient::new(access)), + calls, + probe: client_connection, + _service: service, + _broker_task: broker_task, + } +} + +pub(crate) async fn assert_unattested_peer_live(probe: &Connection) -> tinybus::BusName { + let owner = probe + .name_owner(tinychannels_bus::BUS_NAME) + .await + .expect("query unattested peer owner") + .expect("unattested peer remains connected"); + assert!(owner.is_unique()); + assert!( + probe + .attestation(owner.clone()) + .await + .expect("public peer proof lookup") + .is_none() + ); + let readiness = probe + .proxy( + tinychannels_bus::BUS_NAME, + tinychannels_bus::OBJECT_PATH, + tinychannels_bus::BUS_NAME, + ) + .expect("unattested readiness proxy") + .call::("FixtureReady", ()) + .await + .expect("content-free readiness probe"); + assert!(readiness); + owner +} + +struct UnattestedAccess { + connection: Connection, + record: &'static ReleaseRecord, + reporter: FailureReporter, +} + +impl ModuleAccess for UnattestedAccess { + fn find(&self, id: &str) -> Option<&'static ReleaseRecord> { + (id == self.record.id).then_some(self.record) + } + + fn enabled(&self) -> bool { + true + } + + fn reporter(&self) -> &FailureReporter { + &self.reporter + } + + fn ensure_loaded<'a>(&'a self, _id: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async { Ok(()) }) + } + + fn prepare_attested<'a>(&'a self, _id: &'a str) -> AccessFuture<'a, Result<(), String>> { + Box::pin(async { Ok(()) }) + } + + fn proxy<'a>( + &'a self, + bus_name: &'a str, + object_path: &'a str, + ) -> AccessFuture<'a, Result> { + Box::pin(async move { + self.connection + .proxy(bus_name, object_path, bus_name) + .map_err(|_| ProxyFailure::Contract) + }) + } +} + +pub(crate) async fn calls(probe: &Connection) -> usize { + let proxy = probe + .proxy( + tinychannels_bus::BUS_NAME, + tinychannels_bus::OBJECT_PATH, + tinychannels_bus::BUS_NAME, + ) + .expect("fixture probe proxy"); + proxy + .call("FixtureCallCount", ()) + .await + .expect("read fixture call count") +} + +pub(crate) fn record(pin: Pin) -> &'static ReleaseRecord { + let slot = match pin { + Pin::Correct => &RECORD, + Pin::WrongArchive => &WRONG_ARCHIVE_RECORD, + Pin::WrongLibrary => &WRONG_LIBRARY_RECORD, + }; + slot.get_or_init(|| { + let host_key = Box::leak( + tinybus::module::platform::host_candidates() + .into_iter() + .next() + .expect("test platform has a TinyBus module key") + .into_boxed_str(), + ); + let archive = env("TINYCHANNELS_CLIENT_TEST_ARCHIVE_NAME"); + let archive_sha = match pin { + Pin::WrongArchive => Box::leak("0".repeat(64).into_boxed_str()), + _ => env("TINYCHANNELS_CLIENT_TEST_ARCHIVE_SHA256"), + }; + let library_sha = match pin { + Pin::WrongLibrary => Box::leak("f".repeat(64).into_boxed_str()), + _ => env("TINYCHANNELS_CLIENT_TEST_LIBRARY_SHA256"), + }; + let assets = Box::leak( + vec![ReleaseAsset { + host_key, + archive, + sha256: archive_sha, + library_sha256: Some(library_sha), + }] + .into_boxed_slice(), + ); + ReleaseRecord { + id: MODULE_ID, + bus_name: tinychannels_bus::BUS_NAME, + object_path: tinychannels_bus::OBJECT_PATH, + version: "0.2.0", + release_url: RELEASE_URL, + assets, + } + }) +} + +#[derive(Clone, Copy)] +pub(crate) enum Pin { + Correct, + WrongArchive, + WrongLibrary, +} + +fn cached_release() -> CachedRelease<'static> { + cached_release_with_prefix("TINYCHANNELS_CLIENT_TEST", RELEASE_URL) +} + +fn cached_release_with_prefix(prefix: &str, release_url: &'static str) -> CachedRelease<'static> { + CachedRelease { + release_url, + asset_name: env_prefixed(prefix, "ARCHIVE_NAME"), + expected_sha256: Some(env_prefixed(prefix, "ARCHIVE_SHA256")), + expected_library_sha256: Some(env_prefixed(prefix, "LIBRARY_SHA256")), + cache_dir: Path::new(env_prefixed(prefix, "CACHE")), + allow_download: false, + } +} + +fn native_module_record() -> &'static ReleaseRecord { + NATIVE_MODULE_RECORD.get_or_init(|| { + let host_key = Box::leak( + tinybus::module::platform::host_candidates() + .into_iter() + .next() + .expect("test platform has a TinyBus module key") + .into_boxed_str(), + ); + let archive = env_prefixed("TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST", "ARCHIVE_NAME"); + let archive_sha = env_prefixed("TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST", "ARCHIVE_SHA256"); + let library_sha = env_prefixed("TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST", "LIBRARY_SHA256"); + let assets = Box::leak( + vec![ReleaseAsset { + host_key, + archive, + sha256: archive_sha, + library_sha256: Some(library_sha), + }] + .into_boxed_slice(), + ); + ReleaseRecord { + id: MODULE_ID, + bus_name: tinychannels_bus::BUS_NAME, + object_path: tinychannels_bus::OBJECT_PATH, + version: "0.2.0", + release_url: env_prefixed("TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST", "RELEASE_URL"), + assets, + } + }) +} + +fn env(name: &str) -> &'static str { + Box::leak( + std::env::var(name) + .unwrap_or_else(|_| panic!("{name} must identify the staged local fixture")) + .into_boxed_str(), + ) +} + +fn env_prefixed(prefix: &str, suffix: &str) -> &'static str { + let name = format!("{prefix}_{suffix}"); + Box::leak( + std::env::var(&name) + .unwrap_or_else(|_| panic!("{name} must identify the staged local fixture")) + .into_boxed_str(), + ) +} + +pub(crate) fn assert_unavailable(error: ModuleCallFailure) { + assert_eq!(error.kind, ModuleCallError::Unavailable); +} diff --git a/crates/tinychannels-module/Cargo.toml b/crates/tinychannels-module/Cargo.toml index b2a72c4eb..5b55aae2a 100644 --- a/crates/tinychannels-module/Cargo.toml +++ b/crates/tinychannels-module/Cargo.toml @@ -21,22 +21,22 @@ crate-type = ["rlib", "cdylib"] # fails as `NotConfigured`. Hosts that link the module only to exercise the # other providers (for example a test fixture) can opt out with # `default-features = false`. -tinychannels = { path = "../..", default-features = false, features = ["email", "lark", "relay-websocket"] } +tinychannels = { path = "../..", default-features = false, features = ["email", "lark", "relay-websocket", "imessage"] } # The transport-free vocabulary shared with the host. tinychannels-bus = { path = "../tinychannels-bus" } tinychannels-runtime = { path = "../tinychannels-runtime" } tinyvoice-bus = { git = "https://github.com/tinyhumansai/tinyvoice", rev = "cf6444424149bbc8dc2303bddbd9b366abb29933" } # TinyBus provides the typed service interface and dynamic module host ABI. -tinybus = { version = ">=0.3.6, <0.4", default-features = false, features = ["macros", "modules"] } +tinybus = { version = "=0.3.12", git = "https://github.com/senamakel/tinybus", tag = "v0.3.12", default-features = false, features = ["macros", "modules"] } # The module-side SDK owns its runtime and exports the stable C entrypoints. -tinybus-module = ">=0.3.6, <0.4" +tinybus-module = { version = "=0.3.12", git = "https://github.com/senamakel/tinybus", tag = "v0.3.12" } # Provider listen loops are long-lived tasks the module owns. tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } # Storage over the bus: the module reaches host storage through the same port # traits a linked library gets, never through per-module persistence callbacks. -tinystoragedrivers-api = { git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "bf1f0a97fbe6165a6edd9521659c7f6859700761" } -tinystoragedrivers-types = { git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "bf1f0a97fbe6165a6edd9521659c7f6859700761" } -tinystoragedrivers-busproxy = { version = "2.1.1", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "bf1f0a97fbe6165a6edd9521659c7f6859700761" } +tinystoragedrivers-api = { git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "6226b9484c8bd01c58741718cd89ba90a13a36bb" } +tinystoragedrivers-types = { git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "6226b9484c8bd01c58741718cd89ba90a13a36bb" } +tinystoragedrivers-busproxy = { version = "2.1.2", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "6226b9484c8bd01c58741718cd89ba90a13a36bb" } serde_json = "1" serde = { version = "1", features = ["derive"] } sha2 = "0.11" @@ -53,8 +53,8 @@ base64 = "0.23" [dev-dependencies] tinychannels = { path = "../.." } tinychannels-conformance = { path = "../tinychannels-conformance" } -tinybus = { version = ">=0.3.6, <0.4", default-features = false, features = ["test-support"] } -tinystoragedrivers-testkit = { git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "bf1f0a97fbe6165a6edd9521659c7f6859700761" } +tinybus = { workspace = true, features = ["test-support"] } +tinystoragedrivers-testkit = { git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "6226b9484c8bd01c58741718cd89ba90a13a36bb" } tempfile = "3" tokio = { version = "1", features = ["test-util"] } serde_json = "1" diff --git a/crates/tinychannels-module/src/control.rs b/crates/tinychannels-module/src/control.rs index db40240de..335414273 100644 --- a/crates/tinychannels-module/src/control.rs +++ b/crates/tinychannels-module/src/control.rs @@ -66,7 +66,7 @@ pub(crate) async fn prepare( host, RemoteOperation::Execute { workspace_dir: workspace_dir.clone(), - message: (*message).clone(), + message: Box::new((*message).clone()), route: route.clone(), history_len, session_scope: session_scope.map(|scope| *scope), diff --git a/crates/tinychannels-module/src/delivery.rs b/crates/tinychannels-module/src/delivery.rs index 90847e5a3..ae2ca00eb 100644 --- a/crates/tinychannels-module/src/delivery.rs +++ b/crates/tinychannels-module/src/delivery.rs @@ -35,7 +35,7 @@ impl HostDeliveryStore { self.host .documents() .await - .map(DocumentDeliveryStore::new) + .map(DocumentDeliveryStore::from_prepared) .map_err(storage_error) }) .await diff --git a/crates/tinychannels-module/src/host.rs b/crates/tinychannels-module/src/host.rs index 11c78f0a8..c51b93de4 100644 --- a/crates/tinychannels-module/src/host.rs +++ b/crates/tinychannels-module/src/host.rs @@ -49,7 +49,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex as StdMutex}; use std::time::Duration; -use tinybus::{Connection, Error, Result}; +use tinybus::{BusName, Connection, Error, Result}; use tinychannels_bus::ChannelEvent; use tinychannels_bus::names::{HOST_BUS_NAME, HOST_OBJECT_PATH, host_methods}; use tinychannels_bus::{ChannelBackendRequest, ChannelBackendResult}; @@ -131,6 +131,8 @@ pub struct HostChannels { owner: Arc, connection: Connection, object_path: Arc, + /// Optional unique host callback destination supplied by the loader. + storage_callback: Option, /// The host's storage objects, connected on first use and then shared by /// every clone. storage: Arc>>, @@ -143,12 +145,27 @@ impl std::fmt::Debug for HostChannels { } } impl HostChannels { + #[cfg(test)] pub(crate) fn new_at_with_dispatch( connection: Connection, object_path: String, max_in_flight: usize, ) -> Self { - Self::new_at_inner(connection, object_path, Some(max_in_flight)) + Self::new_at_inner(connection, object_path, Some(max_in_flight), None) + } + + pub(crate) fn new_at_with_dispatch_and_storage_callback( + connection: Connection, + object_path: String, + max_in_flight: usize, + storage_callback: Option, + ) -> Self { + Self::new_at_inner( + connection, + object_path, + Some(max_in_flight), + storage_callback, + ) } /// Start one owned callback consumer. Explicit shutdown drains and joins it. @@ -157,15 +174,36 @@ impl HostChannels { Self::new_at(connection, HOST_OBJECT_PATH.to_owned()) } + pub(crate) fn new_with_storage_callback( + connection: Connection, + storage_callback: Option, + ) -> Self { + Self::new_at_inner( + connection, + HOST_OBJECT_PATH.to_owned(), + None, + storage_callback, + ) + } + /// Bind all callbacks to an immutable runtime-specific host object. pub(crate) fn new_at(connection: Connection, object_path: String) -> Self { - Self::new_at_inner(connection, object_path, None) + Self::new_at_inner(connection, object_path, None, None) + } + + pub(crate) fn new_at_with_storage_callback( + connection: Connection, + object_path: String, + storage_callback: Option, + ) -> Self { + Self::new_at_inner(connection, object_path, None, storage_callback) } fn new_at_inner( connection: Connection, object_path: String, dispatch_limit: Option, + storage_callback: Option, ) -> Self { let object_path: Arc = object_path.into(); let callback_path = object_path.clone(); @@ -314,6 +352,7 @@ impl HostChannels { outbox, connection, object_path, + storage_callback, storage: Arc::new(Mutex::new(None)), owner: Arc::new(Owner { worker: Mutex::new(Some(worker)), @@ -343,11 +382,35 @@ impl HostChannels { if let Some(scoped) = slot.as_ref() { return Ok(scoped.clone()); } - let storage = tinystoragedrivers_busproxy::connect_storage(self.connection.clone()).await?; + let storage = match &self.storage_callback { + Some(destination) => { + tinystoragedrivers_busproxy::connect_storage_at( + self.connection.clone(), + destination.clone(), + ) + .await? + } + None => tinystoragedrivers_busproxy::connect_storage(self.connection.clone()).await?, + }; + self.prepare_storage_schema(storage.documents()).await?; *slot = Some(storage.clone()); Ok(storage) } + async fn prepare_storage_schema( + &self, + documents: &Arc, + ) -> tinystoragedrivers_types::Result<()> { + // A callback host installs the owner's declarations before granting + // access. Its module-facing port deliberately denies schema mutation. + // The legacy standalone storage route still owns its preparation. + if self.storage_callback.is_some() { + Ok(()) + } else { + tinychannels::storage::prepare(documents).await + } + } + /// The scoped document store, for the delivery, remote-session and /// WhatsApp session records. pub(crate) async fn documents( diff --git a/crates/tinychannels-module/src/host_tests.rs b/crates/tinychannels-module/src/host_tests.rs index 8709cc90a..92b5580db 100644 --- a/crates/tinychannels-module/src/host_tests.rs +++ b/crates/tinychannels-module/src/host_tests.rs @@ -3,6 +3,43 @@ use std::sync::atomic::AtomicUsize; use tinybus::broker::Broker; use tinybus::transport::memory::MemoryBus; +#[tokio::test] +async fn callback_storage_reuses_the_schema_owned_by_the_host() { + use tinystoragedrivers_api::StorageBackend; + use tinystoragedrivers_testkit::{Failing, FaultPlan, MemoryBackend}; + use tinystoragedrivers_types::{ErrorKind, Scope}; + + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let connection = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let backend = MemoryBackend::new(); + let storage = backend + .for_scope(&Scope::new("tenant:schema-owner").unwrap()) + .unwrap(); + let faults = FaultPlan::new(); + faults.fail_operation_nth("documents.ensure_collection", 1, ErrorKind::Unavailable); + let documents: Arc = + Arc::new(Failing::with_plan(Arc::clone(storage.documents()), faults)); + let callback = HostChannels::new_with_storage_callback( + connection.clone(), + Some(":1.999".try_into().unwrap()), + ); + callback + .prepare_storage_schema(&documents) + .await + .expect("callback clients must reuse the schema declared by the host"); + + let legacy = HostChannels::new(connection); + assert!( + legacy.prepare_storage_schema(&documents).await.is_err(), + "the standalone legacy client still prepares its own schema" + ); + callback.shutdown().await.unwrap(); + legacy.shutdown().await.unwrap(); +} + fn consume_failure(counter: &AtomicUsize) -> bool { let mut remaining = counter.load(Ordering::SeqCst); loop { @@ -1644,3 +1681,35 @@ async fn module_voice_store_reads_back_what_it_stored_for_the_same_scope() { assert!(store.read_voice(other_turn, opaque).await.is_err()); host.shutdown().await.unwrap(); } + +#[tokio::test] +async fn invalid_bound_storage_callback_never_falls_back_to_legacy_storage() { + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let connection = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let _legacy = crate::storage_fixture::StorageHost::serve(&connection, "legacy-fallback").await; + let destination: tinybus::BusName = ":1.999".try_into().unwrap(); + let host = HostChannels::new_with_storage_callback(connection, Some(destination)); + + assert!( + host.storage().await.is_err(), + "a configured but invalid callback route must fail even when legacy storage is available" + ); + host.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn host_debug_view_does_not_expose_callback_state() { + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let connection = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let host = HostChannels::new(connection); + + assert_eq!(format!("{host:?}"), "HostChannels { .. }"); + + host.shutdown().await.unwrap(); +} diff --git a/crates/tinychannels-module/src/progressive_tests.rs b/crates/tinychannels-module/src/progressive_tests.rs index 36485c45a..c3dcae2de 100644 --- a/crates/tinychannels-module/src/progressive_tests.rs +++ b/crates/tinychannels-module/src/progressive_tests.rs @@ -1276,6 +1276,16 @@ async fn final_output_terminates_without_a_second_legacy_send_and_replays_receip .count(), 1 ); + assert!( + matches!( + requests + .iter() + .find(|request| matches!(request, ProgressiveTransportRequest::SendTyped { .. })), + Some(ProgressiveTransportRequest::SendTyped { text, parts, .. }) + if text == "final" && parts.is_empty() + ), + "a text-only final stays plain text in the typed delivery request" + ); assert_eq!( requests .iter() @@ -2037,6 +2047,7 @@ fn output_scope_accepts_a_hosted_turn_lease_and_rejects_longer_ones() { assert!(scope(3_660_000).is_ok(), "a one-hour turn lease must open"); assert!(scope(3_700_000).is_ok()); assert!(scope(3_700_001).is_err()); + assert!(scope(u64::MAX).is_err()); assert!(scope(0).is_err()); } diff --git a/crates/tinychannels-module/src/provider.rs b/crates/tinychannels-module/src/provider.rs index 07348aa91..764868cc3 100644 --- a/crates/tinychannels-module/src/provider.rs +++ b/crates/tinychannels-module/src/provider.rs @@ -1173,7 +1173,7 @@ impl Running { drafts.as_ref(), &configured_instance, previous, - intent, + *intent, ) .await } diff --git a/crates/tinychannels-module/src/provider_tests.rs b/crates/tinychannels-module/src/provider_tests.rs index a4c450b5d..a95ef078b 100644 --- a/crates/tinychannels-module/src/provider_tests.rs +++ b/crates/tinychannels-module/src/provider_tests.rs @@ -644,14 +644,14 @@ async fn outbound_only_provider_dispatches_draft_upsert_through_retained_draft_p let result = running .interact(tinychannels_bus::ChannelInteraction::UpsertDraft { previous: None, - intent: tinychannels_bus::ChannelOutboundIntent { + intent: Box::new(tinychannels_bus::ChannelOutboundIntent { idempotency_key: "draft-worker".into(), conversation_id: "room".into(), payload: tinychannels_bus::OutboundPayload::Text { text: "draft".into(), }, ..Default::default() - }, + }), }) .await .unwrap_or_else(|error| panic!("{error}")); @@ -675,7 +675,7 @@ async fn outbound_only_provider_rejects_draft_upsert_without_a_draft_port() { let error = running .interact(tinychannels_bus::ChannelInteraction::UpsertDraft { previous: None, - intent: tinychannels_bus::ChannelOutboundIntent::default(), + intent: Box::default(), }) .await .unwrap_err(); diff --git a/crates/tinychannels-module/src/remote_tests.rs b/crates/tinychannels-module/src/remote_tests.rs index 09f4a34bd..951e8d239 100644 --- a/crates/tinychannels-module/src/remote_tests.rs +++ b/crates/tinychannels-module/src/remote_tests.rs @@ -89,7 +89,9 @@ impl Callback { Ok(ChannelBackendResult::RemoteControl(result)) } } -async fn fixture(_workspace: &std::path::Path) -> (HostChannels, Callback) { +async fn fixture( + _workspace: &std::path::Path, +) -> (HostChannels, Callback, crate::storage_fixture::StorageHost) { let bus = MemoryBus::new(); Broker::new().spawn(bus.clone()); let connection = tinybus::Connection::connect(bus.connect().await.unwrap()) @@ -113,23 +115,27 @@ async fn fixture(_workspace: &std::path::Path) -> (HostChannels, Callback) { ) .await .unwrap(); - (HostChannels::new(connection), callback) + let host = HostChannels::new(connection); + host.storage() + .await + .unwrap_or_else(|error| panic!("remote fixture storage must connect: {error:?}")); + (host, callback, storage) } fn command(workspace: &std::path::Path, content: &str, channel: &str) -> RemoteOperation { RemoteOperation::Execute { workspace_dir: workspace.to_str().unwrap().into(), - message: tinychannels_bus::ChannelMessage { + message: Box::new(tinychannels_bus::ChannelMessage { id: "fixture".into(), sender: "sender".into(), reply_target: "chat".into(), content: content.into(), channel: channel.into(), timestamp: 1, - topic_id: None, - reply_to_id: None, thread_ts: None, sender_name: None, - }, + topic_id: None, + reply_to_id: None, + }), route: RemoteRoute { provider: "fixture-provider".into(), model: "fixture-model".into(), @@ -155,11 +161,15 @@ fn scope() -> tinychannels_bus::module_remote::RemoteSessionScope { #[tokio::test] async fn new_command_awaits_host_history_clear_before_persisting_binding() { let workspace = tempfile::tempdir().unwrap(); - let (host, callback) = fixture(workspace.path()).await; + let (host, callback, _storage) = fixture(workspace.path()).await; let result = execute(&host, command(workspace.path(), "/new", "telegram")) .await .unwrap(); - assert!(result.reply.unwrap().contains("Started new session")); + let reply = result.reply.unwrap(); + assert!( + reply.contains("Started new session"), + "unexpected `/new` reply: {reply}" + ); assert_eq!( *callback.calls.lock().await, vec!["create", "clear", "invalidate"] @@ -177,7 +187,7 @@ async fn new_command_awaits_host_history_clear_before_persisting_binding() { #[tokio::test] async fn new_without_prepared_session_scope_never_calls_host_clear() { let workspace = tempfile::tempdir().unwrap(); - let (host, callback) = fixture(workspace.path()).await; + let (host, callback, _storage) = fixture(workspace.path()).await; let mut request = command(workspace.path(), "/new", "telegram"); let RemoteOperation::Execute { session_scope, .. } = &mut request else { unreachable!() @@ -197,7 +207,7 @@ async fn new_without_prepared_session_scope_never_calls_host_clear() { #[tokio::test] async fn sessions_are_sorted_and_turn_state_is_owned_by_the_module() { let workspace = tempfile::tempdir().unwrap(); - let (host, callback) = fixture(workspace.path()).await; + let (host, callback, _storage) = fixture(workspace.path()).await; execute( &host, RemoteOperation::MarkTurn { @@ -214,7 +224,10 @@ async fn sessions_are_sorted_and_turn_state_is_owned_by_the_module() { .unwrap() .reply .unwrap(); - assert!(status.contains("in progress")); + assert!( + status.contains("in progress"), + "busy turn was not rendered: {status}" + ); let text = execute(&host, command(workspace.path(), "/sessions", "telegram")) .await .unwrap() @@ -227,7 +240,7 @@ async fn sessions_are_sorted_and_turn_state_is_owned_by_the_module() { #[tokio::test] async fn ordinary_messages_and_unsupported_channels_do_not_call_host_state() { let workspace = tempfile::tempdir().unwrap(); - let (host, callback) = fixture(workspace.path()).await; + let (host, callback, _storage) = fixture(workspace.path()).await; for (text, channel) in [("ordinary message", "telegram"), ("/new", "email")] { assert_eq!( execute(&host, command(workspace.path(), text, channel)) @@ -254,7 +267,7 @@ async fn legacy_workspace_bindings_are_imported_once_and_left_untouched() { }) .to_string(); std::fs::write(&legacy, &body).unwrap(); - let (host, _callback) = fixture(workspace.path()).await; + let (host, _callback, _storage) = fixture(workspace.path()).await; let status = execute(&host, command(workspace.path(), "/status", "telegram")) .await .unwrap() diff --git a/crates/tinychannels-module/src/runtime.rs b/crates/tinychannels-module/src/runtime.rs index 4ab4a487f..26f849652 100644 --- a/crates/tinychannels-module/src/runtime.rs +++ b/crates/tinychannels-module/src/runtime.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; -use tinybus::{Connection, Error, Result}; +use tinybus::{BusName, Connection, Error, Result}; use tinychannels_bus::{ChannelRuntimeReservation, HOST_OBJECT_PATH, OBJECT_PATH}; use tokio::sync::Mutex; use tokio::task::JoinHandle; @@ -29,14 +29,24 @@ struct Slot { /// Owns separate provider, relay and delivery resources for each host identity. pub(crate) struct Runtimes { connection: Connection, + storage_callback: Option, terminal: AtomicBool, slots: Mutex>>, } impl Runtimes { + #[cfg(test)] pub(crate) fn new(connection: Connection) -> Self { + Self::new_with_storage_callback(connection, None) + } + + pub(crate) fn new_with_storage_callback( + connection: Connection, + storage_callback: Option, + ) -> Self { Self { connection, + storage_callback, terminal: AtomicBool::new(false), slots: Mutex::new(HashMap::new()), } @@ -137,17 +147,20 @@ impl Runtimes { slot.activated.store(true, Ordering::SeqCst); drop(slots); let connection = self.connection.clone(); + let storage_callback = self.storage_callback.clone(); let owned = slot.clone(); *worker = Some(tokio::spawn(async move { let host = match limit { - Some(limit) => HostChannels::new_at_with_dispatch( + Some(limit) => HostChannels::new_at_with_dispatch_and_storage_callback( connection.clone(), owned.reservation.host_object_path.clone(), limit, + storage_callback, ), - None => HostChannels::new_at( + None => HostChannels::new_at_with_storage_callback( connection.clone(), owned.reservation.host_object_path.clone(), + storage_callback, ), }; let child = Arc::new(Channels::new(host, None)); diff --git a/crates/tinychannels-module/src/service.rs b/crates/tinychannels-module/src/service.rs index 402b164ed..251554012 100644 --- a/crates/tinychannels-module/src/service.rs +++ b/crates/tinychannels-module/src/service.rs @@ -34,6 +34,7 @@ //! `tinychannels::factory`. This module has no keyring and cannot resolve a //! placeholder. +use serde::Deserialize; use std::collections::HashMap; use std::sync::Arc; use tinychannels::config::WhatsAppConfigExt as _; @@ -42,7 +43,7 @@ use crate::delivery::HostDeliveryStore; use crate::provider::{Hooks, Running, RunningSendError}; use crate::relay::Relays; use std::sync::atomic::{AtomicBool, Ordering}; -use tinybus::{Connection, Error as BusError, Result as BusResult}; +use tinybus::{BusName, Connection, Error as BusError, Result as BusResult}; use tinychannels::delivery::{ DeliveryAttemptFailure, DeliveryAttemptResult, DeliveryAttemptSuccess, DeliveryQueueError, DeliveryQueueHandler, DeliveryQueueStore, DeliveryResultStore, PendingDeliveryDrainDecision, @@ -715,10 +716,11 @@ impl Channels { &self, request: tinychannels_bus::BeginWebTurn, ) -> BusResult { + let mut web_turns = self.web_turns.lock().await; if self.terminal.load(Ordering::Acquire) { return Err(BusError::failed("channel module is shutting down")); } - self.web_turns.lock().await.begin(request) + web_turns.begin(request) } /// Project one canonical provider output through the active web turn. @@ -727,10 +729,11 @@ impl Channels { &self, request: tinychannels_bus::ProjectWebOutput, ) -> BusResult { + let mut web_turns = self.web_turns.lock().await; if self.terminal.load(Ordering::Acquire) { return Err(BusError::failed("channel module is shutting down")); } - self.web_turns.lock().await.project(request) + web_turns.project(request) } /// End one web turn and release its bounded projection state. @@ -822,7 +825,7 @@ impl Channels { } let message = match request.preparation_mode { tinychannels_bus::module_inbound::ChannelInboundPreparationMode::LegacyText => { - message + *message } tinychannels_bus::module_inbound::ChannelInboundPreparationMode::Typed => { tinychannels::typed_message_from_inbound_envelope( @@ -946,7 +949,7 @@ impl Channels { "intent destination does not match channel", )); } - intent + *intent } }; Ok(PreparedChannelIntent { @@ -1771,17 +1774,73 @@ async fn finish_start_admission( Ok(()) } -async fn setup(connection: Connection) -> BusResult<()> { - let host = HostChannels::new(connection.clone()); +#[derive(Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct StorageSetupOptions { + storage_callback: Option, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum ModuleSetupConfig { + Null(()), + Options(StorageSetupOptions), +} + +impl ModuleSetupConfig { + fn storage_callback(self) -> BusResult> { + match self { + Self::Null(()) => Ok(None), + Self::Options(options) => match options.storage_callback { + Some(destination) if !destination.is_unique() => Err(BusError::failed( + "storage callback destination must be unique", + )), + destination => Ok(destination), + }, + } + } +} + +async fn setup_with_storage_callback( + connection: Connection, + storage_callback: Option, +) -> BusResult<()> { + let host = + HostChannels::new_with_storage_callback(connection.clone(), storage_callback.clone()); + // Storage belongs to the host's invocation scope, which may not exist at + // module boot. HostChannels resolves this exact callback lazily and still + // refuses storage operations if it is unavailable, without a fallback. let channels = Channels::new( - host, - Some(Arc::new(crate::runtime::Runtimes::new(connection.clone()))), + host.clone(), + Some(Arc::new( + crate::runtime::Runtimes::new_with_storage_callback( + connection.clone(), + storage_callback, + ), + )), ); - connection - .serve_at(OBJECT_PATH.try_into()?, channels) - .await?; - connection.request_name(BUS_NAME).await?; - Ok(()) + let result = async { + connection + .serve_at(OBJECT_PATH.try_into()?, channels) + .await?; + // Claim the well-known name only once the object is served, so a + // failed setup never leaves the name held. Storage callbacks identify + // the module by this name, but they connect lazily, after setup. + connection.request_name(BUS_NAME).await?; + Ok(()) + } + .await; + if result.is_err() { + let _ = host.shutdown().await; + } + result +} + +async fn setup_with_context( + context: tinybus_module::ModuleSetupContext, + config: ModuleSetupConfig, +) -> BusResult<()> { + setup_with_storage_callback(context.connection().clone(), config.storage_callback()?).await } fn intent_fingerprint(intent: &ChannelOutboundIntent) -> Result { @@ -1832,9 +1891,12 @@ pub(crate) mod exports { macro_rules! export_channels_module { ($($extra_method:literal),* $(,)?) => { tinybus_module::module_export_optional_static! { - setup = super::setup, + setup_with_context = super::setup_with_context, + config = super::ModuleSetupConfig, worker_threads = 4, - provides = ["ai.tinyhumans.tinychannels.Channels"], + provides = [ + "ai.tinyhumans.tinychannels.Channels", + ], methods = [ "StartChannel", "StopChannel", diff --git a/crates/tinychannels-module/src/service_tests.rs b/crates/tinychannels-module/src/service_tests.rs index 7b521aef7..c791086bb 100644 --- a/crates/tinychannels-module/src/service_tests.rs +++ b/crates/tinychannels-module/src/service_tests.rs @@ -2,6 +2,26 @@ use super::*; use tinychannels_bus::ChannelsConfig; use tinychannels_bus::config::WhatsAppConfig; +#[tokio::test] +async fn storage_callback_is_deferred_until_the_host_runtime_is_ready() { + let bus = tinybus::transport::memory::MemoryBus::new(); + tinybus::broker::Broker::new().spawn(bus.clone()); + let module = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + // Host storage is scoped to a runtime that is created after module boot. + // A configured callback must therefore be resolved lazily, without any + // legacy fallback when an operation later needs storage. + let absent_storage = ":1.999".try_into().unwrap(); + setup_with_storage_callback(module.clone(), Some(absent_storage)) + .await + .expect("module registration does not require a runtime storage scope"); + assert_eq!( + module.name_owner(BUS_NAME).await.unwrap(), + module.unique_name() + ); +} + #[tokio::test] async fn service_web_turn_methods_open_project_and_release_one_scoped_handle() { let bus = tinybus::transport::memory::MemoryBus::new(); @@ -140,6 +160,51 @@ fn whatsapp(cloud: bool) -> ChannelsConfig { } } +#[test] +fn module_setup_config_preserves_null_and_empty_legacy_defaults() { + let null: ModuleSetupConfig = serde_json::from_str("null").unwrap(); + let empty: ModuleSetupConfig = serde_json::from_str("{}").unwrap(); + assert!(null.storage_callback().unwrap().is_none()); + assert!(empty.storage_callback().unwrap().is_none()); +} + +#[test] +fn outbound_intent_restores_topic_and_reply_metadata_for_provider_send() { + let mut message = tinychannels_bus::SendMessage::new("hello", "100"); + message.thread_ts = Some("55".into()); + message.topic_id = Some("7".into()); + message.reply_to_id = Some("56".into()); + let intent = tinychannels::outbound_intent_from_send_message("telegram", &message); + + let restored = legacy_text_message(&intent).unwrap(); + assert_eq!(restored.thread_ts.as_deref(), Some("55")); + assert_eq!(restored.topic_id.as_deref(), Some("7")); + assert_eq!(restored.reply_to_id.as_deref(), Some("56")); +} + +#[test] +fn module_setup_config_accepts_only_a_unique_storage_callback() { + let configured: ModuleSetupConfig = serde_json::from_value(serde_json::json!({ + "storage_callback": ":1.23" + })) + .unwrap(); + assert_eq!( + configured.storage_callback().unwrap().unwrap().as_str(), + ":1.23" + ); + let well_known: ModuleSetupConfig = serde_json::from_value(serde_json::json!({ + "storage_callback": "ai.tinyhumans.openhuman.Storage" + })) + .unwrap(); + assert!(well_known.storage_callback().is_err()); + assert!( + serde_json::from_value::(serde_json::json!({ + "unexpected": true + })) + .is_err() + ); +} + #[test] fn linq_is_refused_because_its_inbound_path_is_a_webhook() { assert!(webhook_backed("linq", &ChannelsConfig::default())); @@ -2385,7 +2450,7 @@ async fn preparation_preserves_explicit_keys_and_typed_thread_before_provider_io let rendered = channels .prepare_outbound_intent( "telegram".into(), - ChannelMessagePreparation::Intent(original.clone()), + ChannelMessagePreparation::Intent(Box::new(original.clone())), ) .await .unwrap(); @@ -2400,7 +2465,7 @@ async fn preparation_preserves_explicit_keys_and_typed_thread_before_provider_io channels .prepare_outbound_intent( "discord".into(), - ChannelMessagePreparation::Intent(original) + ChannelMessagePreparation::Intent(Box::new(original)) ) .await .is_err() @@ -2483,7 +2548,7 @@ async fn inbound_preparation_preserves_escaped_persisted_keys_and_provider_threa let prepared = service .prepare_channel_inbound(PrepareChannelInboundRequest { source: ChannelInboundSource::Legacy { - message: message.clone(), + message: Box::new(message.clone()), envelope: None, }, namespace: "openhuman".into(), @@ -2744,7 +2809,7 @@ async fn inbound_preparation_refuses_mismatched_identity_oversized_payload_and_s envelope.sender.id = "other".into(); let request = PrepareChannelInboundRequest { source: ChannelInboundSource::Legacy { - message: message.clone(), + message: Box::new(message.clone()), envelope: Some(envelope), }, namespace: "tenant".into(), @@ -2761,7 +2826,7 @@ async fn inbound_preparation_refuses_mismatched_identity_oversized_payload_and_s huge.content = "x".repeat(tinychannels_bus::module_runtime::PREPARATION_MESSAGE_BYTES); let request = PrepareChannelInboundRequest { source: ChannelInboundSource::Legacy { - message: huge, + message: Box::new(huge), envelope: None, }, namespace: "tenant".into(), @@ -2796,7 +2861,8 @@ async fn inbound_preparation_refuses_mismatched_identity_oversized_payload_and_s reply_target: "room".into(), content: "hello".into(), ..Default::default() - }, + } + .into(), envelope: None, }, namespace: "tenant".into(), @@ -3637,6 +3703,61 @@ async fn shutdown_refuses_preparation_and_execution_before_host_work() { ); } +#[tokio::test] +async fn declared_web_projection_methods_require_confidential_delivery() { + use tinybus::Interface; + let bus = tinybus::transport::memory::MemoryBus::new(); + tinybus::broker::Broker::new().spawn(bus.clone()); + let connection = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let service = Channels::new(HostChannels::new(connection), None); + for name in [ + tinychannels_bus::methods::BEGIN_WEB_TURN, + tinychannels_bus::methods::PROJECT_WEB_OUTPUT, + tinychannels_bus::methods::END_WEB_TURN, + ] { + let member: tinybus::MemberName = name.try_into().unwrap(); + assert!( + service.members().contains(&member), + "declared method {name} must be dispatched" + ); + assert!( + service.requires_confidential(&member), + "web output is confidential" + ); + } +} + +#[tokio::test] +async fn web_turn_admission_rechecks_shutdown_after_waiting_for_registry() { + let bus = tinybus::transport::memory::MemoryBus::new(); + tinybus::broker::Broker::new().spawn(bus.clone()); + let connection = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let channels = Channels::new(HostChannels::new(connection), None); + let held = channels.web_turns.lock().await; + let mut begin = Box::pin(channels.begin_web_turn( + tinychannels_bus::module_web_output::BeginWebTurn { + scope_key: "web:shutdown".into(), + context: tinychannels_bus::web::WebEventContext { + client_id: "client".into(), + thread_id: "thread".into(), + request_id: "request".into(), + ..Default::default() + }, + }, + )); + assert!(futures_util::poll!(&mut begin).is_pending()); + channels.terminal.store(true, Ordering::SeqCst); + drop(held); + assert!( + begin.await.is_err(), + "shutdown closes queued web turn admission" + ); +} + #[tokio::test] async fn a_conflicting_pending_entry_is_quarantined_and_does_not_block_a_later_required_send() { use tinychannels::delivery::{ @@ -3675,3 +3796,54 @@ async fn a_conflicting_pending_entry_is_quarantined_and_does_not_block_a_later_r assert!(store.load_pending("stuck").await.unwrap().is_none()); assert!(store.load_failed("stuck").await.unwrap().is_some()); } + +#[test] +fn text_only_typed_intent_preserves_parts_and_delivery_identity() { + let parts = ["first", "second"] + .into_iter() + .map(|text| tinychannels_bus::OutboundContentPart::Content { + part: tinychannels_bus::ContentPart::Text { + text: text.into(), + markdown_dialect: None, + }, + }) + .collect::>(); + let intent = ChannelOutboundIntent { + idempotency_key: "typed-key".into(), + conversation_id: "42".into(), + thread_id: Some("thread".into()), + reply_to_id: Some("reply".into()), + payload: tinychannels::OutboundPayload::Parts { + parts: parts.clone(), + }, + ..Default::default() + }; + let message = tinychannels_bus::ChannelOutboundMessage::from_intent(&intent); + assert_eq!(message.parts, parts); + assert_eq!(message.conversation_id, "42"); + assert_eq!(message.thread_id.as_deref(), Some("thread")); + assert_eq!(message.reply_to_id.as_deref(), Some("reply")); + assert_eq!(message.idempotency_key, "typed-key"); +} + +#[test] +fn rich_typed_intent_keeps_its_parts_in_the_provider_message() { + let rich = vec![tinychannels_bus::OutboundContentPart::Content { + part: tinychannels_bus::ContentPart::Native { + provider: "fixture".into(), + data: serde_json::json!({"kind":"provider-payload"}), + }, + }]; + for parts in [rich, Vec::new()] { + let intent = ChannelOutboundIntent { + idempotency_key: "rich-key".into(), + payload: tinychannels::OutboundPayload::Parts { + parts: parts.clone(), + }, + ..Default::default() + }; + let message = tinychannels_bus::ChannelOutboundMessage::from_intent(&intent); + assert_eq!(message.parts, parts); + assert_eq!(message.idempotency_key, "rich-key"); + } +} diff --git a/crates/tinychannels-module/src/storage_fixture.rs b/crates/tinychannels-module/src/storage_fixture.rs index f944f580d..2009dad54 100644 --- a/crates/tinychannels-module/src/storage_fixture.rs +++ b/crates/tinychannels-module/src/storage_fixture.rs @@ -10,7 +10,9 @@ use async_trait::async_trait; use serde_json::Value; use tinybus::Connection; use tinychannels::storage::delivery::{PENDING_COLLECTION, RESULTS_COLLECTION}; -use tinychannels::storage::records::{StoredDelivery, StoredResult}; +use tinychannels::storage::records::{ + StoredDelivery, StoredResult, queued_from_stored_delivery, terminal_result_from_stored, +}; use tinychannels_bus::{DeliveryQueueEntry, DeliveryTerminalResult}; use tinystoragedrivers_api::{DocumentStore, DocumentStoreExt, Storage, StorageBackend}; use tinystoragedrivers_busproxy::{PerCallerEndpoint, ServedStorage, StorageServer, serve}; @@ -174,7 +176,7 @@ impl StorageHost { async fn entries(&self, scope: &str, name: &str) -> BTreeMap { let raw: BTreeMap = self.collection(scope, name).await; raw.into_values() - .map(DeliveryQueueEntry::from) + .map(queued_from_stored_delivery) .map(|entry| (entry.id.clone(), entry)) .collect() } @@ -191,7 +193,7 @@ impl StorageHost { pub(crate) async fn results(&self, scope: &str) -> BTreeMap { let raw: BTreeMap = self.collection(scope, RESULTS_COLLECTION).await; raw.into_values() - .map(DeliveryTerminalResult::from) + .map(terminal_result_from_stored) .map(|result| (result.id.clone(), result)) .collect() } diff --git a/crates/tinychannels-module/tests/module_artifact.rs b/crates/tinychannels-module/tests/module_artifact.rs index a9ce36ae7..250b3a3d7 100644 --- a/crates/tinychannels-module/tests/module_artifact.rs +++ b/crates/tinychannels-module/tests/module_artifact.rs @@ -1413,7 +1413,7 @@ async fn compiled_artifact_ingests_scoped_media_and_sends_its_opaque_reference() channel: "telegram".into(), interaction: tinychannels_bus::ChannelInteraction::UpsertDraft { previous: None, - intent: draft_intent.clone(), + intent: Box::new(draft_intent.clone()), }, }, ) @@ -1460,7 +1460,7 @@ async fn compiled_artifact_ingests_scoped_media_and_sends_its_opaque_reference() channel: "telegram".into(), interaction: tinychannels_bus::ChannelInteraction::UpsertDraft { previous: Some(draft_outcome.receipt.clone()), - intent: update_intent, + intent: Box::new(update_intent), }, }, ) @@ -1507,7 +1507,7 @@ async fn compiled_artifact_ingests_scoped_media_and_sends_its_opaque_reference() channel: "telegram".into(), interaction: tinychannels_bus::ChannelInteraction::UpsertDraft { previous: Some(forged_receipt), - intent: draft_intent + intent: Box::new(draft_intent) }, } ) diff --git a/crates/tinychannels-module/tests/routing_artifact.rs b/crates/tinychannels-module/tests/routing_artifact.rs index 8462913eb..77f6ea5f6 100644 --- a/crates/tinychannels-module/tests/routing_artifact.rs +++ b/crates/tinychannels-module/tests/routing_artifact.rs @@ -1,17 +1,35 @@ +use std::sync::Arc; use std::time::Duration; -use tinybus::test_support::{admit_module, start_bus, wait_until_serving}; +use tinybus::{ + broker::Broker, + module::ModuleHost, + test_support::{admit_module, wait_until_serving}, + transport::memory::MemoryTransport, +}; use tinychannels_bus::{BUS_NAME, OBJECT_PATH}; #[tokio::test] #[ignore = "run through scripts/verify-module-artifact.sh"] async fn compiled_runtime_commands_preserve_alias_unknown_and_help_behavior() { - let (modules, connection, _broker_task) = start_bus().await.unwrap(); - admit_module( + let broker = Broker::new_restricted_module_control(); + let (broker_transport, client_transport) = MemoryTransport::pair(); + broker + .attach_module_controller(Arc::new(broker_transport)) + .unwrap(); + let connection = tinybus::Connection::connect(Box::new(client_transport)) + .await + .unwrap(); + let modules = ModuleHost::new(broker); + let info = admit_module( &modules, "TINYCHANNELS_MODULE_ARTIFACT", "tinychannels-module", ) .unwrap(); + modules + .ensure_ready(info.manifest.bus_name.as_str()) + .await + .unwrap(); wait_until_serving(&connection, BUS_NAME, Duration::from_secs(5)) .await .unwrap(); diff --git a/crates/tinychannels-module/tests/runtime_artifact.rs b/crates/tinychannels-module/tests/runtime_artifact.rs index 5b034ac01..f02527cf3 100644 --- a/crates/tinychannels-module/tests/runtime_artifact.rs +++ b/crates/tinychannels-module/tests/runtime_artifact.rs @@ -5,7 +5,7 @@ use sha2::{Digest, Sha256}; use std::{fs, path::PathBuf, sync::Arc, time::Duration}; -use tinybus::{Connection, broker::Broker, module::ModuleHost, transport::memory::MemoryTransport}; +use tinybus::module::{ModuleRuntime, RuntimeNames}; use tinychannels_bus::{BUS_NAME, ChannelRuntimeReservation, HOST_BUS_NAME, OBJECT_PATH, methods}; #[derive(Debug, serde::Deserialize)] @@ -100,9 +100,27 @@ async fn runtime_only_host_closes_scoped_resources_without_a_legacy_callback_obj ), ) .unwrap(); - let broker = Broker::new(); - let modules = ModuleHost::new(broker.clone()); + let runtime = ModuleRuntime::start(&RuntimeNames::default(), None).unwrap(); + let connection = runtime.connection(); + let modules = runtime.host(); let loaded = modules.load_file(&staged).unwrap(); + modules + .ensure_ready(loaded.manifest.bus_name.as_str()) + .await + .unwrap(); + assert!( + connection + .attestation(loaded.manifest.bus_name.clone()) + .await + .unwrap() + .is_some() + ); + assert_eq!(loaded.manifest.provides.len(), 1); + assert_eq!( + loaded.manifest.provides[0].version.interface.as_str(), + BUS_NAME, + "storage is supplied through the host callback instead of a migration service" + ); let provided = loaded.manifest.provides.first().unwrap(); let actual: Vec<_> = provided .methods @@ -110,9 +128,6 @@ async fn runtime_only_host_closes_scoped_resources_without_a_legacy_callback_obj .map(|method| method.as_str()) .collect(); assert_eq!(actual, tinychannels_bus::METHODS); - let (client, broker_transport) = MemoryTransport::pair(); - broker.attach(Arc::new(broker_transport)); - let connection = Connection::connect(Box::new(client)).await.unwrap(); connection.request_name(HOST_BUS_NAME).await.unwrap(); let proxy = connection.proxy(BUS_NAME, OBJECT_PATH, BUS_NAME).unwrap(); let bounded_proxy = connection @@ -257,18 +272,18 @@ async fn runtime_only_host_closes_scoped_resources_without_a_legacy_callback_obj let request = RemoteOperation::Execute { workspace_dir: remote_workspace.path().to_str().unwrap().into(), - message: tinychannels_bus::ChannelMessage { + message: Box::new(tinychannels_bus::ChannelMessage { id: "fixture".into(), sender: "sender".into(), reply_target: "chat".into(), content: "/HELP@FixtureBot".into(), channel: "telegram".into(), timestamp: 1, - topic_id: None, - reply_to_id: None, thread_ts: None, sender_name: None, - }, + topic_id: None, + reply_to_id: None, + }), route: RemoteRoute { provider: "fixture".into(), model: "fixture".into(), @@ -323,14 +338,14 @@ async fn runtime_only_host_closes_scoped_resources_without_a_legacy_callback_obj assert_eq!(prepared.client_id, "inbound"); let inbound = PrepareChannelInboundRequest { source: ChannelInboundSource::Legacy { - message: tinychannels_bus::ChannelMessage { + message: Box::new(tinychannels_bus::ChannelMessage { id: "msg".into(), channel: "discord".into(), sender: "user".into(), reply_target: "room".into(), content: "fixture".into(), ..Default::default() - }, + }), envelope: None, }, namespace: "tenant".into(), @@ -551,7 +566,10 @@ async fn runtime_only_host_closes_scoped_resources_without_a_legacy_callback_obj let rendered: PreparedChannelIntent = first_client .call_confidential( methods::PREPARE_OUTBOUND_INTENT, - ("linq", ChannelMessagePreparation::Intent(prepared.intent)), + ( + "linq", + ChannelMessagePreparation::Intent(Box::new(prepared.intent)), + ), ) .await .unwrap(); diff --git a/crates/tinychannels-module/tests/static_link.rs b/crates/tinychannels-module/tests/static_link.rs index 38399391a..b62e2f2d0 100644 --- a/crates/tinychannels-module/tests/static_link.rs +++ b/crates/tinychannels-module/tests/static_link.rs @@ -3,18 +3,12 @@ #![cfg(feature = "static-link")] #![allow(clippy::unwrap_used, clippy::expect_used)] -use tinybus::{ - broker::Broker, - module::{ModuleHost, ModuleState}, - transport::memory::MemoryBus, -}; +use tinybus::module::{ModuleRuntime, ModuleState, RuntimeNames}; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn linked_module_is_admitted_and_becomes_ready() { - let bus = MemoryBus::new(); - let broker = Broker::new(); - broker.clone().spawn(bus.clone()); - let host = ModuleHost::new(broker); + let runtime = ModuleRuntime::start(&RuntimeNames::default(), None).unwrap(); + let host = runtime.host(); let info = host .attach_linked_with_config( tinychannels_module::linked_module().unwrap(), @@ -36,4 +30,9 @@ async fn linked_module_is_admitted_and_becomes_ready() { }) .await .expect("linked module reaches Ready"); + assert_eq!(info.manifest.provides.len(), 1); + assert_eq!( + info.manifest.provides[0].version.interface.as_str(), + tinychannels_bus::BUS_NAME + ); } diff --git a/crates/tinychannels-runtime/src/adapters/web/project.rs b/crates/tinychannels-runtime/src/adapters/web/project.rs index 2bb789fc8..3ce8d041a 100644 --- a/crates/tinychannels-runtime/src/adapters/web/project.rs +++ b/crates/tinychannels-runtime/src/adapters/web/project.rs @@ -157,7 +157,7 @@ fn project_event(ctx: &WebEventContext, event: ChannelOutputEvent) -> Option Value { let payload = match &intent.payload { OutboundPayload::Text { text } => serde_json::json!({ "text": text }), diff --git a/crates/tinychannels-runtime/src/channel/mod_tests.rs b/crates/tinychannels-runtime/src/channel/mod_tests.rs index 27f92e7a9..9ca9d9dfc 100644 --- a/crates/tinychannels-runtime/src/channel/mod_tests.rs +++ b/crates/tinychannels-runtime/src/channel/mod_tests.rs @@ -3,7 +3,7 @@ use super::*; use serde_json::json; -use tinychannels_bus::ContentPart; +use tinychannels_bus::{ContentPart, OutboundContentPart}; #[test] fn durable_final_delivery_capabilities_match_openclaw_order() { @@ -864,8 +864,10 @@ fn legacy_message_payload_replaces_a_blank_idempotency_key() { #[test] fn send_message_intent_preserves_legacy_typed_fields() { - let message = SendMessage::with_subject("hello", "alice", "subject") + let mut message = SendMessage::with_subject("hello", "alice", "subject") .in_thread(Some("thread-1".to_string())); + message.topic_id = Some("topic-2".into()); + message.reply_to_id = Some("message-3".into()); let intent = outbound_intent_from_send_message("discord", &message); let payload = legacy_message_value_from_outbound_intent(&intent); @@ -876,6 +878,18 @@ fn send_message_intent_preserves_legacy_typed_fields() { assert_eq!(payload["recipient"], "alice"); assert_eq!(payload["subject"], "subject"); assert_eq!(payload["thread_ts"], "thread-1"); + assert_eq!(intent.reply_to_id.as_deref(), Some("message-3")); + assert_eq!( + intent + .conversation_ref + .as_ref() + .unwrap() + .topic_id + .as_deref(), + Some("topic-2") + ); + assert_eq!(payload["topicId"], "topic-2"); + assert_eq!(payload["replyToId"], "message-3"); assert_eq!(payload["idempotencyKey"], intent.idempotency_key); } @@ -1018,6 +1032,18 @@ fn outbound_payload_variants_keep_legacy_and_relay_content() { json!({"text":"hello"}), "hello", ), + ( + OutboundPayload::Parts { + parts: vec![OutboundContentPart::Content { + part: ContentPart::Text { + text: "typed parts".into(), + markdown_dialect: None, + }, + }], + }, + json!({"parts":[{"kind":"content","part":{"kind":"text","text":"typed parts"}}]}), + "", + ), ( OutboundPayload::Media { text: Some("caption".into()), @@ -1118,3 +1144,78 @@ fn nested_receipt_ids_recover_parts_and_delivery_time() { assert_eq!(empty.sent_at, 0); assert!(empty.parts.is_empty()); } + +#[test] +fn typed_text_parts_preserve_order_and_delivery_identity_without_flattening() { + let parts = ["first", "second"] + .into_iter() + .map(|text| OutboundContentPart::Content { + part: ContentPart::Text { + text: text.into(), + markdown_dialect: None, + }, + }) + .collect::>(); + let intent = ChannelOutboundIntent { + idempotency_key: "stable-key".into(), + turn_id: Some("turn-42".into()), + payload: OutboundPayload::Parts { + parts: parts.clone(), + }, + ..Default::default() + }; + let legacy = legacy_message_value_from_outbound_intent(&intent); + assert_eq!(legacy["parts"], serde_json::to_value(&parts).unwrap()); + assert_eq!(legacy["idempotencyKey"], "stable-key"); + assert_eq!(legacy["turnId"], "turn-42"); + let typed = tinychannels_bus::ChannelOutboundMessage::from_intent(&intent); + assert_eq!(typed.parts, parts); + assert_eq!(typed.idempotency_key, "stable-key"); + assert_eq!(typed.turn_id.as_deref(), Some("turn-42")); +} + +#[test] +fn typed_projection_keeps_non_text_parts_intact() { + let parts = vec![OutboundContentPart::Content { + part: ContentPart::Native { + provider: "fixture".into(), + data: json!({"text":"not portable text"}), + }, + }]; + let intent = ChannelOutboundIntent { + payload: OutboundPayload::Parts { + parts: parts.clone(), + }, + ..Default::default() + }; + let legacy = legacy_message_value_from_outbound_intent(&intent); + assert!(legacy.get("text").is_none()); + assert_eq!(legacy["parts"], serde_json::to_value(&parts).unwrap()); + assert_eq!( + tinychannels_bus::ChannelOutboundMessage::from_intent(&intent).parts, + parts + ); +} + +#[test] +fn typed_inbound_projection_keeps_reply_thread_and_topic_distinct() { + let envelope = ChannelInboundEnvelope { + reply_to_id: Some("source-message".into()), + conversation: ConversationRef { + id: "chat".into(), + thread_id: Some("thread-id".into()), + topic_id: Some("topic-id".into()), + ..Default::default() + }, + parts: vec![ContentPart::Text { + text: "typed text".into(), + markdown_dialect: None, + }], + ..Default::default() + }; + let projected = typed_message_from_inbound_envelope(&envelope, 42); + assert_eq!(projected.content, "typed text"); + assert_eq!(projected.reply_to_id.as_deref(), Some("source-message")); + assert_eq!(projected.thread_ts.as_deref(), Some("thread-id")); + assert_eq!(projected.topic_id.as_deref(), Some("topic-id")); +} diff --git a/crates/tinychannels-runtime/src/context_tests.rs b/crates/tinychannels-runtime/src/context_tests.rs index eee770362..119898b4f 100644 --- a/crates/tinychannels-runtime/src/context_tests.rs +++ b/crates/tinychannels-runtime/src/context_tests.rs @@ -24,9 +24,9 @@ fn message(channel: &str, sender: &str, reply_target: &str) -> ChannelMessage { content: "hello".into(), channel: channel.into(), timestamp: 123, + thread_ts: None, topic_id: None, reply_to_id: None, - thread_ts: None, sender_name: None, } } diff --git a/crates/tinychannels-runtime/src/lib.rs b/crates/tinychannels-runtime/src/lib.rs index 04f9b14ba..a8a03b134 100644 --- a/crates/tinychannels-runtime/src/lib.rs +++ b/crates/tinychannels-runtime/src/lib.rs @@ -150,3 +150,6 @@ pub mod config; #[cfg(test)] #[path = "relocation_tests.rs"] mod relocation_tests; + +/// Projection of portable channel output onto the deployed web event wire. +pub mod web; diff --git a/crates/tinychannels-runtime/src/runtime_extras_tests.rs b/crates/tinychannels-runtime/src/runtime_extras_tests.rs index cfdb6358e..5b3a025c0 100644 --- a/crates/tinychannels-runtime/src/runtime_extras_tests.rs +++ b/crates/tinychannels-runtime/src/runtime_extras_tests.rs @@ -16,9 +16,9 @@ fn message(id: &str) -> tinychannels_bus::ChannelMessage { content: "hi".into(), channel: "test".into(), timestamp: 0, + thread_ts: None, topic_id: None, reply_to_id: None, - thread_ts: None, sender_name: None, } } diff --git a/crates/tinychannels-runtime/src/traits_tests.rs b/crates/tinychannels-runtime/src/traits_tests.rs index 331cd4754..500852a7f 100644 --- a/crates/tinychannels-runtime/src/traits_tests.rs +++ b/crates/tinychannels-runtime/src/traits_tests.rs @@ -256,3 +256,42 @@ fn webhook_rejection_keeps_its_safe_wire_error_text() { let source: &dyn std::error::Error = &error; assert!(source.source().is_none()); } + +struct Recording(std::sync::Mutex>); + +#[async_trait] +impl Channel for Recording { + fn name(&self) -> &str { + "recording" + } + + async fn send(&self, message: &SendMessage) -> anyhow::Result<()> { + self.0 + .lock() + .unwrap() + .push(format!("send:{}", message.content)); + Ok(()) + } + + async fn listen(&self, _tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> { + Ok(()) + } + + async fn send_idempotent(&self, message: &SendMessage) -> anyhow::Result<()> { + self.0 + .lock() + .unwrap() + .push(format!("idem:{}", message.content)); + Ok(()) + } +} + +#[tokio::test] +async fn send_with_outbound_intent_routes_through_send_idempotent() { + let channel = Recording(Default::default()); + channel + .send_with_outbound_intent(&SendMessage::new("x", "bob")) + .await + .unwrap(); + assert_eq!(*channel.0.lock().unwrap(), vec!["idem:x".to_string()]); +} diff --git a/crates/tinychannels-runtime/src/web.rs b/crates/tinychannels-runtime/src/web.rs new file mode 100644 index 000000000..5816034c2 --- /dev/null +++ b/crates/tinychannels-runtime/src/web.rs @@ -0,0 +1,10 @@ +//! Compatibility path for the canonical web channel output projector. +//! +//! Projection is owned by the web adapter so callers through either public +//! path use the same typed content, lifecycle, and wire normalization rules. + +pub use crate::adapters::web::{WebTurnProjector, context_mut}; + +#[cfg(test)] +#[path = "web_tests.rs"] +mod tests; diff --git a/crates/tinychannels-runtime/src/web_tests.rs b/crates/tinychannels-runtime/src/web_tests.rs new file mode 100644 index 000000000..9c71566fb --- /dev/null +++ b/crates/tinychannels-runtime/src/web_tests.rs @@ -0,0 +1,128 @@ +use super::*; +use tinychannels_bus::module_progressive::ChannelOutputEvent; + +#[test] +fn projection_preserves_wire_fields_and_monotonic_order() { + let mut projector = WebTurnProjector::new("client", "thread", "request"); + for seq in 1..=2 { + let events = projector.project( + ChannelOutputEvent::TextDelta { + text: "hello".into(), + iteration: Some(2), + }, + 10, + ); + assert_eq!(events.len(), 1); + let wire = serde_json::to_value(&events[0]).unwrap(); + assert_eq!(wire["event"], "text_delta"); + assert_eq!(wire["request_id"], "request"); + assert_eq!(wire["round"], 2); + assert_eq!(wire["seq"], seq); + assert_eq!(wire["ts"], 10); + } +} + +#[test] +fn lifecycle_projection_keeps_the_frontend_event_names() { + for (stage, name) in [ + ("inference_start", "inference_start"), + ("inference_heartbeat", "inference_heartbeat"), + ("iteration_start", "iteration_start"), + ("context_compacting", "context_compacting"), + ] { + let events = WebTurnProjector::new("client", "thread", "request").project( + ChannelOutputEvent::Lifecycle { + stage: stage.into(), + message: None, + iteration: Some(1), + max_iterations: None, + stop: None, + }, + 10, + ); + assert_eq!(events[0].event_name(), name); + } +} + +#[test] +fn tool_result_projection_preserves_failure_and_caps_the_output() { + let output_size = tinychannels_bus::web::MAX_WEB_TOOL_PAYLOAD_BYTES + 100_000; + let events = WebTurnProjector::new("client", "thread", "request").project( + ChannelOutputEvent::ToolCallCompleted { + call_id: "call".into(), + tool_name: "shell".into(), + success: false, + output_chars: output_size, + output: "x".repeat(output_size), + arguments: None, + elapsed_ms: 4, + iteration: 1, + failure: Some(tinychannels_bus::module_progressive::ToolFailureSummary { + class: "policy".into(), + category: "denied".into(), + cause_plain: "denied".into(), + next_action: "stop".into(), + recoverable: false, + }), + display_label: None, + display_detail: None, + structured: None, + }, + 10, + ); + let wire = serde_json::to_value(&events[0]).unwrap(); + assert_eq!(wire["failure"]["class"], "policy"); + assert!( + wire["output"].as_str().unwrap().len() <= tinychannels_bus::web::MAX_WEB_TOOL_PAYLOAD_BYTES + ); +} + +#[test] +fn compatibility_projector_retains_typed_parts_and_present_zero_and_false_values() { + use tinychannels_bus::{ContentPart, OutboundContentPart}; + let mut projector = WebTurnProjector::new("client", "thread", "request"); + let parts = vec![OutboundContentPart::Content { + part: ContentPart::Text { + text: "typed final".into(), + markdown_dialect: None, + }, + }]; + let events = projector.project( + ChannelOutputEvent::FinalMessage { + text: "typed final".into(), + parts: parts.clone(), + receipt: None, + }, + 0, + ); + let wire = serde_json::to_value(&events[0]).unwrap(); + assert_eq!(wire["parts"], serde_json::to_value(parts).unwrap()); + assert_eq!(wire["ts"], 0); + + let events = projector.project( + ChannelOutputEvent::ToolCallCompleted { + call_id: "call".into(), + tool_name: "shell".into(), + success: false, + output_chars: 0, + output: String::new(), + arguments: Some(serde_json::Value::Null), + elapsed_ms: 0, + iteration: 0, + failure: None, + display_label: None, + display_detail: None, + structured: None, + }, + 0, + ); + let wire = serde_json::to_value(&events[0]).unwrap(); + assert_eq!(wire["success"], false); + assert_eq!(wire["elapsed_ms"], 0); + assert_eq!(wire["round"], 0); + assert_eq!( + wire.get("args"), + Some(&serde_json::Value::Null), + "explicit null arguments remain distinct from omitted arguments" + ); +} diff --git a/deny.toml b/deny.toml index 68f9d2fd4..0f9c0796e 100644 --- a/deny.toml +++ b/deny.toml @@ -48,8 +48,10 @@ deny = [] unknown-registry = "deny" unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] -# TinyStorageDrivers and TinyBus are pinned to exact canonical revisions. +# Storage/voice use canonical revisions; the attested TinyBus SDK is pinned +# to the coordinated migration PR until it lands upstream. allow-git = [ "https://github.com/tinyhumansai/tinystoragedrivers", "https://github.com/tinyhumansai/tinyvoice", + "https://github.com/senamakel/tinybus", ] diff --git a/docs/spec/README.md b/docs/spec/README.md index 17eec0ef2..0d773fcd2 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -32,6 +32,10 @@ state remain outside this crate until they can be split behind transport traits. ## Research and Porting Specs +- [TinyChannels client](tinychannels-client.md) — host construction, attested + runtime leases, and the native test proof boundaries. +- [Storage needs](storage-needs.md) — the bus contract's declared collection + inventory for delivery, remote sessions, and WhatsApp Web persistence. - [Channel Contract v2](contract-v2.md) — normative target and v21-to-v22 compatibility plan. - [Harness progress output](progress-output.md) — canonical progress events, diff --git a/docs/spec/pure-contract-relocation.md b/docs/spec/pure-contract-relocation.md index eb7ff01e8..a1f7fcda2 100644 --- a/docs/spec/pure-contract-relocation.md +++ b/docs/spec/pure-contract-relocation.md @@ -47,10 +47,14 @@ it does not change the wire contract version or released module interface. ## Verification and remaining work The purity checker generates separate probe workspaces under `target/` and -traverses all normal/build edges without a platform filter, once for defaults -and once for every declared bus feature. Dev dependencies are excluded. Its -allowlist covers serialization/schema/error packages and Base64; source guards -also catch runtime seams that could use only the standard library. +traverses the complete normal/build dependency closure without a platform +filter, once for defaults and once for every declared bus feature. Dev edges +are excluded and missing graph metadata fails closed. The bus owns async +provider interfaces, Tokio channel types, in-memory context helpers, and static +TSD2 collection declarations through `tinystoragedrivers-types`; it does not +own provider execution, sockets, tasks, storage clients, database clients or +drivers. The source guard checks those runtime seams independently of the +dependency graph, and CI runs both the guard and its negative controls. The module follow-up adds a bounded relay lifecycle (`ReserveRelay`, confidential `StartRelay`, `StopRelay`, safe `RelayStatus`, and diff --git a/docs/spec/storage-needs.md b/docs/spec/storage-needs.md new file mode 100644 index 000000000..8362e1097 --- /dev/null +++ b/docs/spec/storage-needs.md @@ -0,0 +1,33 @@ +# TinyChannels storage contract + +The transport-free `tinychannels-bus` crate exposes its own synchronous +`CollectionSpec` declarations through `tinychannels_bus::storage::collections()`. +These serializable DTOs do not depend on TSD types or a storage runtime. Host +composition roots use those declarations when installing a scoped storage +owner. They do not select a scope or grant the module access to an unscoped +backend. + +The collection inventory is defined in +[`tinychannels_bus::storage::schema`](../../crates/tinychannels-bus/src/storage/schema.rs). +It includes 30 delivery, remote-session, media, migration-progress, and WhatsApp +Web collections, including the legacy source collections read during migration. WhatsApp collections remain declared when the feature is +disabled, so provisioning does not depend on the module build. + +The implementation connects through TSD2's storage ports. A linked host passes +scoped handles directly; a loaded module receives a unique `storage_callback` +destination in its setup configuration and calls `connect_storage_at`. When +that destination is configured, a failed connection does not fall back to +legacy global storage. `tinychannels::storage::prepare` provisions the owner +collections and runs idempotent owner-local migrations before stores open. + +Legacy record conversion rules and migration execution belong to the implementation +in [`src/storage/migrations.rs`](../../src/storage/migrations.rs). +The bus crate contains only collection declarations and record vocabulary; it +has no storage client, driver, or backend implementation. + +The standalone consumer in [`proofs/storage-needs-consumer`](../../proofs/storage-needs-consumer/) +depends only on `tinychannels-bus` and checks the declaration inventory. Run +`python3 scripts/check-storage-needs-boundary.py` to verify that its complete +normal/build dependency closure contains only the bus and its reviewed +serialization/schema dependencies. The gate rejects TSD types and storage +clients as well as runtime and transport packages. diff --git a/docs/spec/tinychannels-client.md b/docs/spec/tinychannels-client.md new file mode 100644 index 000000000..9de3dd614 --- /dev/null +++ b/docs/spec/tinychannels-client.md @@ -0,0 +1,45 @@ +# TinyChannels Client + +`tinychannels-client` is the host-facing typed layer over TinyBus. It wraps a +host-supplied `ModuleClient`; it does not own module discovery, release policy, +credentials, retries, callback registration or the `ModuleRuntime` lifecycle. +Top-level controller and health operations use the confidential route when +their arguments include configuration, credentials or proxy policy. + +## Identity-scoped runtime lifecycle + +The host creates a `TinyChannelsClient`, obtains a `ChannelRuntimeDriver`, and +passes that driver to `ModuleRuntime::lease`. The lease transaction reserves a +module-generated identity, validates its opaque handle and exact private +object paths, registers the host callback at the returned callback path, then +starts the runtime. The lease exposes a `ChannelRuntimeClient` whose methods +stay bound to the same attested peer as reservation and lifecycle calls. + +The callback factory receives the reservation and object, so the host can bind +the callback to the identity that owns the lease. The callback remains +registered until lease cleanup finishes. Sign-out and shutdown close that +identity's lease; they do not stop the shared module or another identity's +runtime. `runtime_driver_with_dispatch` opts into host-selected bounded +concurrency; `runtime_driver` retains serial inbound dispatch. + +## Native test proof boundaries + +The client E2E suite uses locally built, digest-pinned artifacts and does not +call external providers. The controlled module fixture supports peer handover: +an ordinary readiness control demonstrates that a competing peer is live, +while a typed object response distinguishes the originally bound peer from the +new alias owner. The readiness control is not itself a confidential-call +target, so it cannot prove a negative confidential canary. + +The production module composition test loads the actual module library with an +empty provider configuration. It covers reserve, object creation, start, +typed object calls and acknowledged stop, without claiming provider traffic or +module-originated callback delivery. Callback registration is tested through +the host connection. These proofs remain separate from controlled peer +handover. + +Run `scripts/test-features.sh` for the all-feature lanes. It stages the local +artifacts before the feature-enabled Cargo run, including the four client +native targets and their expected test counts. Those targets remain required; +a bare Cargo all-features invocation does not substitute for the staged native +suite. diff --git a/proofs/storage-needs-consumer/Cargo.lock b/proofs/storage-needs-consumer/Cargo.lock new file mode 100644 index 000000000..26ab0d906 --- /dev/null +++ b/proofs/storage-needs-consumer/Cargo.lock @@ -0,0 +1,205 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinychannels-bus" +version = "0.2.0" +dependencies = [ + "base64", + "schemars", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "tinychannels-storage-needs-consumer" +version = "0.1.0" +dependencies = [ + "tinychannels-bus", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/proofs/storage-needs-consumer/Cargo.toml b/proofs/storage-needs-consumer/Cargo.toml new file mode 100644 index 000000000..36e9c2046 --- /dev/null +++ b/proofs/storage-needs-consumer/Cargo.toml @@ -0,0 +1,12 @@ +[workspace] +resolver = "3" + +[package] +name = "tinychannels-storage-needs-consumer" +version = "0.1.0" +edition = "2024" +rust-version = "1.94" +publish = false + +[dependencies] +tinychannels-bus = { path = "../../crates/tinychannels-bus", features = ["storage"] } diff --git a/proofs/storage-needs-consumer/src/main.rs b/proofs/storage-needs-consumer/src/main.rs new file mode 100644 index 000000000..6098ed645 --- /dev/null +++ b/proofs/storage-needs-consumer/src/main.rs @@ -0,0 +1,51 @@ +use tinychannels_bus::storage::{CollectionSpec, collections}; + +fn main() { + let needs = collections(); + let names: Vec<_> = needs + .iter() + .map(|collection| collection.name.as_str()) + .collect(); + assert_eq!( + names, + [ + "channels.delivery_pending", + "channels.delivery_failed", + "channels.delivery_results", + "channels.remote_bindings", + "channels.remote_busy", + "channels.remote_meta", + "channels.media_metadata", + "channels.migrations", + "channels.whatsapp_device", + "channels.whatsapp_identity", + "channels.whatsapp_session", + "channels.whatsapp_prekey", + "channels.whatsapp_signed_prekey", + "channels.whatsapp_sender_key", + "channels.whatsapp_sync_key", + "channels.whatsapp_lid_mapping", + "channels_delivery_pending", + "channels_delivery_failed", + "channels_delivery_results", + "channel_remote_bindings", + "channel_remote_busy", + "channel_remote_meta", + "whatsapp_device", + "whatsapp_identity", + "whatsapp_session", + "whatsapp_prekey", + "whatsapp_signed_prekey", + "whatsapp_sender_key", + "whatsapp_sync_key", + "whatsapp_lid_mapping", + ] + ); + assert_eq!(needs.len(), 30); + let _: &CollectionSpec = &needs[0]; + assert!(needs.iter().all(|collection| { + collection.indexes.is_empty() + && collection.ttl_field.is_none() + && collection.search.is_none() + })); +} diff --git a/scripts/__tests__/check_bus_purity_tests.py b/scripts/__tests__/check_bus_purity_tests.py index 2ca75e83e..c57cd40a6 100644 --- a/scripts/__tests__/check_bus_purity_tests.py +++ b/scripts/__tests__/check_bus_purity_tests.py @@ -37,6 +37,20 @@ def test_allows_contract_dependencies_in_manifest(self): } self.assertEqual(CHECKER.declared_forbidden_dependencies(manifest), set()) + def test_all_probe_includes_implicit_optional_features_but_not_suppressed_aliases(self): + manifest = { + 'features': {'storage': ['dep:driver'], 'named': []}, + 'dependencies': { + 'driver': {'optional': True}, + 'optional-http': {'optional': True}, + }, + 'target': {'cfg(windows)': {'dependencies': {'windows-http': {'optional': True}}}}, + } + self.assertEqual( + CHECKER.contract_features(manifest), + ['named', 'optional-http', 'storage', 'windows-http'], + ) + def test_storage_exemption_does_not_hide_unrelated_optional_dependencies(self): manifest = { 'dependencies': { @@ -50,27 +64,26 @@ def test_storage_exemption_does_not_hide_unrelated_optional_dependencies(self): 'storage-port': 'tinystoragedrivers-bus', 'http': 'optional-http-client', } - nodes = { - 'bus': {'deps': [ - {'pkg': 'storage', 'dep_kinds': [{'kind': None}]}, - {'pkg': 'http', 'dep_kinds': [{'kind': None}]}, + graph = { + 'packages': [{'id': package_id, 'name': name} for package_id, name in packages.items()], + 'resolve': {'nodes': [ + {'id': 'bus', 'features': [], 'deps': [ + {'pkg': 'storage', 'dep_kinds': [{'kind': None}]}, + {'pkg': 'http', 'dep_kinds': [{'kind': None}]}, + ]}, + {'id': 'storage', 'features': [], 'deps': [ + {'pkg': 'storage-port', 'dep_kinds': [{'kind': None}]}, + ]}, + {'id': 'storage-port', 'features': [], 'deps': []}, + {'id': 'http', 'features': [], 'deps': []}, ]}, - 'storage': {'deps': [ - {'pkg': 'storage-port', 'dep_kinds': [{'kind': None}]}, - ]}, - 'storage-port': {'deps': []}, - 'http': {'deps': []}, } + errors = CHECKER.dependency_violations(graph) + self.assertTrue(any('optional-http-client' in error for error in errors)) + self.assertTrue(any('tinystoragedrivers-types' in error for error in errors)) + self.assertTrue(any('tinystoragedrivers-bus' in error for error in errors)) + self.assertEqual(CHECKER.declared_forbidden_dependencies(manifest), {'tinystoragedrivers-types'}) - exempt = CHECKER.dependency_closure( - nodes, - CHECKER.optional_storage_port_roots(manifest, packages), - ) - audited = CHECKER.dependency_closure(nodes, ['bus']) - exempt - unsafe = {packages[package] for package in audited} - CHECKER.ALLOWED - - self.assertEqual(exempt, {'storage', 'storage-port'}) - self.assertEqual(unsafe, {'optional-http-client'}) class WindowsEncodingTests(unittest.TestCase): @@ -86,7 +99,7 @@ def test_checker_cli_reads_utf8_rust_source_with_cp1252_default(self): bus_id = 'tinychannels-bus 0.1.0 (path+file:///tinychannels-bus)' graph = { 'packages': [{'id': bus_id, 'name': 'tinychannels-bus'}], - 'resolve': {'nodes': [{'id': bus_id, 'deps': []}]}, + 'resolve': {'nodes': [{'id': bus_id, 'features': [], 'deps': []}]}, } original_read_text = pathlib.Path.read_text @@ -101,11 +114,78 @@ def cp1252_default(path, encoding=None, errors=None): patch('sys.argv', ['check-bus-purity.py', '--offline']), redirect_stdout(StringIO()) as output, ): - CHECKER.main() + self.assertEqual(CHECKER.main(), 0) self.assertIn('default: pure contract closure (1 packages)', output.getvalue()) self.assertIn('all: pure contract closure (1 packages)', output.getvalue()) +class IndependentFeatureProbeTests(unittest.TestCase): + def run_checker(self, *, forbidden_mode=None, feature_names=('storage', 'optional-http')): + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + source_dir = root / 'crates/tinychannels-bus/src' + source_dir.mkdir(parents=True) + (source_dir / 'lib.rs').write_text('// pure contract\n', encoding='utf-8') + manifest = source_dir.parent / 'Cargo.toml' + manifest.write_text('[features]\n' + ''.join(f'{name} = []\n' for name in feature_names), encoding='utf-8') + probes = {} + + def metadata(command, **kwargs): + path = pathlib.Path(command[command.index('--manifest-path') + 1]) + mode = path.parent.name + probes[mode] = path.read_text(encoding='utf-8') + self.assertEqual(command[0:2], ['cargo', 'metadata']) + self.assertIn('--offline', command) + graph = { + 'packages': [{'id': 'bus', 'name': 'tinychannels-bus'}], + 'resolve': {'nodes': [{'id': 'bus', 'features': [], 'deps': []}]}, + } + if mode == forbidden_mode: + graph['packages'].append({'id': 'http', 'name': 'reqwest'}) + graph['resolve']['nodes'][0]['deps'].append({'pkg': 'http', 'dep_kinds': [{'kind': None}]}) + graph['resolve']['nodes'].append({'id': 'http', 'features': [], 'deps': []}) + return json.dumps(graph) + + with ( + patch.object(CHECKER, 'ROOT', root), + patch.object(CHECKER, 'BUS_MANIFEST', manifest), + patch.object(CHECKER.subprocess, 'check_output', side_effect=metadata), + redirect_stdout(StringIO()), + patch('sys.stderr', StringIO()) as errors, + ): + result = CHECKER.main(['--offline']) + return result, probes, errors.getvalue() + + def test_default_all_and_storage_are_independent_cargo_consumers(self): + result, probes, _ = self.run_checker() + self.assertEqual(result, 0) + self.assertEqual(set(probes), {'default', 'all', 'storage'}) + self.assertNotIn('features = ', probes['default']) + self.assertIn('features = ["optional-http", "storage"]', probes['all']) + self.assertIn('features = ["storage"]', probes['storage']) + for probe in probes.values(): + self.assertIn('[workspace]', probe) + self.assertIn('tinychannels-bus = { path = ', probe) + + def test_optional_dependency_enabled_only_by_all_features_is_rejected(self): + result, probes, errors = self.run_checker(forbidden_mode='all') + self.assertEqual(result, 1) + self.assertEqual(set(probes), {'default', 'all'}) + self.assertIn('all: contract dependency boundary failed', errors) + self.assertIn('reqwest', errors) + + def test_storage_probe_does_not_exempt_storage_dependencies(self): + result, probes, errors = self.run_checker(forbidden_mode='storage') + self.assertEqual(result, 1) + self.assertEqual(set(probes), {'default', 'all', 'storage'}) + self.assertIn('storage: contract dependency boundary failed', errors) + + def test_contract_without_storage_feature_still_audits_default_and_all(self): + result, probes, _ = self.run_checker(feature_names=()) + self.assertEqual(result, 0) + self.assertEqual(set(probes), {'default', 'all'}) + + if __name__ == '__main__': unittest.main() diff --git a/scripts/check-bus-purity.py b/scripts/check-bus-purity.py index a6195af5a..24bc5dde0 100644 --- a/scripts/check-bus-purity.py +++ b/scripts/check-bus-purity.py @@ -1,121 +1,314 @@ #!/usr/bin/env python3 -"""Audit contract source and independent default/all dependency closures.""" +"""Audit contract source and independent normal/build dependency closures.""" + import argparse import json -import pathlib +from pathlib import Path +import re import subprocess +import sys import tomllib -ROOT = pathlib.Path(__file__).resolve().parents[1] -BUS_MANIFEST = ROOT / 'crates/tinychannels-bus/Cargo.toml' -ALLOWED = { - 'tinychannels-bus', 'serde', 'serde_core', 'serde_derive', 'serde_json', - 'schemars', 'schemars_derive', 'thiserror', 'thiserror-impl', - 'proc-macro2', 'quote', 'syn', 'unicode-ident', 'itoa', 'memchr', 'zmij', - 'serde_derive_internals', 'dyn-clone', 'ref-cast', 'ref-cast-impl', 'base64', -} -FORBIDDEN_DEPS = {'anyhow', 'async-trait', 'tokio'} -FORBIDDEN = ('async fn ', 'async_trait', 'tokio::', 'std::sync', 'std::thread', - 'std::fs', 'std::env', 'std::time', 'parking_lot::', 'sha2::', 'hmac::', - 'rand::', 'uuid::', 'fn build_session_key', 'fn classify_send_error', - 'fn authenticated_inbound_event', 'fn segment_for_delivery') -STORAGE_PORT_PACKAGES = {'tinystoragedrivers-core', 'tinystoragedrivers-types'} - - -def dependency_closure(nodes, roots): - """Return the non-dev dependency closure for package ids in roots.""" - pending = list(roots) - seen = set() + +ROOT = Path(__file__).resolve().parents[1] +BUS_SOURCE = ROOT / "crates/tinychannels-bus/src" +BUS_MANIFEST = ROOT / "crates/tinychannels-bus/Cargo.toml" +ALLOWED = frozenset({ + "tinychannels-bus", "serde", "serde_core", "serde_derive", "serde_json", + "schemars", "schemars_derive", "thiserror", "thiserror-impl", + "proc-macro2", "quote", "syn", "unicode-ident", "itoa", "memchr", "zmij", + "serde_derive_internals", "dyn-clone", "ref-cast", "ref-cast-impl", "base64", +}) +FORBIDDEN_DEPS = frozenset({"anyhow", "async-trait", "tokio"}) + +# This is the observed default/all-feature normal + build closure. New packages +# require review because this crate is consumed directly by host libraries. +FORBIDDEN_PACKAGES = frozenset({ + "axum", "hyper", "mongodb", "reqwest", "rusqlite", "sqlx", + "tinybus", "tinybus-macros", "tinybus-module", + "tinystoragedrivers-api", "tinystoragedrivers-busproxy", + "tinystoragedrivers-core", "tinystoragedrivers-file", + "tinystoragedrivers-mongodb", "tinystoragedrivers-s3", + "tinystoragedrivers-secrets", "tinystoragedrivers-sqlite", +}) +NORMAL_BUILD_KINDS = {None, "normal", "build"} + +# The contract is synchronous; execution and storage-driver types belong in +# the implementation even when a runtime feature is disabled. +FORBIDDEN_SOURCE = ( + (re.compile(r"\basync\s+fn\b|\basync_trait\b"), "async contract interface or implementation"), + (re.compile(r"\btokio::"), "Tokio runtime dependency"), + (re.compile(r"\bstd::sync\b"), "runtime synchronization"), + (re.compile(r"\btinystoragedrivers_\w*::"), "storage driver dependency"), + (re.compile(r"\bstd::net\b"), "socket/network implementation"), + (re.compile(r"\btokio::net\b"), "Tokio network implementation"), + (re.compile(r"\bstd::fs\b|\btokio::fs\b"), "filesystem implementation"), + (re.compile(r"\bstd::process\b|\btokio::process\b"), "process execution"), + (re.compile(r"\bstd::env\b"), "process environment access"), + (re.compile(r"\bstd::time\b"), "clock access"), + (re.compile(r"\bstd::thread\b"), "thread execution"), + (re.compile(r"\bstd::sync::(?:mpsc|atomic)::"), "thread/IPC coordination"), + (re.compile(r"\bparking_lot::"), "runtime synchronization"), + (re.compile(r"\btokio::(?:spawn|task::|time::)"), "Tokio task/time execution"), + ( + re.compile( + r"\btokio::sync::(?:Mutex|RwLock|OnceCell|Notify|oneshot::|watch::|broadcast::)" + ), + "Tokio runtime synchronization", + ), + ( + re.compile(r"\b(?:TcpListener|TcpStream|UdpSocket|UnixListener|UnixStream)\b"), + "socket type", + ), + ( + re.compile(r"\b(?:rusqlite|mongodb|sqlx|reqwest|hyper|axum)::"), + "database or HTTP implementation", + ), + (re.compile(r"\bimpl\s+Channel\s+for\b"), "concrete provider executor"), + ( + re.compile(r"\b(?:sha2|hmac|rand|uuid)::"), + "cryptographic, random, or identity implementation", + ), + ( + re.compile( + r"\bfn\s+(?:build_session_key|classify_send_error|" + r"authenticated_inbound_event|segment_for_delivery)\b" + ), + "implementation algorithm", + ), +) + + +def source_violations(source_text, source_path=None): + """Return runtime/implementation constructs found in one Rust source.""" + errors = [ + label + for pattern, label in FORBIDDEN_SOURCE + if pattern.search(source_text) + ] + return errors + + +def source_tree_violations(source_root=None): + """Scan production Rust files while leaving colocated behavior tests alone.""" + source_root = source_root if source_root is not None else ROOT / "crates/tinychannels-bus/src" + errors = [] + storage = Path(source_root) / "storage" + for name in ("migrate.rs", "migrate_tests.rs"): + if (storage / name).exists(): + errors.append(f"{(storage / name).relative_to(ROOT)}: migration execution belongs to tinychannels") + records = storage / "records.rs" + if records.exists(): + text = records.read_text(encoding="utf-8") + if "impl From<" in text or "to_stored_ms" in text or "from_stored_ms" in text: + errors.append(f"{records.relative_to(ROOT)}: storage projections belong to tinychannels") + for source in sorted(Path(source_root).rglob("*.rs")): + if source.name.endswith("_tests.rs"): + continue + for violation in source_violations( + source.read_text(encoding="utf-8"), source_path=source + ): + errors.append(f"{source.relative_to(ROOT)}: {violation}") + return errors + + +def reachable_normal_build(metadata, root_package="tinychannels-bus"): + """Resolve the bus package's complete normal/build closure, fail closed.""" + errors = [] + packages = metadata.get("packages") + resolve = metadata.get("resolve") + if not isinstance(packages, list) or not isinstance(resolve, dict): + return {}, {}, set(), ["Cargo metadata is missing packages or resolve data"] + + packages_by_id = {} + for package in packages: + if ( + not isinstance(package, dict) + or not isinstance(package.get("id"), str) + or not isinstance(package.get("name"), str) + ): + errors.append("Cargo metadata contains a malformed package record") + continue + if package["id"] in packages_by_id: + errors.append(f"Cargo metadata repeats package ID {package['id']}") + packages_by_id[package["id"]] = package + + nodes = resolve.get("nodes") + if not isinstance(nodes, list): + return packages_by_id, {}, set(), errors + [ + "Cargo metadata is missing resolved nodes" + ] + nodes_by_id = {} + for node in nodes: + if not isinstance(node, dict) or not isinstance(node.get("id"), str): + errors.append("Cargo resolve graph contains a malformed node") + continue + if node["id"] in nodes_by_id: + errors.append(f"Cargo resolve graph repeats node {node['id']}") + nodes_by_id[node["id"]] = node + + bus_ids = [ + package_id + for package_id, package in packages_by_id.items() + if package["name"] == root_package + ] + if len(bus_ids) != 1: + return packages_by_id, nodes_by_id, set(), errors + [ + f"expected one {root_package} package, found {len(bus_ids)}" + ] + + reachable = set() + pending = list(bus_ids) while pending: - package = pending.pop() - if package in seen: + package_id = pending.pop() + if package_id in reachable: continue - seen.add(package) - pending.extend( - edge['pkg'] for edge in nodes[package]['deps'] - if any(kind['kind'] != 'dev' for kind in edge['dep_kinds']) - ) - return seen + package = packages_by_id.get(package_id) + node = nodes_by_id.get(package_id) + if package is None or node is None: + errors.append(f"missing resolved normal/build node or package record for {package_id}") + continue + reachable.add(package_id) + if not isinstance(node.get("features"), list) or any( + not isinstance(feature, str) for feature in node.get("features", []) + ): + errors.append(f"resolved node {package_id} has no feature evidence") + dependencies = node.get("deps") + if not isinstance(dependencies, list): + errors.append(f"resolved node {package_id} has no dependency evidence") + continue + for dependency in dependencies: + if not isinstance(dependency, dict): + errors.append(f"resolved node {package_id} has a malformed dependency") + continue + kinds = dependency.get("dep_kinds") + if not isinstance(kinds, list) or not kinds: + errors.append(f"resolved dependency from {package_id} has no kind evidence") + continue + if any( + not isinstance(kind, dict) + or "kind" not in kind + or kind.get("kind") not in (None, "normal", "dev", "build") + for kind in kinds + ): + errors.append(f"resolved dependency from {package_id} has malformed kind evidence") + continue + if not any(kind.get("kind") in NORMAL_BUILD_KINDS for kind in kinds): + continue + child = dependency.get("pkg") + if not isinstance(child, str): + errors.append(f"resolved dependency from {package_id} has no package ID") + elif child not in nodes_by_id or child not in packages_by_id: + errors.append( + f"missing resolved normal/build node for " + f"{dependency.get('name', child)} ({child})" + ) + else: + pending.append(child) + return packages_by_id, nodes_by_id, reachable, errors -def optional_storage_port_roots(manifest, packages): - """Select only the explicitly optional TSD storage-port package ids.""" - declared = { - spec.get('package', name) if isinstance(spec, dict) else name - for name, spec in manifest.get('dependencies', {}).items() - if isinstance(spec, dict) and spec.get('optional') - } - return { - package_id for package_id, name in packages.items() - if name in STORAGE_PORT_PACKAGES and name in declared - } + +def dependency_violations(metadata): + packages_by_id, _, reachable, errors = reachable_normal_build(metadata) + packages = [packages_by_id[package_id] for package_id in reachable] + names = {package["name"] for package in packages} + unsafe = sorted(names - ALLOWED) + if unsafe: + errors.append(f"unsafe contract dependencies: {unsafe}") + return errors def declared_forbidden_dependencies(manifest): - """Return forbidden direct dependencies, including dev and target-specific edges.""" - tables = [manifest] - tables.extend(manifest.get('target', {}).values()) + """Reject runtime/TSD declarations, including unused optional and dev edges.""" + tables = [manifest, *manifest.get("target", {}).values()] found = set() for table in tables: - for section in ('dependencies', 'dev-dependencies', 'build-dependencies'): + for section in ("dependencies", "dev-dependencies", "build-dependencies"): for alias, spec in table.get(section, {}).items(): - package = spec.get('package', alias) if isinstance(spec, dict) else alias - if package in FORBIDDEN_DEPS: + package = spec.get("package", alias) if isinstance(spec, dict) else alias + if package in FORBIDDEN_DEPS or package in FORBIDDEN_PACKAGES or package.startswith("tinystoragedrivers-"): found.add(package) return found -def main(): +def contract_features(manifest): + """Include Cargo's implicit optional-dependency features in the all probe.""" + explicit = manifest.get("features", {}) + features = set(explicit) + suppressed = { + member.removeprefix("dep:") + for members in explicit.values() + for member in members + if member.startswith("dep:") + } + for table in [manifest, *manifest.get("target", {}).values()]: + for alias, spec in table.get("dependencies", {}).items(): + if isinstance(spec, dict) and spec.get("optional") and alias not in suppressed: + features.add(alias) + return sorted(features) + + +def run_metadata(manifest, offline=False): + command = ["cargo", "metadata"] + if offline: + command.append("--offline") + command.extend(["--format-version=1", "--manifest-path", str(manifest)]) + output = subprocess.check_output(command, cwd=ROOT, text=True) + return json.loads(output) + + +def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--offline', action='store_true', help='Use only cached Cargo registry data') - args = parser.parse_args() + parser.add_argument("--offline", action="store_true", help="Use only cached Cargo registry data") + args = parser.parse_args(argv) - for source in (ROOT / 'crates/tinychannels-bus/src').rglob('*.rs'): - if source.name.endswith('_tests.rs'): - continue - content = source.read_text(encoding='utf-8') - for forbidden in FORBIDDEN: - if forbidden in content: - raise SystemExit(f'{source.relative_to(ROOT)}: implementation token {forbidden!r}') + source_errors = source_tree_violations() + if source_errors: + print("unsafe contract source:", file=sys.stderr) + print("\n".join(f"- {error}" for error in source_errors), file=sys.stderr) + return 1 - manifest = tomllib.loads(BUS_MANIFEST.read_text(encoding='utf-8')) + manifest = tomllib.loads(BUS_MANIFEST.read_text(encoding="utf-8")) forbidden_direct = declared_forbidden_dependencies(manifest) if forbidden_direct: - raise SystemExit(f'contract manifest declares runtime dependencies: {sorted(forbidden_direct)}') - - features = list(manifest.get('features', {})) - for mode in ('default', 'all', 'storage'): - probe = ROOT / 'target/bus-purity' / mode - probe.mkdir(parents=True, exist_ok=True) - (probe / 'src').mkdir(exist_ok=True) - (probe / 'src/lib.rs').write_text('pub use tinychannels_bus::*;\n', encoding='utf-8') - (probe / 'Cargo.toml').write_text( + print(f"contract manifest declares runtime dependencies: {sorted(forbidden_direct)}", file=sys.stderr) + return 1 + features = contract_features(manifest) + modes = ["default", "all"] + if "storage" in features: + modes.append("storage") + for mode in modes: + probe = ROOT / "target/bus-purity" / mode + (probe / "src").mkdir(parents=True, exist_ok=True) + (probe / "src/lib.rs").write_text("pub use tinychannels_bus::*;\n", encoding="utf-8") + dependency = ( + "tinychannels-bus = { path = " + + json.dumps(str(ROOT / "crates/tinychannels-bus")) + + ( + ", features = " + json.dumps(features) + if mode == "all" + else ', features = ["storage"]' + if mode == "storage" + else "" + ) + + " }\n" + ) + (probe / "Cargo.toml").write_text( '[package]\nname="contract-purity-probe"\nversion="0.0.0"\nedition="2024"\n' - '[workspace]\n[dependencies]\ntinychannels-bus = { path = ' + json.dumps(str(BUS_MANIFEST.parent)) - + (', features = ' + json.dumps(features) if mode == 'all' else ', features = ["storage"]' if mode == 'storage' else '') + ' }\n', - encoding='utf-8', + "[workspace]\n[dependencies]\n" + + dependency, + encoding="utf-8", ) - graph = json.loads(subprocess.check_output([ - 'cargo', 'metadata', *(['--offline'] if args.offline else []), '--format-version=1', '--manifest-path', str(probe / 'Cargo.toml') - ], cwd=ROOT)) - packages = {p['id']: p['name'] for p in graph['packages']} - nodes = {n['id']: n for n in graph['resolve']['nodes']} - bus_id = next(p['id'] for p in graph['packages'] if p['name'] == 'tinychannels-bus') - seen = dependency_closure(nodes, [bus_id]) - exempt = set() - if mode in {'storage', 'all'} and 'storage' in features: - # Only the explicitly optional TSD storage-port dependency and its - # closure are exempt; unrelated optional edges remain audited. - roots = optional_storage_port_roots(manifest, packages) - exempt = dependency_closure(nodes, roots) - unsafe = sorted({packages[p] for p in seen - exempt} - ALLOWED) - unsafe_runtime = sorted({packages[p] for p in seen - exempt} & FORBIDDEN_DEPS) - if unsafe_runtime: - raise SystemExit(f'{mode}: runtime dependencies in contract closure: {unsafe_runtime}') - if unsafe: - raise SystemExit(f'{mode}: unsafe contract dependencies: {unsafe}') - print(f'{mode}: pure contract closure ({len(seen)} packages)') - - -if __name__ == '__main__': - main() + metadata = run_metadata(probe / "Cargo.toml", offline=args.offline) + errors = dependency_violations(metadata) + if errors: + print(f"{mode}: contract dependency boundary failed:", file=sys.stderr) + print("\n".join(f"- {error}" for error in errors), file=sys.stderr) + return 1 + print(f"{mode}: pure contract closure ({len(reachable_normal_build(metadata)[2])} packages)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh index 2ca559232..8c05dcb74 100755 --- a/scripts/check-coverage.sh +++ b/scripts/check-coverage.sh @@ -2,4 +2,6 @@ set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" cd "$root" +scripts/test-client-e2e.sh --prepare-only +source target/tinychannels-client-e2e.env .github/scripts/check-file-coverage.sh 90 coverage.json diff --git a/scripts/check-storage-engine-trees.sh b/scripts/check-storage-engine-trees.sh index 46a918073..c821bf11d 100755 --- a/scripts/check-storage-engine-trees.sh +++ b/scripts/check-storage-engine-trees.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -blocked='^(rusqlite|libsqlite3-sys|mongodb|bson|aws-sdk-s3|lapin|amq-protocol|sqlx|redb|sled|rocksdb|tinystoragedrivers-(document|kv|blob|queue|secrets|connect)|tinystoragedrivers)$' +blocked='^(rusqlite|libsqlite3-sys|mongodb|bson|aws-sdk-s3|lapin|amq-protocol|sqlx|redb|sled|rocksdb|tinystoragedrivers-(core|file|mongodb|s3|sqlite|document|kv|blob|queue|secrets|connect)|tinystoragedrivers)$' check_tree() { package="$1" @@ -13,9 +13,10 @@ check_tree() { offender="$(printf '%s\n' "$names" | grep -Ex "$blocked" || true)" if [[ -n "$offender" ]]; then # SQLite is the one deliberate feature exception: the implementation's - # opt-in iMessage reader uses rusqlite. Keep it confined to that package - # and verify the no-default-features build below remains SQLite-free. - if [[ "$package" == "tinychannels" && "${ALLOW_IMESSAGE_SQLITE:-0}" == 1 ]]; then + # opt-in iMessage reader uses rusqlite and ships in the module artifact. + # Confine it to those implementations; the library's no-default-features + # build below must remain SQLite-free. + if [[ ( "$package" == "tinychannels" || "$package" == "tinychannels-module" ) && "${ALLOW_IMESSAGE_SQLITE:-0}" == 1 ]]; then offender="$(printf '%s\n' "$offender" | grep -Ev '^(rusqlite|libsqlite3-sys)$' || true)" fi fi @@ -74,6 +75,21 @@ if [[ "${1:-}" == "--self-test" ]]; then printf 'self-test failed: the SQLite exception accepted an unrelated offender\n' >&2 exit 1 fi + for engine in core file mongodb s3 sqlite; do + if check_tree tinychannels "$(printf 'tinychannels\ntinystoragedrivers-%s\n' "$engine")"; then + printf 'self-test failed: TSD implementation %s was accepted\n' "$engine" >&2 + exit 1 + fi + done + ALLOW_IMESSAGE_SQLITE=1 check_tree tinychannels-module $'tinychannels-module\nrusqlite\nlibsqlite3-sys' + if ALLOW_IMESSAGE_SQLITE=1 check_tree tinychannels-module $'tinychannels-module\nrusqlite\ntinystoragedrivers-sqlite'; then + printf 'self-test failed: module iMessage exception accepted a storage engine\n' >&2 + exit 1 + fi + if ALLOW_IMESSAGE_SQLITE=1 check_tree tinychannels-bus $'tinychannels-bus\nrusqlite'; then + printf 'self-test failed: iMessage exception escaped to the bus contract\n' >&2 + exit 1 + fi metadata='{"workspace_members":["lib-id","rlib-id","dylib-id","cdylib-id","staticlib-id","proc-macro-id","binary-id"],"packages":[{"id":"lib-id","name":"lib-only","targets":[{"kind":["lib"]}]},{"id":"rlib-id","name":"rlib-only","targets":[{"kind":["rlib"]}]},{"id":"dylib-id","name":"dylib-only","targets":[{"kind":["dylib"]}]},{"id":"cdylib-id","name":"cdylib-only","targets":[{"kind":["cdylib"]}]},{"id":"staticlib-id","name":"staticlib-only","targets":[{"kind":["staticlib"]}]},{"id":"proc-macro-id","name":"proc-macro-only","targets":[{"kind":["proc-macro"]}]},{"id":"binary-id","name":"binary-only","targets":[{"kind":["bin"]}]},{"id":"external-id","name":"external-dylib","targets":[{"kind":["dylib"]}]}]}' selected="$(printf '%s' "$metadata" | library_packages)" expected=$'lib-only\nrlib-only\ndylib-only\ncdylib-only\nstaticlib-only\nproc-macro-only' @@ -113,6 +129,8 @@ while IFS= read -r package; do exit 1 fi check_tree "$package" "$default_names" + elif [[ "$package" == "tinychannels-module" ]]; then + ALLOW_IMESSAGE_SQLITE=1 check_tree "$package" "$names" else check_tree "$package" "$names" fi diff --git a/scripts/check-storage-needs-boundary.py b/scripts/check-storage-needs-boundary.py new file mode 100755 index 000000000..a579ed659 --- /dev/null +++ b/scripts/check-storage-needs-boundary.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Guard the standalone consumer of pure bus collection declarations.""" + +import importlib.util +import json +from pathlib import Path +import subprocess +import sys + + +ROOT = Path(__file__).resolve().parent.parent +MANIFEST = ROOT / "proofs/storage-needs-consumer/Cargo.toml" +PROOF_PACKAGE = "tinychannels-storage-needs-consumer" + +# Share the contract gate's reviewed serde/schema dependency allowlist and its +# fail-closed normal/build graph traversal. The proof adds only its root crate; +# it must never need a storage driver, module runtime, or transport dependency. +SPEC = importlib.util.spec_from_file_location("bus_purity", ROOT / "scripts/check-bus-purity.py") +BUS_PURITY = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(BUS_PURITY) + + +def violations(metadata): + """Require a reachable bus contract and a complete, pure consumer closure.""" + if not isinstance(metadata, dict): + return ["Cargo metadata is not an object"] + packages, _, reachable, errors = BUS_PURITY.reachable_normal_build( + metadata, root_package=PROOF_PACKAGE + ) + for name in (PROOF_PACKAGE, "tinychannels-bus"): + package_ids = [package_id for package_id, package in packages.items() if package["name"] == name] + if len(package_ids) != 1: + errors.append(f"expected one {name} package, found {len(package_ids)}") + elif package_ids[0] not in reachable: + errors.append(f"{name} is missing from the resolved consumer closure") + allowed = BUS_PURITY.ALLOWED | {PROOF_PACKAGE} + unsafe = sorted({packages[package_id]["name"] for package_id in reachable} - allowed) + for name in unsafe: + errors.append(f"forbidden package in collection declaration graph: {name}") + return errors + + +def main(): + try: + output = subprocess.check_output( + ["cargo", "metadata", "--locked", "--format-version", "1", "--manifest-path", str(MANIFEST)], + cwd=ROOT, + text=True, + ) + errors = violations(json.loads(output)) + except (subprocess.CalledProcessError, json.JSONDecodeError) as error: + print(error, file=sys.stderr) + return 1 + if errors: + print("storage collection boundary failed:", file=sys.stderr) + print("\n".join(f"- {error}" for error in errors), file=sys.stderr) + return 1 + print("storage collection declarations use the standalone pure bus contract") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-bus-purity.py b/scripts/test-bus-purity.py new file mode 100644 index 000000000..db9039932 --- /dev/null +++ b/scripts/test-bus-purity.py @@ -0,0 +1,141 @@ +"""Source and graph controls for the synchronous, storage-free contract.""" + +import importlib.util +from pathlib import Path +import tempfile +import tomllib +import unittest +from unittest.mock import patch + + +SCRIPT = Path(__file__).with_name("check-bus-purity.py") +SPEC = importlib.util.spec_from_file_location("bus_purity", SCRIPT) +BUS_PURITY = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(BUS_PURITY) + + +def metadata_fixture(*, extra=(), kind=None): + packages = [ + {"id": "bus", "name": "tinychannels-bus", "version": "0.2.0"}, + {"id": "serde", "name": "serde", "version": "1.0.0"}, + *extra, + ] + nodes = [ + {"id": "bus", "features": ["storage"], "deps": [ + {"pkg": "serde", "name": "serde", "dep_kinds": [{"kind": None}]}, + ]}, + {"id": "serde", "features": [], "deps": []}, + ] + for package in extra: + nodes[0]["deps"].append({"pkg": package["id"], "name": package["name"], "dep_kinds": [{"kind": kind}]}) + nodes.append({"id": package["id"], "features": [], "deps": []}) + return {"packages": packages, "resolve": {"nodes": nodes}} + + +class BusPurityTests(unittest.TestCase): + def test_bus_storage_schema_is_a_pure_collection_declaration(self): + self.assertEqual(BUS_PURITY.source_tree_violations(), []) + schema = (BUS_PURITY.BUS_SOURCE / "storage/schema.rs").read_text(encoding="utf-8") + self.assertIn("CollectionSpec", schema) + self.assertNotIn("async fn", schema) + self.assertNotIn("tinystoragedrivers_", schema) + implementation = BUS_PURITY.ROOT / "src/storage" + self.assertIn("pub async fn prepare", (implementation / "mod.rs").read_text(encoding="utf-8")) + self.assertIn("pub fn stored_delivery_from_queued", (implementation / "records.rs").read_text(encoding="utf-8")) + + def test_bus_declares_no_runtime_or_storage_driver_dependencies(self): + manifest = tomllib.loads(BUS_PURITY.BUS_MANIFEST.read_text(encoding="utf-8")) + self.assertEqual(BUS_PURITY.declared_forbidden_dependencies(manifest), set()) + self.assertEqual(manifest["features"]["storage"], []) + self.assertEqual(BUS_PURITY.dependency_violations(metadata_fixture()), []) + + def test_storage_clients_and_types_are_rejected_for_any_version_or_source(self): + for name in ("tinystoragedrivers-busproxy", "tinystoragedrivers-types", "tinystoragedrivers-core"): + for version in ("1.9.0", "2.1.2"): + with self.subTest(name=name, version=version): + graph = metadata_fixture(extra=[{ + "id": "storage", "name": name, "version": version, + "source": "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761", + }]) + self.assertTrue(any("unsafe contract dependencies" in error for error in BUS_PURITY.dependency_violations(graph))) + + def test_tokio_is_rejected_even_with_only_sync_features(self): + for features in (["sync"], ["sync", "rt"]): + with self.subTest(features=features): + graph = metadata_fixture(extra=[{"id": "tokio", "name": "tokio"}]) + graph["resolve"]["nodes"][-1]["features"] = features + self.assertTrue(BUS_PURITY.dependency_violations(graph)) + + def test_normal_and_build_dependencies_are_audited_but_dev_edges_are_excluded(self): + for kind in (None, "normal", "build", "dev"): + with self.subTest(kind=kind): + graph = metadata_fixture(extra=[{"id": "http", "name": "reqwest"}], kind=kind) + self.assertEqual(bool(BUS_PURITY.dependency_violations(graph)), kind != "dev") + + def test_transitive_build_dependency_is_rejected(self): + graph = metadata_fixture(extra=[{"id": "http", "name": "reqwest"}]) + edge = graph["resolve"]["nodes"][0]["deps"].pop() + edge["dep_kinds"] = [{"kind": "build"}] + graph["resolve"]["nodes"][1]["deps"].append(edge) + self.assertTrue(BUS_PURITY.dependency_violations(graph)) + + def test_unrecognized_dependencies_are_rejected(self): + graph = metadata_fixture(extra=[{"id": "new", "name": "unreviewed-crate"}]) + self.assertTrue(BUS_PURITY.dependency_violations(graph)) + + def test_incomplete_metadata_fails_closed(self): + mutations = ( + lambda g: g.pop("packages"), + lambda g: g.update(resolve=None), + lambda g: g["resolve"].pop("nodes"), + lambda g: g["resolve"]["nodes"].pop(), + lambda g: g["resolve"]["nodes"][0].pop("features"), + lambda g: g["resolve"]["nodes"][0].update(features=[None]), + lambda g: g["resolve"]["nodes"][0]["deps"][0].update(dep_kinds=[{}]), + lambda g: g["resolve"]["nodes"][0]["deps"][0].update(dep_kinds=[{"kind": []}]), + lambda g: g["resolve"]["nodes"][0].pop("deps"), + lambda g: g["resolve"]["nodes"][0]["deps"][0].pop("dep_kinds"), + lambda g: g["resolve"]["nodes"][0]["deps"][0].update(dep_kinds=[]), + lambda g: g["resolve"]["nodes"][0]["deps"][0].update(dep_kinds=[{"kind": "unknown"}]), + lambda g: g["resolve"]["nodes"][0]["deps"][0].pop("pkg"), + lambda g: g["packages"].append(dict(g["packages"][0])), + lambda g: g["resolve"]["nodes"].append(dict(g["resolve"]["nodes"][0])), + ) + for mutate in mutations: + with self.subTest(mutation=mutate): + graph = metadata_fixture() + mutate(graph) + self.assertTrue(BUS_PURITY.dependency_violations(graph)) + + def test_execution_and_async_interfaces_are_rejected_including_traits(self): + for source in ( + "async fn send() {}", "#[async_trait] trait Channel {}", + "tokio::sync::mpsc::Sender", "std::net::TcpStream", + "std::process::Command", "std::env::var(\"KEY\")", "std::time::Instant", + "std::fs::read(path)", "std::thread::spawn(f)", "std::sync::Arc", + "parking_lot::Mutex", "sha2::Sha256", "hmac::Hmac", "rand::random()", + "uuid::Uuid", "reqwest::Client", "impl Channel for Provider {}", + "fn build_session_key() {}", "fn classify_send_error() {}", + "fn authenticated_inbound_event() {}", "fn segment_for_delivery() {}", + "use tinystoragedrivers_types::CollectionSpec;", + ): + with self.subTest(source=source): + self.assertTrue(BUS_PURITY.source_violations(source, source_path=Path("traits.rs"))) + + def test_migrations_and_storage_projections_are_rejected_but_tests_are_skipped(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + storage = root / "crates/tinychannels-bus/src/storage" + storage.mkdir(parents=True) + (storage / "records.rs").write_text("impl From for StoredDelivery {}", encoding="utf-8") + (storage / "migrate.rs").write_text("", encoding="utf-8") + (storage / "records_tests.rs").write_text("std::fs::read(path)", encoding="utf-8") + with patch.object(BUS_PURITY, "ROOT", root): + errors = BUS_PURITY.source_tree_violations() + self.assertEqual(len(errors), 2) + self.assertTrue(any("migration execution" in error for error in errors)) + self.assertTrue(any("storage projections" in error for error in errors)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-client-e2e-standalone.py b/scripts/test-client-e2e-standalone.py new file mode 100644 index 000000000..f90bd9171 --- /dev/null +++ b/scripts/test-client-e2e-standalone.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +"""Exercise the client E2E script from a standalone checkout with fake Cargo.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import shutil +import subprocess + + +ROOT = Path(__file__).resolve().parent.parent +WORK = ROOT / "target" / "evidence" / "client-e2e-standalone-smoke" + + +def fail(message: str) -> None: + raise SystemExit(message) + + +def write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def main() -> None: + bash_executable = os.environ.get("FEATURE_TEST_BASH", "bash") + if WORK.exists(): + shutil.rmtree(WORK) + repo = WORK / "tinychannels" + (repo / "scripts").mkdir(parents=True) + shutil.copy2(ROOT / "Cargo.toml", repo / "Cargo.toml") + shutil.copy2(ROOT / "scripts" / "test-client-e2e.sh", repo / "scripts") + shutil.copy2(ROOT / "scripts" / "test-client-e2e.ps1", repo / "scripts") + shutil.copytree( + ROOT / "crates" / "tinychannels-client" / "test-fixture", + repo / "crates" / "tinychannels-client" / "test-fixture", + ignore=shutil.ignore_patterns("src", "target"), + ) + + fake_bin = WORK / "bin" + write( + fake_bin / "rustc", + "#!/bin/sh\nprintf '%s\\n' 'host: aarch64-apple-darwin'\n", + ) + write( + fake_bin / "uname", + "#!/bin/sh\nprintf '%s\\n' \"${TINYCHANNELS_FAKE_UNAME:-Darwin}\"\n", + ) + pwsh_stub = fake_bin / "pwsh_stub.py" + write( + pwsh_stub, + """#!/usr/bin/env python3 +import json +import os +import sys +from pathlib import Path + +with Path(os.environ['TINYCHANNELS_FAKE_PWSH_LOG']).open('a', encoding='utf-8') as stream: + stream.write(json.dumps(sys.argv[1:]) + '\\n') +""", + ) + write(fake_bin / "pwsh", f"#!/bin/sh\nexec python3 '{pwsh_stub}' \"$@\"\n") + cargo_stub = fake_bin / "cargo_stub.py" + write( + cargo_stub, + """#!/usr/bin/env python3 +import json +import os +from pathlib import Path +import sys + +args = sys.argv[1:] +root = Path(os.environ['TINYCHANNELS_FAKE_REPO']) +target = Path(os.environ['TINYCHANNELS_FAKE_TARGET']) +record = { + 'args': args, + 'fixture_cache': os.environ.get('TINYCHANNELS_CLIENT_TEST_CACHE'), + 'native_cache': os.environ.get('TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_CACHE'), +} +with Path(os.environ['TINYCHANNELS_FAKE_CARGO_LOG']).open('a', encoding='utf-8') as stream: + stream.write(json.dumps(record) + '\\n') +if not args: + raise SystemExit('fake cargo received no command') +if args[0] == 'build': + output = target / 'debug' + output.mkdir(parents=True, exist_ok=True) + library = 'libtinychannels_module.dylib' if '-p' in args else 'libtinychannels_client_test_fixture.dylib' + (output / library).write_bytes(b'fake native library: ' + library.encode()) +elif args[0] == 'test': + test_name = args[args.index('--test') + 1] + expected = {'native_e2e': 4, 'runtime_native_e2e': 1, 'runtime_readiness_e2e': 1, 'native_module_runtime_e2e': 1}[test_name] + if os.environ.get('TINYCHANNELS_FAKE_ZERO_TEST_TARGET') == test_name: + expected = 0 + print(f'test result: ok. {expected} passed; 0 failed; 0 ignored; 0 measured; 0 filtered out') +else: + raise SystemExit(f'unexpected fake cargo command: {args[0]}') +""", + ) + write(fake_bin / "cargo", f"#!/bin/sh\nexec python3 '{cargo_stub}' \"$@\"\n") + + cargo_log = WORK / "cargo.jsonl" + pwsh_log = WORK / "pwsh.jsonl" + env = os.environ.copy() + env.update( + { + "PATH": f"{fake_bin}{os.pathsep}{env['PATH']}", + "RUNNER_OS": "", + "TINYCHANNELS_FAKE_REPO": str(repo), + "TINYCHANNELS_FAKE_TARGET": str(repo / "target"), + "TINYCHANNELS_FAKE_CARGO_LOG": str(cargo_log), + "TINYCHANNELS_FAKE_PWSH_LOG": str(pwsh_log), + "TINYCHANNELS_FAKE_UNAME": "Darwin", + } + ) + result = subprocess.run( + [bash_executable, str(repo / "scripts" / "test-client-e2e.sh")], + cwd=repo, + env=env, + check=False, + capture_output=True, + text=True, + ) + if result.returncode: + fail( + "standalone Bash E2E script failed with fake Cargo " + f"(exit {result.returncode}):\n{result.stdout}{result.stderr}" + ) + if pwsh_log.exists(): + fail("Unix fake-Cargo smoke unexpectedly delegated to PowerShell") + + bash_script = (ROOT / "scripts" / "test-client-e2e.sh").read_text(encoding="utf-8") + powershell_script = (ROOT / "scripts" / "test-client-e2e.ps1").read_text( + encoding="utf-8" + ) + for label, script in (("Bash", bash_script), ("PowerShell", powershell_script)): + if "ci-cancel-aware.sh" in script or "../../scripts" in script: + fail(f"{label} client E2E script depends on files outside its standalone repository") + if re.search(r"\$cancelAware|&\s*bash\s+\$cancelAware", powershell_script): + fail("PowerShell client E2E script still wraps Cargo through an external helper") + if len(re.findall(r"(?m)^cargo\s+build\b", bash_script)) != 2 or "cargo test" not in bash_script: + fail("Bash client E2E script must build both fixtures and run counted native tests") + if len(re.findall(r"(?m)^&\s+cargo build\b", powershell_script)) != 2 or "& cargo @Arguments" not in powershell_script: + fail("PowerShell client E2E script must build both fixtures and run counted native tests") + + calls = [json.loads(line) for line in cargo_log.read_text(encoding="utf-8").splitlines()] + if len(calls) != 6 or [call["args"][0] for call in calls] != [ + "build", + "build", + "test", + "test", + "test", + "test", + ]: + fail(f"expected two builds and three test commands, captured: {calls!r}") + + expected_tests = ["native_e2e", "runtime_native_e2e", "runtime_readiness_e2e", "native_module_runtime_e2e"] + for call, expected in zip(calls[2:], expected_tests, strict=True): + args = call["args"] + if "--test" not in args or args[args.index("--test") + 1] != expected: + fail(f"expected Cargo test target {expected}, captured: {args!r}") + manifest = Path(args[args.index("--manifest-path") + 1]) + if manifest != repo / "Cargo.toml": + fail(f"Cargo test escaped the standalone repository: {manifest}") + for key in ("fixture_cache", "native_cache"): + if not call[key] or not Path(call[key]).is_relative_to(repo / "target"): + fail(f"Cargo test {key} is missing or outside the standalone target") + + expected_outputs = [ + repo / "target" / "tinychannels-client-e2e" / "cache" / "tinychannels-module-client-test.tar.gz", + repo / "target" / "tinychannels-module-client-native-e2e" / "cache" / "tinychannels-module-native-test.tar.gz", + ] + for output in expected_outputs: + if not output.is_file(): + fail(f"expected local staged module archive: {output}") + expected_cargo_call_count = len(calls) + + windows_delegations = ( + ("MINGW64_NT-10.0", True), + ("MSYS_NT-10.0", True), + ("CYGWIN_NT-10.0", False), + ) + for platform, prepare_only in windows_delegations: + windows_env = dict(env, TINYCHANNELS_FAKE_UNAME=platform) + windows_env.pop("RUNNER_OS", None) + command = [bash_executable, str(repo / "scripts" / "test-client-e2e.sh")] + if prepare_only: + command.append("--prepare-only") + delegated = subprocess.run( + command, + cwd=repo, + env=windows_env, + check=False, + capture_output=True, + text=True, + ) + if delegated.returncode: + fail( + f"RUNNER_OS-unset {platform} delegation failed " + f"(exit {delegated.returncode}): {delegated.stdout}{delegated.stderr}" + ) + calls = [json.loads(line) for line in pwsh_log.read_text(encoding="utf-8").splitlines()] + expected_args = ["-NoProfile", "-File", "scripts/test-client-e2e.ps1"] + if prepare_only: + expected_args.append("-PrepareOnly") + if calls[-1:] != [expected_args]: + fail(f"RUNNER_OS-unset {platform} delegated unexpected PowerShell args: {calls!r}") + after_calls = [ + json.loads(line) for line in cargo_log.read_text(encoding="utf-8").splitlines() + ] + if len(after_calls) != expected_cargo_call_count: + fail(f"RUNNER_OS-unset {platform} delegation unexpectedly invoked fake Cargo") + environment_file = repo / "target" / "tinychannels-client-e2e.env" + if not environment_file.is_file(): + fail("client fixture preparation did not write its reusable environment file") + environment_bytes = environment_file.read_bytes() + environment = environment_bytes.decode("utf-8") + if b"\r" in environment_bytes: + fail("prepared client fixture environment must use LF-only line endings") + for key in ( + "TINYCHANNELS_CLIENT_TEST_CACHE", + "TINYCHANNELS_CLIENT_TEST_ARCHIVE_NAME", + "TINYCHANNELS_CLIENT_TEST_ARCHIVE_SHA256", + "TINYCHANNELS_CLIENT_TEST_LIBRARY_SHA256", + "TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_CACHE", + "TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_ARCHIVE_NAME", + "TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_ARCHIVE_SHA256", + "TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_LIBRARY_SHA256", + "TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_RELEASE_URL", + ): + if f"export {key}=" not in environment: + fail(f"client fixture environment omits {key}") + + source_env = """set -e +source "$1" +printf '%s\\0' \\ + "$TINYCHANNELS_CLIENT_TEST_CACHE" \\ + "$TINYCHANNELS_CLIENT_TEST_ARCHIVE_NAME" \\ + "$TINYCHANNELS_CLIENT_TEST_ARCHIVE_SHA256" \\ + "$TINYCHANNELS_CLIENT_TEST_LIBRARY_SHA256" \\ + "$TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_CACHE" \\ + "$TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_ARCHIVE_NAME" \\ + "$TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_ARCHIVE_SHA256" \\ + "$TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_LIBRARY_SHA256" \\ + "$TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_RELEASE_URL" +""" + sourced_env = subprocess.run( + [bash_executable, "-c", source_env, "client-env-test", "target/tinychannels-client-e2e.env"], + cwd=repo, + check=False, + capture_output=True, + ) + if sourced_env.returncode: + fail(f"Bash could not source the generated client fixture environment: {sourced_env.stderr!r}") + values = sourced_env.stdout.decode("utf-8").split("\0")[:-1] + if len(values) != 9 or any("\r" in value for value in values): + fail(f"sourced client fixture environment has missing or CR-suffixed values: {values!r}") + normalized_cache = values[0].replace("\\", "/") + normalized_native_cache = values[4].replace("\\", "/") + if not normalized_cache.endswith("/target/tinychannels-client-e2e/cache"): + fail(f"Bash sourced an incorrect fixture cache path: {values[0]!r}") + if values[1] != "tinychannels-module-client-test.tar.gz" or values[5] != "tinychannels-module-native-test.tar.gz": + fail("Bash sourced an unexpected local fixture archive name") + if not normalized_native_cache.endswith("/target/tinychannels-module-client-native-e2e/cache"): + fail(f"Bash sourced an incorrect production module cache path: {values[4]!r}") + if values[8] != "https://github.com/tinyhumansai/tinychannels/releases/tag/v0.2.0": + fail("Bash sourced an unexpected local module release URL") + if any(not re.fullmatch(r"[0-9a-f]{64}", values[index]) for index in (2, 3, 6, 7)): + fail("Bash sourced an invalid archive or library digest") + + crlf_value = r"D:\a\repo\target\fixture-cache" + crlf_line_env = repo / "target" / "windows-crlf-line-ending-control.env" + crlf_line_env.write_bytes(f"export CLIENT_TEST_VALUE='{crlf_value}'\r\n".encode("utf-8")) + crlf_line_result = subprocess.run( + [bash_executable, "-c", 'source "$1"; printf "%s" "$CLIENT_TEST_VALUE"', + "client-env-crlf-line-ending-control", "target/windows-crlf-line-ending-control.env"], + cwd=repo, + check=False, + capture_output=True, + ) + crlf_prefix = crlf_value.encode("utf-8") + if crlf_line_result.returncode or not crlf_line_result.stdout.startswith(crlf_prefix): + fail(f"Bash CRLF line-ending control changed the value prefix: {crlf_line_result.stdout!r}") + trailing_cr = crlf_line_result.stdout[len(crlf_prefix) :] + if trailing_cr not in (b"", b"\r"): + fail(f"Bash CRLF line-ending control had unexpected trailing bytes: {trailing_cr!r}") + + embedded_cr_env = repo / "target" / "embedded-cr-control.env" + embedded_cr_env.write_text( + f"CLIENT_TEST_BASE='{crlf_value}'\n" + "printf -v CLIENT_TEST_VALUE '%s\\r' \"$CLIENT_TEST_BASE\"\n" + "export CLIENT_TEST_VALUE\n", + encoding="utf-8", + newline="\n", + ) + embedded_cr_result = subprocess.run( + [bash_executable, "-c", 'source "$1"; printf "%s" "$CLIENT_TEST_VALUE"', + "client-env-embedded-cr-control", "target/embedded-cr-control.env"], + cwd=repo, + check=False, + capture_output=True, + ) + if embedded_cr_result.returncode or embedded_cr_result.stdout.decode("utf-8") != crlf_value + "\r": + fail("Bash embedded-CR control did not preserve the corrupt environment value") + print( + "Bash CRLF trailing-byte repr: " + f"{trailing_cr!r}; embedded-CR control: retained; prepared env: LF-only" + ) + + prepare_result = subprocess.run( + [bash_executable, str(repo / "scripts" / "test-client-e2e.sh"), "--prepare-only"], + cwd=repo, + env=env, + check=False, + capture_output=True, + text=True, + ) + if prepare_result.returncode: + fail(f"prepare-only client E2E mode failed: {prepare_result.stdout}{prepare_result.stderr}") + prepare_calls = [json.loads(line) for line in cargo_log.read_text(encoding="utf-8").splitlines()] + if len(prepare_calls) != 8 or [call["args"][0] for call in prepare_calls[-2:]] != ["build", "build"]: + fail("prepare-only client E2E mode must build both local fixtures without running tests") + + empty_env = dict(env, TINYCHANNELS_FAKE_ZERO_TEST_TARGET="native_e2e") + empty_result = subprocess.run( + [bash_executable, str(repo / "scripts" / "test-client-e2e.sh")], + cwd=repo, + env=empty_env, + check=False, + capture_output=True, + text=True, + ) + if empty_result.returncode == 0 or "expected 4" not in empty_result.stderr: + fail("client E2E runner accepted a successful Cargo command that reported zero native tests") + + print("standalone client E2E scripts use checkout-local files; count guard rejected zero native tests") + print("RUNNER_OS-unset Git Bash simulation delegated MINGW/MSYS/CYGWIN to PowerShell") + + +if __name__ == "__main__": + main() diff --git a/scripts/test-client-e2e.ps1 b/scripts/test-client-e2e.ps1 new file mode 100644 index 000000000..e6d27e012 --- /dev/null +++ b/scripts/test-client-e2e.ps1 @@ -0,0 +1,124 @@ +param([switch]$PrepareOnly) +$ErrorActionPreference = 'Stop' +$root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +Set-Location $root +$target = Join-Path $root 'target' +New-Item -ItemType Directory -Force $target | Out-Null +$target = (Resolve-Path -LiteralPath $target).Path +$rootPrefix = $root.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar +if (-not $target.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'test output must stay inside the checkout target directory' } + +$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() +$runnerSid = "*$($identity.User.Value):(OI)(CI)F" +$adminSid = '*S-1-5-32-544:(OI)(CI)F' +$systemSid = '*S-1-5-18:(OI)(CI)F' +function Set-PrivateDirectory([string]$Path) { + New-Item -ItemType Directory -Force $Path | Out-Null + icacls $Path /reset | Out-Null + if ($LASTEXITCODE -ne 0) { throw "could not reset test fixture directory permissions: $Path" } + icacls $Path /inheritance:r /grant:r $runnerSid $adminSid $systemSid | Out-Null + if ($LASTEXITCODE -ne 0) { throw "could not restrict test fixture directory permissions: $Path" } + icacls $Path /setowner $identity.Name | Out-Null + if ($LASTEXITCODE -ne 0) { throw "could not set test fixture directory owner: $Path" } +} + +function ConvertTo-BashLiteral([string]$Value) { + return "'" + $Value.Replace("'", "'\''") + "'" +} + +function Invoke-CountedNativeTest { + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][int]$ExpectedCount, + [Parameter(Mandatory)][string[]]$Arguments + ) + + $logPath = Join-Path $nativeTestLogs "$Name.log" + & cargo @Arguments 2>&1 | Tee-Object -FilePath $logPath + if ($LASTEXITCODE -ne 0) { throw "TinyChannels native E2E target failed: $Name" } + $summaries = @(Select-String -Path $logPath -Pattern '^test result: ok\. (\d+) passed;') + if ($summaries.Count -eq 0) { throw "TinyChannels native E2E target reported no test result: $Name" } + $actualCount = [int]$summaries[-1].Matches[0].Groups[1].Value + if ($actualCount -ne $ExpectedCount) { + throw "TinyChannels native E2E target $Name ran $actualCount tests; expected $ExpectedCount" + } +} + +$fixtureManifest = Join-Path $root 'crates/tinychannels-client/test-fixture/Cargo.toml' +& cargo build --manifest-path $fixtureManifest --locked --target-dir $target +if ($LASTEXITCODE -ne 0) { throw 'Failed to build tinychannels-client test fixture' } +& cargo build --manifest-path (Join-Path $root 'Cargo.toml') --locked --target-dir $target -p tinychannels-module +if ($LASTEXITCODE -ne 0) { throw 'Failed to build the actual TinyChannels module' } + +$stage = Join-Path $target 'tinychannels-client-e2e' +$cache = Join-Path $stage 'cache' +$archiveName = 'tinychannels-module-client-test.tar.gz' +$archive = Join-Path $stage $archiveName +$nativeStage = Join-Path $target 'tinychannels-module-client-native-e2e' +$nativeCache = Join-Path $nativeStage 'cache' +$nativeArchiveName = 'tinychannels-module-native-test.tar.gz' +$nativeArchive = Join-Path $nativeStage $nativeArchiveName +Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item -LiteralPath $nativeStage -Recurse -Force -ErrorAction SilentlyContinue +Set-PrivateDirectory $stage +Set-PrivateDirectory $cache +Set-PrivateDirectory $nativeStage +Set-PrivateDirectory $nativeCache +$libraryName = 'tinychannels_client_test_fixture.dll' +$library = Join-Path $cache $libraryName +Copy-Item -LiteralPath (Join-Path $target "debug/$libraryName") -Destination $library +$nativeLibraryName = 'tinychannels_module.dll' +$nativeLibrary = Join-Path $nativeCache $nativeLibraryName +Copy-Item -LiteralPath (Join-Path $target "debug/$nativeLibraryName") -Destination $nativeLibrary +$libraryHash = (Get-FileHash -LiteralPath $library -Algorithm SHA256).Hash.ToLowerInvariant() +'"{0}" = "{1}"' -f $libraryName, $libraryHash | + Set-Content -Path (Join-Path $cache 'modules.toml') -Encoding utf8NoBOM +$nativeTar = Join-Path $env:SystemRoot 'System32/tar.exe' +if (-not (Test-Path -LiteralPath $nativeTar -PathType Leaf)) { throw "Windows tar.exe is missing: $nativeTar" } +& $nativeTar -czf $archive -C $cache $libraryName modules.toml +if ($LASTEXITCODE -ne 0) { throw 'Could not archive the local TinyChannels test fixture' } +Copy-Item -LiteralPath $archive -Destination (Join-Path $cache $archiveName) +$archiveHash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() +$nativeLibraryHash = (Get-FileHash -LiteralPath $nativeLibrary -Algorithm SHA256).Hash.ToLowerInvariant() +'"{0}" = "{1}"' -f $nativeLibraryName, $nativeLibraryHash | + Set-Content -Path (Join-Path $nativeCache 'modules.toml') -Encoding utf8NoBOM +& $nativeTar -czf $nativeArchive -C $nativeCache $nativeLibraryName modules.toml +if ($LASTEXITCODE -ne 0) { throw 'Could not archive the actual TinyChannels module' } +Copy-Item -LiteralPath $nativeArchive -Destination (Join-Path $nativeCache $nativeArchiveName) +$nativeArchiveHash = (Get-FileHash -LiteralPath $nativeArchive -Algorithm SHA256).Hash.ToLowerInvariant() + +$env:TINYCHANNELS_CLIENT_TEST_CACHE = $cache +$env:TINYCHANNELS_CLIENT_TEST_ARCHIVE_NAME = $archiveName +$env:TINYCHANNELS_CLIENT_TEST_ARCHIVE_SHA256 = $archiveHash +$env:TINYCHANNELS_CLIENT_TEST_LIBRARY_SHA256 = $libraryHash +$env:TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_CACHE = $nativeCache +$env:TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_ARCHIVE_NAME = $nativeArchiveName +$env:TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_ARCHIVE_SHA256 = $nativeArchiveHash +$env:TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_LIBRARY_SHA256 = $nativeLibraryHash +$env:TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_RELEASE_URL = 'https://github.com/tinyhumansai/tinychannels/releases/tag/v0.2.0' +$environmentPath = Join-Path $target 'tinychannels-client-e2e.env' +$environment = @( + "export TINYCHANNELS_CLIENT_TEST_CACHE=$(ConvertTo-BashLiteral $cache)" + "export TINYCHANNELS_CLIENT_TEST_ARCHIVE_NAME=$(ConvertTo-BashLiteral $archiveName)" + "export TINYCHANNELS_CLIENT_TEST_ARCHIVE_SHA256=$(ConvertTo-BashLiteral $archiveHash)" + "export TINYCHANNELS_CLIENT_TEST_LIBRARY_SHA256=$(ConvertTo-BashLiteral $libraryHash)" + "export TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_CACHE=$(ConvertTo-BashLiteral $nativeCache)" + "export TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_ARCHIVE_NAME=$(ConvertTo-BashLiteral $nativeArchiveName)" + "export TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_ARCHIVE_SHA256=$(ConvertTo-BashLiteral $nativeArchiveHash)" + "export TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_LIBRARY_SHA256=$(ConvertTo-BashLiteral $nativeLibraryHash)" + "export TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_RELEASE_URL=$(ConvertTo-BashLiteral $env:TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_RELEASE_URL)" +) +$environmentText = ($environment -join "`n") + "`n" +[System.IO.File]::WriteAllText($environmentPath, $environmentText, [System.Text.UTF8Encoding]::new($false)) +if ($PrepareOnly) { return } + +$nativeTestLogs = Join-Path $target 'tinychannels-client-e2e-logs' +Remove-Item -LiteralPath $nativeTestLogs -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force $nativeTestLogs | Out-Null +$manifest = Join-Path $root 'Cargo.toml' +$common = @('--manifest-path', $manifest, '--locked', '--target-dir', $target, + '-p', 'tinychannels-client', '--features', 'native-e2e') +Invoke-CountedNativeTest -Name 'native_e2e' -ExpectedCount 4 -Arguments (@('test') + $common + @('--test', 'native_e2e', '--', '--test-threads=1')) +Invoke-CountedNativeTest -Name 'runtime_native_e2e' -ExpectedCount 1 -Arguments (@('test') + $common + @('--test', 'runtime_native_e2e', '--', '--test-threads=1')) +Invoke-CountedNativeTest -Name 'runtime_readiness_e2e' -ExpectedCount 1 -Arguments (@('test') + $common + @('--test', 'runtime_readiness_e2e', '--', '--test-threads=1')) +Invoke-CountedNativeTest -Name 'native_module_runtime_e2e' -ExpectedCount 1 -Arguments (@('test') + $common + @('--test', 'native_module_runtime_e2e', '--', '--test-threads=1')) diff --git a/scripts/test-client-e2e.sh b/scripts/test-client-e2e.sh new file mode 100755 index 000000000..783bd466b --- /dev/null +++ b/scripts/test-client-e2e.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$repo_root" +prepare_only=0 +if [[ "$#" -gt 1 || ( "$#" -eq 1 && "$1" != "--prepare-only" ) ]]; then + echo "usage: scripts/test-client-e2e.sh [--prepare-only]" >&2 + exit 64 +fi +if [[ "$#" -eq 1 ]]; then prepare_only=1; fi +case "${RUNNER_OS:-$(uname -s)}" in + Windows|MINGW*|MSYS*|CYGWIN*) + if [[ "$prepare_only" -eq 1 ]]; then + pwsh -NoProfile -File scripts/test-client-e2e.ps1 -PrepareOnly + else + pwsh -NoProfile -File scripts/test-client-e2e.ps1 + fi + exit + ;; +esac +TASK_TARGET_ROOT="$repo_root/target" +host="$(rustc -vV | sed -n 's/^host: //p')" +case "$host" in + *-apple-darwin) + library_name="libtinychannels_client_test_fixture.dylib" + native_library_name="libtinychannels_module.dylib" + ;; + *-linux-gnu) + library_name="libtinychannels_client_test_fixture.so" + native_library_name="libtinychannels_module.so" + ;; + *-windows-*) echo "test-client-e2e.sh requires RUNNER_OS=Windows on a Windows runner" >&2; exit 1 ;; + *) echo "unsupported test host: $host" >&2; exit 2 ;; +esac + +fixture_manifest="$repo_root/crates/tinychannels-client/test-fixture/Cargo.toml" +fixture_library="$TASK_TARGET_ROOT/debug/$library_name" +native_module_library="$TASK_TARGET_ROOT/debug/$native_library_name" +stage="$TASK_TARGET_ROOT/tinychannels-client-e2e" +cache="$stage/cache" +archive_name="tinychannels-module-client-test.tar.gz" +archive="$stage/$archive_name" +native_stage="$TASK_TARGET_ROOT/tinychannels-module-client-native-e2e" +native_cache="$native_stage/cache" +native_archive_name="tinychannels-module-native-test.tar.gz" +native_archive="$native_stage/$native_archive_name" + +cargo build --manifest-path "$fixture_manifest" --locked --target-dir "$TASK_TARGET_ROOT" +cargo build --manifest-path "$repo_root/Cargo.toml" --locked --target-dir "$TASK_TARGET_ROOT" -p tinychannels-module +rm -rf "$stage" +rm -rf "$native_stage" +mkdir -p "$cache" +mkdir -p "$native_cache" +chmod 700 "$stage" "$cache" +chmod 700 "$native_stage" "$native_cache" +install -m 755 "$fixture_library" "$cache/$library_name" +install -m 755 "$native_module_library" "$native_cache/$native_library_name" + +if command -v sha256sum >/dev/null 2>&1; then + library_sha256="$(sha256sum < "$cache/$library_name" | awk '{print $1}')" + archive_sha256() { sha256sum < "$1" | awk '{print $1}'; } +else + library_sha256="$(shasum -a 256 < "$cache/$library_name" | awk '{print $1}')" + archive_sha256() { shasum -a 256 < "$1" | awk '{print $1}'; } +fi +printf '"%s" = "%s"\n' "$library_name" "$library_sha256" > "$cache/modules.toml" +tar -C "$cache" -czf "$archive" "$library_name" modules.toml +install -m 644 "$archive" "$cache/$archive_name" +archive_hash="$(archive_sha256 "$archive")" + +if command -v sha256sum >/dev/null 2>&1; then + native_library_sha256="$(sha256sum < "$native_cache/$native_library_name" | awk '{print $1}')" +else + native_library_sha256="$(shasum -a 256 < "$native_cache/$native_library_name" | awk '{print $1}')" +fi +printf '"%s" = "%s"\n' "$native_library_name" "$native_library_sha256" > "$native_cache/modules.toml" +tar -C "$native_cache" -czf "$native_archive" "$native_library_name" modules.toml +install -m 644 "$native_archive" "$native_cache/$native_archive_name" +native_archive_hash="$(archive_sha256 "$native_archive")" + +export TINYCHANNELS_CLIENT_TEST_CACHE="$cache" +export TINYCHANNELS_CLIENT_TEST_ARCHIVE_NAME="$archive_name" +export TINYCHANNELS_CLIENT_TEST_ARCHIVE_SHA256="$archive_hash" +export TINYCHANNELS_CLIENT_TEST_LIBRARY_SHA256="$library_sha256" +export TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_CACHE="$native_cache" +export TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_ARCHIVE_NAME="$native_archive_name" +export TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_ARCHIVE_SHA256="$native_archive_hash" +export TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_LIBRARY_SHA256="$native_library_sha256" +export TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_RELEASE_URL="https://github.com/tinyhumansai/tinychannels/releases/tag/v0.2.0" + +write_environment_file() { + local env_file="$TASK_TARGET_ROOT/tinychannels-client-e2e.env" + { + printf 'export TINYCHANNELS_CLIENT_TEST_CACHE=%q\n' "$cache" + printf 'export TINYCHANNELS_CLIENT_TEST_ARCHIVE_NAME=%q\n' "$archive_name" + printf 'export TINYCHANNELS_CLIENT_TEST_ARCHIVE_SHA256=%q\n' "$archive_hash" + printf 'export TINYCHANNELS_CLIENT_TEST_LIBRARY_SHA256=%q\n' "$library_sha256" + printf 'export TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_CACHE=%q\n' "$native_cache" + printf 'export TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_ARCHIVE_NAME=%q\n' "$native_archive_name" + printf 'export TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_ARCHIVE_SHA256=%q\n' "$native_archive_hash" + printf 'export TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_LIBRARY_SHA256=%q\n' "$native_library_sha256" + printf 'export TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_RELEASE_URL=%q\n' \ + "$TINYCHANNELS_CLIENT_NATIVE_MODULE_TEST_RELEASE_URL" + } > "$env_file" +} +write_environment_file + +if [[ "$prepare_only" -eq 1 ]]; then + echo "Prepared local client and production-module fixtures; environment: $TASK_TARGET_ROOT/tinychannels-client-e2e.env" + exit 0 +fi + +test_logs="$TASK_TARGET_ROOT/tinychannels-client-e2e-logs" +rm -rf "$test_logs" +mkdir -p "$test_logs" +run_counted_test() { + local expected_count="$1" + local test_name="$2" + shift 2 + local log_path="$test_logs/$test_name.log" + if ! "$@" 2>&1 | tee "$log_path"; then + echo "native client E2E target $test_name failed; see $log_path" >&2 + return 1 + fi + local actual_count + actual_count="$(sed -nE 's/^test result: ok\. ([0-9]+) passed;.*/\1/p' "$log_path" | tail -n 1)" + if [[ "$actual_count" != "$expected_count" ]]; then + echo "native client E2E target $test_name ran ${actual_count:-no reported tests}; expected $expected_count" >&2 + return 1 + fi +} + +run_counted_test 4 native_e2e cargo test --manifest-path "$repo_root/Cargo.toml" --locked \ + -p tinychannels-client --features native-e2e --test native_e2e -- --test-threads=1 +run_counted_test 1 runtime_native_e2e cargo test --manifest-path "$repo_root/Cargo.toml" --locked \ + -p tinychannels-client --features native-e2e --test runtime_native_e2e -- --test-threads=1 +run_counted_test 1 runtime_readiness_e2e cargo test --manifest-path "$repo_root/Cargo.toml" --locked \ + -p tinychannels-client --features native-e2e --test runtime_readiness_e2e -- --test-threads=1 +run_counted_test 1 native_module_runtime_e2e cargo test --manifest-path "$repo_root/Cargo.toml" --locked \ + -p tinychannels-client --features native-e2e --test native_module_runtime_e2e -- --test-threads=1 diff --git a/scripts/test-client-native-topology.py b/scripts/test-client-native-topology.py new file mode 100644 index 000000000..69122e41d --- /dev/null +++ b/scripts/test-client-native-topology.py @@ -0,0 +1,175 @@ +"""Keep artifact-gated client proofs staged and counted in every CI lane.""" + +from pathlib import Path +import re +import tomllib +import unittest + + +ROOT = Path(__file__).resolve().parent.parent +TARGETS = { + "native_e2e": ( + "all_public_wrappers_route_to_the_pinned_peer_and_decode_typed_results", + "detailed_invalid_input_keeps_its_wire_classification_and_redacts_rendering", + "malformed_public_responses_report_one_sanitized_module_fault", + "wrong_release_pins_and_unattested_peers_are_refused_before_business_calls", + ), + "runtime_native_e2e": ( + "typed_driver_preserves_peer_through_dispatch_uncertainty_and_alias_handover", + ), + "runtime_readiness_e2e": ( + "withheld_ready_fails_closed_within_the_client_deadline", + ), + "native_module_runtime_e2e": ( + "actual_native_module_runs_provider_free_runtime_and_releases_callback_on_ack", + ), +} +EXPECTED_COUNTS = { + "native_e2e": 4, + "runtime_native_e2e": 1, + "runtime_readiness_e2e": 1, + "native_module_runtime_e2e": 1, +} + + +class ClientNativeTopologyTests(unittest.TestCase): + def test_workspace_keeps_all_three_native_targets_in_the_client_package(self): + client_manifest = tomllib.loads( + (ROOT / "crates/tinychannels-client/Cargo.toml").read_text(encoding="utf-8") + ) + self.assertIn("crates/tinychannels-client", tomllib.loads( + (ROOT / "Cargo.toml").read_text(encoding="utf-8") + )["workspace"]["members"]) + self.assertEqual(client_manifest["features"]["native-e2e"], []) + targets = {target["name"]: target for target in client_manifest["test"]} + self.assertEqual(set(targets), set(TARGETS)) + for target in targets.values(): + self.assertEqual(target["required-features"], ["native-e2e"]) + + def test_all_native_cases_remain_unchanged_and_not_ignored(self): + self.assertEqual(sum(EXPECTED_COUNTS.values()), 7) + for target, names in TARGETS.items(): + source = (ROOT / "crates/tinychannels-client/tests" / f"{target}.rs").read_text( + encoding="utf-8" + ) + self.assertEqual( + len(re.findall(r"(?m)^\s*#\[(?:tokio::)?test(?:\([^\n]*\))?\]", source)), + EXPECTED_COUNTS[target], + ) + for name in names: + self.assertRegex(source, rf"(?m)^async fn {re.escape(name)}\(") + self.assertNotRegex(source, r"(?m)^\s*#\[ignore(?:\(|\])") + + def test_runtime_bind_diagnostic_is_finite_and_content_free(self): + support = (ROOT / "crates/tinychannels-client/tests/support/mod.rs").read_text( + encoding="utf-8" + ) + runtime_test = (ROOT / "crates/tinychannels-client/tests/runtime_native_e2e.rs").read_text( + encoding="utf-8" + ) + for stage in ( + "preparation_not_started", + "cached_load_in_progress", + "cached_load_failed", + "cached_load_complete", + "readiness_in_progress", + "readiness_failed", + "ready_before_peer_resolution", + ): + self.assertIn(f'"{stage}"', support) + self.assertIn("native fixture stage: {}", runtime_test) + self.assertIn("fixture.bind_stage()", runtime_test) + + def test_readiness_deadline_pauses_only_after_foreign_gate_and_drains_before_asserts(self): + readiness = (ROOT / "crates/tinychannels-client/tests/runtime_readiness_e2e.rs").read_text( + encoding="utf-8" + ) + ordered_markers = ( + "support::gated_runtime_fixture().await", + "readiness_attempt_started(before_limited_bind)", + "tokio::time::pause()", + "tokio::time::advance(Duration::from_secs(2)).await", + "let bind_outcome = limited_bind.await", + "tokio::time::resume()", + "fixture.release_ready()", + "fixture.wait_until_gate_callback_returns().await", + ".ensure_ready(tinychannels_bus::BUS_NAME)", + "!module_ready_before_bind,", + ) + positions = [readiness.index(marker) for marker in ordered_markers] + self.assertEqual(positions, sorted(positions)) + support = (ROOT / "crates/tinychannels-client/tests/support/mod.rs").read_text( + encoding="utf-8" + ) + self.assertIn("wait_until_gate_callback_returns(&self) -> bool", support) + self.assertIn("tokio::time::timeout(Duration::from_secs(5)", support) + self.assertIn("assert!(gate_callback_returned", readiness) + + runtime_test = (ROOT / "crates/tinychannels-client/tests/runtime_native_e2e.rs").read_text( + encoding="utf-8" + ) + cold_bind = runtime_test.index("startup_client.runtime_driver()") + release = runtime_test.index("fixture.release_ready()", cold_bind) + cold_result = runtime_test.index("let startup_result = startup_bind.await", release) + bounded_bind = runtime_test.index("let serial_driver = fixture.client.runtime_driver()", cold_result) + self.assertEqual([cold_bind, release, cold_result, bounded_bind], sorted([ + cold_bind, release, cold_result, bounded_bind + ])) + + def test_native_ci_runners_call_every_target_and_enforce_test_counts(self): + shell_runner = (ROOT / "scripts/test-client-e2e.sh").read_text(encoding="utf-8") + powershell_runner = (ROOT / "scripts/test-client-e2e.ps1").read_text(encoding="utf-8") + self.assertIn("--prepare-only", shell_runner) + self.assertIn("run_counted_test() {", shell_runner) + self.assertIn("function Invoke-CountedNativeTest", powershell_runner) + self.assertIn("& cargo @Arguments", powershell_runner) + self.assertEqual(powershell_runner.count("-Arguments (@('test') + $common + @("), 4) + for target, count in EXPECTED_COUNTS.items(): + self.assertEqual(shell_runner.count(f"run_counted_test {count} {target}"), 1) + self.assertIn(f"--test {target}", shell_runner) + self.assertIn(f"-Name '{target}' -ExpectedCount {count}", powershell_runner) + self.assertIn(f"'--test', '{target}'", powershell_runner) + + unix_e2e = (ROOT / "scripts/test-e2e.sh").read_text(encoding="utf-8") + windows_e2e = (ROOT / "scripts/test-e2e.ps1").read_text(encoding="utf-8") + self.assertIn("bash scripts/test-client-e2e.sh", unix_e2e) + self.assertIn("test-client-e2e.ps1", windows_e2e) + ci = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") + self.assertIn("module-e2e-script: scripts/test-e2e.sh", ci) + + def test_windows_runner_uses_native_tar_for_drive_letter_paths(self): + powershell_runner = (ROOT / "scripts/test-client-e2e.ps1").read_text(encoding="utf-8") + self.assertIn("System32/tar.exe", powershell_runner) + self.assertNotRegex(powershell_runner, r"(?m)^&\s+tar\.exe\b") + self.assertIn('($environment -join "`n") + "`n"', powershell_runner) + self.assertIn("[System.IO.File]::WriteAllText", powershell_runner) + self.assertNotIn("[System.IO.File]::WriteAllLines", powershell_runner) + + def test_feature_and_coverage_runs_prepare_fixture_before_all_feature_tests(self): + feature_runner = (ROOT / "scripts/test-features.sh").read_text(encoding="utf-8") + self.assertIn("python3 scripts/test-client-native-topology.py", feature_runner) + self.assertIn("cargo test --locked --workspace --all-features", feature_runner) + prepare = feature_runner.index("scripts/test-client-e2e.sh --prepare-only") + source = feature_runner.index("source target/tinychannels-client-e2e.env") + test = feature_runner.index("cargo test --locked --workspace --all-features") + self.assertLess(prepare, source) + self.assertLess(source, test) + + coverage_runner = (ROOT / "scripts/check-coverage.sh").read_text(encoding="utf-8") + prepare = coverage_runner.index("scripts/test-client-e2e.sh --prepare-only") + source = coverage_runner.index("source target/tinychannels-client-e2e.env") + coverage = coverage_runner.index("check-file-coverage.sh 90 coverage.json") + self.assertLess(prepare, source) + self.assertLess(source, coverage) + coverage_check = (ROOT / ".github/scripts/check-file-coverage.sh").read_text( + encoding="utf-8" + ) + self.assertIn("cargo llvm-cov", coverage_check) + self.assertIn("--workspace", coverage_check) + self.assertIn("--all-targets", coverage_check) + self.assertIn("--all-features", coverage_check) + self.assertIn("check-file-coverage.sh 90 coverage.json", coverage_runner) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-e2e.ps1 b/scripts/test-e2e.ps1 index 14505ba6f..77032c11d 100644 --- a/scripts/test-e2e.ps1 +++ b/scripts/test-e2e.ps1 @@ -12,3 +12,5 @@ $outputDir = 'target/module-e2e' if ($LASTEXITCODE -ne 0) { throw 'tinychannels module build failed' } & (Join-Path $PSScriptRoot 'verify-module.ps1') -Archive "$outputDir/tinychannels-module-$Version-ci.zip" if ($LASTEXITCODE -ne 0) { throw 'tinychannels module end-to-end verification failed' } +& (Join-Path $PSScriptRoot 'test-client-e2e.ps1') +if ($LASTEXITCODE -ne 0) { throw 'tinychannels client native E2E failed' } diff --git a/scripts/test-e2e.sh b/scripts/test-e2e.sh index 6f090f18e..63156bb9f 100755 --- a/scripts/test-e2e.sh +++ b/scripts/test-e2e.sh @@ -10,5 +10,6 @@ case "${RUNNER_OS:-$(uname -s)}" in *) scripts/build-module.sh --crate tinychannels-module --version "$version" --target "$target" --host ci --output-dir target/module-e2e scripts/verify-module.sh --archive "target/module-e2e/tinychannels-module-$version-ci.tar.gz" + bash scripts/test-client-e2e.sh ;; esac diff --git a/scripts/test-feature-lanes.py b/scripts/test-feature-lanes.py index 3cb995faf..7a927f127 100644 --- a/scripts/test-feature-lanes.py +++ b/scripts/test-feature-lanes.py @@ -23,7 +23,12 @@ def test_send_only_lane_cannot_unify_full_email_from_workspace(self): 'export -f cargo python3', 'source "$1"', ]), "feature-lanes", (ROOT / "scripts/test-features.sh").as_posix()], - env=dict(os.environ, FEATURE_LANE_CALLS=calls.as_posix(), RUNNER_OS="Linux"), + env=dict( + os.environ, + FEATURE_LANE_CALLS=calls.as_posix(), + FEATURE_TEST_SKIP_CLIENT_PREPARE="1", + RUNNER_OS="Linux", + ), capture_output=True, text=True, ) self.assertEqual(result.returncode, 0, result.stderr) diff --git a/scripts/test-feature-shell.py b/scripts/test-feature-shell.py index b20db0eca..18e8fb7b1 100644 --- a/scripts/test-feature-shell.py +++ b/scripts/test-feature-shell.py @@ -30,6 +30,7 @@ def test_feature_harness_ignores_a_competing_bash_on_path(self): ]), "feature-shell", str(ROOT / "scripts/test-features.sh")], env=dict(os.environ, PATH=directory + os.pathsep + os.environ["PATH"], FEATURE_TEST_PYTHON=sys.executable, + FEATURE_TEST_SKIP_CLIENT_PREPARE="1", FEATURE_TEST_BASH=shutil.which("bash"), RUNNER_OS="Linux"), capture_output=True, text=True, ) diff --git a/scripts/test-features.sh b/scripts/test-features.sh index ebdce27b2..726d4dbec 100755 --- a/scripts/test-features.sh +++ b/scripts/test-features.sh @@ -13,8 +13,17 @@ case "${RUNNER_OS:-$(uname -s)}" in Windows|MINGW*|MSYS*|CYGWIN*) feature_test_bash="$(cygpath -m "$BASH")" ;; esac FEATURE_TEST_BASH="$feature_test_bash" python3 scripts/test-feature-lanes.py +FEATURE_TEST_BASH="$feature_test_bash" python3 scripts/test-reviewed-epic-release.py +python3 scripts/test-bus-purity.py +python3 scripts/check-bus-purity.py python3 scripts/test-feature-shell.py +python3 scripts/test-client-native-topology.py +FEATURE_TEST_BASH="$feature_test_bash" python3 scripts/test-client-e2e-standalone.py cargo test --locked --workspace +if [[ "${FEATURE_TEST_SKIP_CLIENT_PREPARE:-0}" != "1" ]]; then + scripts/test-client-e2e.sh --prepare-only + source target/tinychannels-client-e2e.env +fi cargo test --locked --workspace --all-features cargo test --locked -p tinychannels --no-default-features cargo test --locked -p tinychannels --no-default-features --features email-send diff --git a/scripts/test-reviewed-epic-release.py b/scripts/test-reviewed-epic-release.py new file mode 100644 index 000000000..e172bfd08 --- /dev/null +++ b/scripts/test-reviewed-epic-release.py @@ -0,0 +1,273 @@ +"""Exercise the actual reviewed-epic release workflow input validator.""" + +import os +from pathlib import Path +import subprocess +import tempfile +import tomllib +import unittest + + +ROOT = Path(__file__).resolve().parent.parent +WORKFLOW = ROOT / ".github/workflows/release.yml" +CANONICAL = "tinyhumansai/tinychannels" +EPIC_REF = "refs/heads/epic/8056-modules-wiring" +REVIEWED_RELEASE = "tinyhumansai/.github/.github/workflows/module-reviewed-epic-release.yml@6ed83b04aab2d8977ec62e966cb914531e534fa4" +TINYBUS_REV = "61dce96aac5d20d8b6fc7cfe27daef559c7d54c9" + + +def workflow_run_block(step_name): + lines = WORKFLOW.read_text(encoding="utf-8").splitlines() + step = next( + i + for i, line in enumerate(lines) + if line.strip() == step_name + ) + run = next(i for i in range(step + 1, len(lines)) if lines[i].strip() == "run: |") + block = [] + for line in lines[run + 1 :]: + if line.startswith(" "): + block.append(line[10:]) + elif not line.strip(): + block.append("") + else: + break + if not block: + raise AssertionError(f"workflow run block is empty for {step_name}") + return "\n".join(block) + "\n" + + +def gitlink_revision(repo_root, path): + """Return a committed gitlink revision without requiring submodule contents.""" + result = subprocess.run( + ["git", "-C", str(repo_root), "ls-tree", "HEAD", "--", path], + check=True, + capture_output=True, + text=True, + ) + entry = result.stdout.strip() + if not entry: + return None + metadata, separator, entry_path = entry.partition("\t") + fields = metadata.split() + if not separator or entry_path != path or len(fields) != 3: + return None + mode, object_type, revision = fields + if mode != "160000" or object_type != "commit": + return None + return revision + + +def run_validator(**overrides): + values = { + "EVENT_NAME": "workflow_dispatch", + "REPOSITORY": CANONICAL, + "REF": EPIC_REF, + "RELEASE_MODE": "reviewed", + "REVIEWED_PR": "8056", + } + values.update(overrides) + bash = os.environ.get("FEATURE_TEST_BASH", "bash") + with tempfile.TemporaryDirectory(prefix="reviewed epic release ") as directory: + output_file = Path(directory) / "github-output.txt" + result = subprocess.run( + [bash, "-c", workflow_run_block("- name: Validate reviewed epic dispatch inputs")], + cwd=directory, + env=dict(os.environ, **values, GITHUB_OUTPUT="github-output.txt"), + capture_output=True, + text=True, + ) + output = output_file.read_text(encoding="utf-8") if output_file.exists() else "" + return result, output + + +def run_tag_resolver(tag): + bash = os.environ.get("FEATURE_TEST_BASH", "bash") + with tempfile.TemporaryDirectory(prefix="ordinary release tag ") as directory: + output_file = Path(directory) / "github-output.txt" + result = subprocess.run( + [bash, "-c", workflow_run_block("- id: tag")], + cwd=directory, + env=dict(os.environ, TAG=tag, GITHUB_OUTPUT="github-output.txt"), + capture_output=True, + text=True, + ) + output = output_file.read_text(encoding="utf-8") if output_file.exists() else "" + return result, output + + +class ReviewedEpicReleaseTests(unittest.TestCase): + def test_valid_dispatch_emits_reviewed_release_outputs(self): + result, output = run_validator() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(output, "reviewed_pr=8056\n") + + def test_wrong_event_repo_branch_and_mode_are_rejected(self): + for name, value in ( + ("EVENT_NAME", "push"), + ("REPOSITORY", "senamakel/tinychannels"), + ("REF", "refs/heads/main"), + ("RELEASE_MODE", "tag-only"), + ): + with self.subTest(input=name, value=value): + result, output = run_validator(**{name: value}) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(output, "") + + def test_legacy_zero_and_nonpositive_or_noninteger_pr_values_are_rejected(self): + for value in ("0", "-1", "1.5", "abc", "01"): + with self.subTest(reviewed_pr=value): + result, output = run_validator(REVIEWED_PR=value) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(output, "") + + def test_tag_must_be_v_prefixed_semver(self): + for value in ("0.1.37", "v0.1", "vrelease"): + with self.subTest(tag=value): + result, output = run_tag_resolver(value) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(output, "") + + result, output = run_tag_resolver("v0.1.37") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(output, "tag=v0.1.37\nversion=0.1.37\n") + + def test_workflow_calls_reviewed_release_with_skip_safe_numeric_pr(self): + source = WORKFLOW.read_text(encoding="utf-8") + self.assertIn(" mode:\n", source) + self.assertIn("options: [tag, reviewed]", source) + self.assertIn(REVIEWED_RELEASE, source) + self.assertIn("fromJSON(needs.validate.outputs.reviewed_pr || '0')", source) + self.assertIn("default: '0'", source) + self.assertIn("if: inputs.mode == 'reviewed'", source) + self.assertIn("if: github.event_name == 'push' || inputs.mode != 'reviewed'", source) + + def test_permissions_are_scoped_to_validation_and_release_needs(self): + source = WORKFLOW.read_text(encoding="utf-8") + self.assertIn("permissions: {}", source) + self.assertIn( + " validate:\n if: inputs.mode == 'reviewed'\n" + " runs-on: ubuntu-latest\n permissions: {}", + source, + ) + self.assertIn( + " contents: write\n pull-requests: read\n" + " checks: read\n actions: read", + source, + ) + + def test_release_validator_runs_in_existing_feature_test_ci_lane(self): + test_features = (ROOT / "scripts/test-features.sh").read_text(encoding="utf-8") + ci = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") + self.assertIn( + 'FEATURE_TEST_BASH="$feature_test_bash" python3 scripts/test-reviewed-epic-release.py', + test_features, + ) + self.assertIn("feature-test-script: scripts/test-features.sh", ci) + + def test_reviewed_release_is_in_the_registered_release_workflow(self): + source = WORKFLOW.read_text(encoding="utf-8") + self.assertIn("on:\n workflow_dispatch:", source) + self.assertIn(" push:\n tags: ['v*']", source) + self.assertIn("if: github.event_name == 'push' || inputs.mode != 'reviewed'", source) + self.assertIn("if: inputs.mode == 'reviewed'", source) + self.assertFalse((ROOT / ".github/workflows/epic-release.yml").exists()) + + def test_gitlink_lookup_accepts_exact_and_rejects_stale_or_missing(self): + def repository_with_gitlink(revision): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + repo = Path(temp.name) + subprocess.run(["git", "init", "-q", str(repo)], check=True) + subprocess.run( + ["git", "-C", str(repo), "config", "user.name", "Test"], + check=True, + ) + subprocess.run( + ["git", "-C", str(repo), "config", "user.email", "test@example.invalid"], + check=True, + ) + if revision is not None: + subprocess.run( + [ + "git", "-C", str(repo), "update-index", "--add", "--cacheinfo", + f"160000,{revision},vendor/tinybus", + ], + check=True, + ) + subprocess.run( + ["git", "-C", str(repo), "commit", "--allow-empty", "-qm", "fixture"], + check=True, + ) + return repo + + exact = repository_with_gitlink(TINYBUS_REV) + stale = repository_with_gitlink("1" * 40) + missing = repository_with_gitlink(None) + self.assertEqual(gitlink_revision(exact, "vendor/tinybus"), TINYBUS_REV) + self.assertNotEqual(gitlink_revision(stale, "vendor/tinybus"), TINYBUS_REV) + self.assertIsNone(gitlink_revision(missing, "vendor/tinybus")) + + def test_tinybus_and_module_sdk_share_the_published_workspace_source(self): + workspace = (ROOT / "Cargo.toml").read_text(encoding="utf-8") + module = (ROOT / "crates/tinychannels-module/Cargo.toml").read_text( + encoding="utf-8" + ) + fixture = (ROOT / "crates/tinychannels-client/test-fixture/Cargo.toml").read_text( + encoding="utf-8" + ) + self.assertIn( + 'tinybus = { version = "=0.3.12", git = ' + '"https://github.com/senamakel/tinybus", tag = "v0.3.12", ' + 'default-features = false, features = ["macros", "modules"] }', + module, + ) + self.assertIn( + 'tinybus-module = { version = "=0.3.12", git = ' + '"https://github.com/senamakel/tinybus", tag = "v0.3.12" }', + module, + ) + self.assertIn( + 'tinybus = { path = "vendor/tinybus/crates/tinybus" }\n' + 'tinybus-module = { path = "vendor/tinybus/crates/tinybus-module" }', + workspace, + ) + workspace_manifest = tomllib.loads(workspace) + module_manifest = tomllib.loads(module) + test_bus = module_manifest["dev-dependencies"]["tinybus"] + self.assertTrue(test_bus["workspace"]) + self.assertIn("test-support", test_bus["features"]) + shared_bus = workspace_manifest["workspace"]["dependencies"]["tinybus"] + self.assertEqual(shared_bus["version"], "=0.3.12") + self.assertEqual(shared_bus["git"], "https://github.com/senamakel/tinybus") + self.assertEqual(shared_bus["tag"], "v0.3.12") + self.assertFalse(shared_bus["default-features"]) + self.assertIn( + 'tinybus = { version = "0.3.12", path = "../../../vendor/tinybus/crates/tinybus"', + fixture, + ) + self.assertIn( + 'tinybus-module = { version = "0.3.12", path = "../../../vendor/tinybus/crates/tinybus-module"', + fixture, + ) + self.assertIn( + 'tinychannels-bus = { path = "../../tinychannels-bus", features = ["storage"] }', + fixture, + ) + self.assertEqual(gitlink_revision(ROOT, "vendor/tinybus"), TINYBUS_REV) + + def test_ordinary_tag_release_caller_remains_in_place(self): + source = (ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") + self.assertIn( + "tinyhumansai/.github/.github/workflows/module-release.yml@" + "3e87ade76b622844985d0c665a3b075313e4545c", + source, + ) + self.assertIn("module-reviewed-epic-release.yml", source) + self.assertIn("module-release:", source) + self.assertIn("default: tag", source) + self.assertIn("needs: resolve", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-storage-needs-boundary.py b/scripts/test-storage-needs-boundary.py new file mode 100644 index 000000000..053413653 --- /dev/null +++ b/scripts/test-storage-needs-boundary.py @@ -0,0 +1,118 @@ +"""Negative controls for the standalone, storage-driver-free bus consumer.""" + +import importlib.util +import json +from pathlib import Path +import unittest +from contextlib import redirect_stdout +from io import StringIO +from unittest.mock import patch + + +SCRIPT = Path(__file__).with_name("check-storage-needs-boundary.py") +SPEC = importlib.util.spec_from_file_location("storage_boundary", SCRIPT) +BOUNDARY = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(BOUNDARY) + + +def fixture(*, extra=(), kind=None): + packages = [ + {"id": "proof", "name": "tinychannels-storage-needs-consumer", "version": "0.1.0"}, + {"id": "bus", "name": "tinychannels-bus", "version": "0.2.0"}, + {"id": "serde", "name": "serde", "version": "1.0.0"}, + *extra, + ] + nodes = [ + {"id": "proof", "features": [], "deps": [ + {"pkg": "bus", "dep_kinds": [{"kind": None}]}, + ]}, + {"id": "bus", "features": ["storage"], "deps": [ + {"pkg": "serde", "dep_kinds": [{"kind": None}]}, + ]}, + {"id": "serde", "features": [], "deps": []}, + ] + for package in extra: + nodes[1]["deps"].append({"pkg": package["id"], "dep_kinds": [{"kind": kind}]}) + nodes.append({"id": package["id"], "features": [], "deps": []}) + return {"packages": packages, "resolve": {"nodes": nodes}} + + +class StorageBoundaryTests(unittest.TestCase): + def test_pure_bus_collection_declarations_need_no_storage_driver(self): + self.assertEqual(BOUNDARY.violations(fixture()), []) + + def test_storage_clients_types_and_runtime_packages_are_rejected(self): + for name in ( + "tinystoragedrivers-busproxy", "tinystoragedrivers-core", + "tinystoragedrivers-types", "tinychannels-runtime", "tinychannels", + "tinychannels-module", "tinybus", "tokio", "async-trait", "reqwest", + ): + with self.subTest(package=name): + errors = BOUNDARY.violations(fixture(extra=[{"id": "extra", "name": name}])) + self.assertTrue(any(name in error for error in errors)) + + def test_storage_types_are_rejected_for_every_version_and_source(self): + for version in ("1.9.0", "2.1.2"): + for source in ( + "git+https://github.com/senamakel/tinystoragedrivers?rev=c447da3#c447da3", + "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=bf1f0a97fbe6165a6edd9521659c7f6859700761#bf1f0a97", + ): + with self.subTest(version=version, source=source): + errors = BOUNDARY.violations(fixture(extra=[{ + "id": "types", "name": "tinystoragedrivers-types", + "version": version, "source": source, + }])) + self.assertTrue(any("tinystoragedrivers-types" in error for error in errors)) + + def test_unreviewed_dependency_is_rejected(self): + self.assertTrue(BOUNDARY.violations(fixture(extra=[{"id": "extra", "name": "unknown-driver"}]))) + + def test_build_dependencies_are_audited_and_dev_edges_do_not_change_the_contract(self): + for kind in (None, "build", "dev"): + with self.subTest(kind=kind): + errors = BOUNDARY.violations(fixture(extra=[{"id": "runtime", "name": "tokio"}], kind=kind)) + self.assertEqual(bool(errors), kind != "dev") + + def test_missing_or_malformed_graph_evidence_fails_closed(self): + mutations = ( + lambda g: g.pop("packages"), + lambda g: g.pop("resolve"), + lambda g: g.update(resolve=None), + lambda g: g["resolve"].pop("nodes"), + lambda g: g["resolve"]["nodes"].pop(), + lambda g: g["resolve"]["nodes"][1].pop("features"), + lambda g: g["resolve"]["nodes"][1].pop("deps"), + lambda g: g["resolve"]["nodes"][1]["deps"][0].pop("dep_kinds"), + ) + for mutate in mutations: + with self.subTest(mutation=mutate): + graph = fixture() + mutate(graph) + self.assertTrue(BOUNDARY.violations(graph)) + + def test_required_bus_must_be_reachable_from_the_proof(self): + graph = fixture() + graph["resolve"]["nodes"][0]["deps"] = [] + self.assertTrue(BOUNDARY.violations(graph)) + + def test_duplicate_required_packages_are_rejected(self): + for package in ("proof", "bus"): + with self.subTest(package=package): + graph = fixture() + original = next(p for p in graph["packages"] if p["id"] == package) + graph["packages"].append({**original, "id": "duplicate"}) + graph["resolve"]["nodes"].append({"id": "duplicate", "features": [], "deps": []}) + self.assertTrue(BOUNDARY.violations(graph)) + + def test_cli_accepts_current_pure_proof_graph(self): + with ( + patch.object(BOUNDARY.subprocess, "check_output", return_value=json.dumps(fixture())) as metadata, + redirect_stdout(StringIO()) as output, + ): + self.assertEqual(BOUNDARY.main(), 0) + self.assertIn("pure bus contract", output.getvalue()) + self.assertIn("--locked", metadata.call_args.args[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-module-artifact.sh b/scripts/verify-module-artifact.sh index d6af61cc3..9bd1ab783 100644 --- a/scripts/verify-module-artifact.sh +++ b/scripts/verify-module-artifact.sh @@ -26,9 +26,9 @@ trap 'rm -rf "$artifact_dir"' EXIT staged_artifact="$artifact_dir/$(basename "$artifact")" cp "$artifact" "$staged_artifact" if command -v sha256sum >/dev/null 2>&1; then - artifact_hash="$(sha256sum "$staged_artifact" | awk '{print $1}')" + artifact_hash="$(sha256sum < "$staged_artifact" | awk '{print $1}')" else - artifact_hash="$(shasum -a 256 "$staged_artifact" | awk '{print $1}')" + artifact_hash="$(shasum -a 256 < "$staged_artifact" | awk '{print $1}')" fi printf '"%s" = "%s"\n' "$(basename "$staged_artifact")" "$artifact_hash" > "$artifact_dir/modules.toml" diff --git a/src/delivery/mod_tests.rs b/src/delivery/mod_tests.rs index ebf0c0f59..aa474f897 100644 --- a/src/delivery/mod_tests.rs +++ b/src/delivery/mod_tests.rs @@ -663,6 +663,7 @@ fn payload_shape_maps_to_expected_durable_capability() { }, Payload, ), + (OutboundPayload::Parts { parts: vec![] }, Payload), ]; for (payload, expected) in cases { diff --git a/src/factory.rs b/src/factory.rs index d3741a896..afe8d06e1 100644 --- a/src/factory.rs +++ b/src/factory.rs @@ -987,6 +987,13 @@ pub fn build_channels( ); } + #[cfg(not(feature = "imessage"))] + if config.imessage.is_some() { + tracing::warn!( + "[channels] imessage is configured but iMessage support is disabled in this build; skipping" + ); + } + if let Some(signal) = build_signal_channel(config, host, http) { channels.push( BuiltChannel::with_output_ports( diff --git a/src/host/media.rs b/src/host/media.rs index 6b3b2af05..b416d5ae3 100644 --- a/src/host/media.rs +++ b/src/host/media.rs @@ -11,9 +11,8 @@ use sha2::{Digest, Sha256}; use tinystoragedrivers_api::{BlobReader, BlobStore, DocumentStore}; use tinystoragedrivers_types::{Filter, Precondition, Query, Version}; -use tinychannels_bus::storage::{ - MEDIA_BLOB_PREFIX, MEDIA_METADATA as MEDIA_COLLECTION, media_metadata_spec, -}; +use crate::storage::schema::media_metadata_spec; +use tinychannels_bus::storage::{MEDIA_BLOB_PREFIX, MEDIA_METADATA as MEDIA_COLLECTION}; const MEDIA_KEY_PREFIX: &str = MEDIA_BLOB_PREFIX; const OPAQUE_ID_PREFIX: &str = "tinychannels_media_v1_"; diff --git a/src/providers/imessage/tests/runtime_tests.rs b/src/providers/imessage/tests/runtime_tests.rs index fc813f085..6866f03ae 100644 --- a/src/providers/imessage/tests/runtime_tests.rs +++ b/src/providers/imessage/tests/runtime_tests.rs @@ -250,6 +250,8 @@ async fn listen_delivers_only_allowed_non_blank_messages() { assert_eq!(first.conversation.id, "+15555550123"); assert_eq!(first.text, "hello"); assert_eq!(first.channel.id, "imessage"); + assert_eq!(first.conversation.topic_id, None); + assert_eq!(first.reply_to_id, None); assert_ne!( first.message_id, "1", "rows before startup are never replayed" diff --git a/src/providers/telegram/channel_ops.rs b/src/providers/telegram/channel_ops.rs index 52b501641..c955abae6 100644 --- a/src/providers/telegram/channel_ops.rs +++ b/src/providers/telegram/channel_ops.rs @@ -1588,8 +1588,17 @@ impl TelegramChannel { return Ok(None); } - let (chat_id, thread_id) = Self::parse_reply_target(&message.recipient); - let parent_message_id = Self::parse_message_id(message.thread_ts.as_deref()); + let (chat_id, recipient_thread_id) = Self::parse_reply_target(&message.recipient); + let thread_id = message + .topic_id + .as_deref() + .or(recipient_thread_id.as_deref()); + let parent_message_id = Self::parse_message_id( + message + .reply_to_id + .as_deref() + .or(message.thread_ts.as_deref()), + ); let initial_text = if message.content.is_empty() { "...".to_string() } else { diff --git a/src/providers/telegram/channel_ops_tests.rs b/src/providers/telegram/channel_ops_tests.rs index 6ac26210a..66f178aef 100644 --- a/src/providers/telegram/channel_ops_tests.rs +++ b/src/providers/telegram/channel_ops_tests.rs @@ -45,6 +45,64 @@ async fn send_draft_posts_placeholder_and_returns_message_id() { assert!(ch.last_draft_edit.lock().contains_key("100")); } +#[tokio::test] +async fn send_uses_separate_topic_and_reply_ids() { + let server = mock_api().await; + mount_post(&server, "sendMessage", ok()).await; + let ch = channel_for(&server, &["*"]); + let mut message = SendMessage::new("hello", "100"); + message.topic_id = Some("7".into()); + message.thread_ts = Some("55".into()); + message.reply_to_id = Some("56".into()); + + ch.send_compat_message(&message).await.unwrap(); + + assert_eq!( + json_bodies(&server, "sendMessage").await, + vec![serde_json::json!({ + "chat_id": "100", + "text": "hello", + "parse_mode": "MarkdownV2", + "message_thread_id": "7", + "reply_to_message_id": 56, + })] + ); +} + +#[tokio::test] +async fn send_draft_prefers_explicit_topic_and_reply_ids() { + let server = mock_api().await; + mount_post( + &server, + "sendMessage", + ok_with(serde_json::json!({ "message_id": 323 })), + ) + .await; + let channel = streaming(&server); + let mut message = SendMessage::new("thinking", "100:8"); + message.topic_id = Some("7".into()); + message.thread_ts = Some("55".into()); + message.reply_to_id = Some("56".into()); + + assert_eq!( + channel + .send_draft_provider(&message) + .await + .unwrap() + .as_deref(), + Some("323") + ); + assert_eq!( + json_bodies(&server, "sendMessage").await, + vec![serde_json::json!({ + "chat_id": "100", + "text": "thinking", + "message_thread_id": "7", + "reply_to_message_id": 56, + })] + ); +} + #[tokio::test] async fn send_draft_without_message_id_in_result_returns_none() { let server = mock_api().await; diff --git a/src/relay/mod_tests.rs b/src/relay/mod_tests.rs index c9042563d..db661aa79 100644 --- a/src/relay/mod_tests.rs +++ b/src/relay/mod_tests.rs @@ -1308,3 +1308,30 @@ async fn relay_shutdown_joins_reader_and_refuses_reconnect() { ); supervisor.shutdown().await; } + +#[test] +fn relay_send_action_preserves_ordered_typed_parts_in_payload_metadata() { + let parts = ["first", "second"] + .into_iter() + .map(|text| tinychannels_bus::OutboundContentPart::Content { + part: tinychannels_bus::ContentPart::Text { + text: text.into(), + markdown_dialect: None, + }, + }) + .collect::>(); + let intent = ChannelOutboundIntent { + idempotency_key: "typed-relay-key".into(), + payload: OutboundPayload::Parts { + parts: parts.clone(), + }, + ..Default::default() + }; + let action = relay_send_action_from_outbound_intent(&intent); + assert_eq!( + action["metadata"]["payload"]["parts"], + serde_json::to_value(&parts).unwrap() + ); + assert_eq!(action["metadata"]["idempotency_key"], "typed-relay-key"); + assert_eq!(action["content"], ""); +} diff --git a/src/storage/delivery.rs b/src/storage/delivery.rs index 2e1fa30d6..d58c3ce7b 100644 --- a/src/storage/delivery.rs +++ b/src/storage/delivery.rs @@ -34,7 +34,10 @@ use tinystoragedrivers_types::{ WriteOptions, }; -use super::records::{StoredDelivery, StoredResult, to_stored_ms}; +use super::records::{ + StoredDelivery, StoredResult, queued_from_stored_delivery, stored_delivery_from_queued, + stored_result_from_terminal, terminal_result_from_stored, to_stored_ms, +}; use super::schema::{self, SCHEMA_VERSION}; use crate::delivery::{ DeliveryQueueError, DeliveryQueueStore, DeliveryResultStore, QueuedDelivery, @@ -76,6 +79,16 @@ impl DocumentDeliveryStore { } } + /// Use an owner-bound handle whose schema and legacy import are already + /// prepared by the caller. Module callback ports cannot mutate schemas. + #[must_use] + pub fn from_prepared(docs: Arc) -> Self { + Self { + docs, + ready: Arc::new(tokio::sync::OnceCell::new_with(Some(()))), + } + } + async fn docs(&self) -> Result<&Arc, DeliveryQueueError> { self.ready .get_or_try_init(|| async { @@ -139,7 +152,7 @@ fn write_options() -> WriteOptions { fn decode_entry(found: Versioned) -> Result { let entry = match serde_json::from_value::(found.doc.clone()) { - Ok(stored) => QueuedDelivery::from(stored), + Ok(stored) => queued_from_stored_delivery(stored), Err(_) => serde_json::from_value::(found.doc) .map_err(|_| invalid("stored delivery record is unreadable"))?, }; @@ -154,7 +167,7 @@ fn decode_entry(found: Versioned) -> Result Result { - serde_json::to_value(StoredDelivery::from(entry)) + serde_json::to_value(stored_delivery_from_queued(entry)) .map_err(|_| invalid("delivery record could not be encoded")) } @@ -312,7 +325,7 @@ impl DeliveryResultStore for DocumentDeliveryStore { if doc_id(&record.id).ok().as_deref() != Some(found.id.as_str()) { return Err(invalid("stored delivery result is unreadable")); } - Ok(Some(record.into())) + Ok(Some(terminal_result_from_stored(record))) } async fn save_result(&self, result: DeliveryTerminalResult) -> Result<(), DeliveryQueueError> { @@ -327,7 +340,7 @@ impl DeliveryResultStore for DocumentDeliveryStore { .saturating_add(DELIVERY_RESULT_TTL_SECONDS.saturating_mul(1000)), ); let id = doc_id(&result.id)?; - let doc = serde_json::to_value(StoredResult::from(&result)) + let doc = serde_json::to_value(stored_result_from_terminal(&result)) .map_err(|_| invalid("result could not be encoded"))?; docs.put_with( RESULTS_COLLECTION, diff --git a/src/storage/delivery_tests.rs b/src/storage/delivery_tests.rs index aba664958..e03897ad0 100644 --- a/src/storage/delivery_tests.rs +++ b/src/storage/delivery_tests.rs @@ -39,6 +39,46 @@ fn result(id: &str, completed_at: u64) -> DeliveryTerminalResult { } } +#[tokio::test] +async fn durable_delivery_preserves_account_conversation_and_turn_identity() { + let store = DocumentDeliveryStore::new(docs()); + let mut queued = entry("scoped-delivery", 1); + queued.intent.channel_ref = Some(tinychannels_bus::ChannelRef { + id: "telegram".into(), + account_id: Some("telegram:fixture".into()), + }); + queued.intent.conversation_ref = Some(tinychannels_bus::ConversationRef { + id: "room".into(), + thread_id: Some("topic".into()), + ..Default::default() + }); + queued.intent.turn_id = Some("cron-run".into()); + store.save_pending(queued.clone()).await.unwrap(); + + assert_eq!( + store.load_pending("scoped-delivery").await.unwrap(), + Some(queued) + ); +} + +#[tokio::test] +async fn prepared_storage_delivers_without_repeating_schema_mutations() { + use tinystoragedrivers_testkit::{Failing, FaultPlan}; + + let documents = docs(); + crate::storage::prepare(&documents).await.unwrap(); + let faults = FaultPlan::new(); + faults.fail_operation_nth("documents.ensure_collection", 1, ErrorKind::Unavailable); + let documents: Arc = Arc::new(Failing::with_plan(documents, faults)); + let store = DocumentDeliveryStore::from_prepared(documents); + + store.save_pending(entry("host-prepared", 1)).await.unwrap(); + assert_eq!( + store.load_pending("host-prepared").await.unwrap(), + Some(entry("host-prepared", 1)) + ); +} + #[tokio::test] async fn pending_records_round_trip_in_enqueue_order() { let store = DocumentDeliveryStore::new(docs()); diff --git a/src/storage/migrations.rs b/src/storage/migrations.rs index 372b31c30..39a318a20 100644 --- a/src/storage/migrations.rs +++ b/src/storage/migrations.rs @@ -13,7 +13,7 @@ use tinystoragedrivers_types::{ }; use super::key; -use super::records::{ChatBinding, StoredDelivery, StoredResult, to_stored_ms}; +use super::records::{ChatBinding, to_stored_ms}; use super::schema::{ DELIVERY_FAILED, DELIVERY_PENDING, DELIVERY_RESULTS, LEGACY_DELIVERY_FAILED, LEGACY_DELIVERY_PENDING, LEGACY_DELIVERY_RESULTS, LEGACY_REMOTE_BINDINGS, LEGACY_REMOTE_BUSY, @@ -335,7 +335,7 @@ fn transform(kind: Transform, record: Versioned) -> Result } Some(Target { id: key::of([entry.id.as_str()])?.as_str().to_owned(), - body: serde_json::to_value(StoredDelivery::from(&entry))?, + body: serde_json::to_value(super::records::stored_delivery_from_queued(&entry))?, expires_ms: None, }) } @@ -345,7 +345,9 @@ fn transform(kind: Transform, record: Versioned) -> Result }; Some(Target { id: key::of([legacy.result.id.as_str()])?.as_str().to_owned(), - body: serde_json::to_value(StoredResult::from(&legacy.result))?, + body: serde_json::to_value(super::records::stored_result_from_terminal( + &legacy.result, + ))?, expires_ms: Some(to_stored_ms(legacy.expires_at_unix_ms)), }) } diff --git a/src/storage/migrations_tests.rs b/src/storage/migrations_tests.rs index 98ffef8c6..f5a4d7ad0 100644 --- a/src/storage/migrations_tests.rs +++ b/src/storage/migrations_tests.rs @@ -276,7 +276,10 @@ async fn retry_accepts_an_exact_target_written_before_source_deletion() { docs.put_with( DELIVERY_PENDING, "op", - serde_json::to_value(StoredDelivery::from(&queued)).unwrap(), + serde_json::to_value(crate::storage::records::stored_delivery_from_queued( + &queued, + )) + .unwrap(), Precondition::Absent, WriteOptions::new().schema(SCHEMA_VERSION), ) @@ -636,7 +639,10 @@ async fn retry_refuses_a_matching_body_with_a_different_deadline() { docs.put_with( DELIVERY_PENDING, "op", - serde_json::to_value(StoredDelivery::from(&queued)).unwrap(), + serde_json::to_value(crate::storage::records::stored_delivery_from_queued( + &queued, + )) + .unwrap(), Precondition::Absent, WriteOptions::new() .schema(SCHEMA_VERSION) diff --git a/src/storage/records.rs b/src/storage/records.rs index f41a9bc24..f0e1a773f 100644 --- a/src/storage/records.rs +++ b/src/storage/records.rs @@ -1,4 +1,121 @@ -//! The stored (v2) delivery records live in `tinychannels_bus::storage::records` -//! beside the migration steps that build them; re-exported here. +//! Projections between bus records and the implementation's stored shapes. +//! +//! The serializable record DTOs are owned by `tinychannels-bus`; these +//! conversions belong here because they encode TinyChannels persistence rules. -pub use tinychannels_bus::storage::records::*; +use tinychannels_bus::channel::ChannelOutboundIntent; +use tinychannels_bus::module_delivery::{ + DeliveryQueueEntry as QueuedDelivery, DeliveryTerminalResult, +}; + +pub use tinychannels_bus::storage::records::{ + ChatBinding, StoredDelivery, StoredIntent, StoredResult, +}; + +/// Convert a wire millisecond count to the persisted signed representation. +/// +/// Values above `i64::MAX` saturate at `i64::MAX`. +#[must_use] +pub fn to_stored_ms(ms: u64) -> i64 { + i64::try_from(ms).unwrap_or(i64::MAX) +} + +/// Convert a persisted signed millisecond count to the wire representation. +/// +/// Negative values become zero. +#[must_use] +pub fn from_stored_ms(ms: i64) -> u64 { + u64::try_from(ms).unwrap_or(0) +} + +/// Project a channel intent into its persisted DTO. +#[must_use] +pub fn stored_intent_from_channel(intent: &ChannelOutboundIntent) -> StoredIntent { + StoredIntent { + idempotency_key: intent.idempotency_key.clone(), + channel_id: intent.channel_id.clone(), + conversation_id: intent.conversation_id.clone(), + channel_ref: intent.channel_ref.clone(), + conversation_ref: intent.conversation_ref.clone(), + turn_id: intent.turn_id.clone(), + reply_to_id: intent.reply_to_id.clone(), + thread_id: intent.thread_id.clone(), + durability: intent.durability, + payload: intent.payload.clone(), + } +} + +/// Rebuild a channel intent from its persisted DTO. +#[must_use] +pub fn channel_from_stored_intent(stored: StoredIntent) -> ChannelOutboundIntent { + ChannelOutboundIntent { + idempotency_key: stored.idempotency_key, + channel_id: stored.channel_id, + conversation_id: stored.conversation_id, + channel_ref: stored.channel_ref, + conversation_ref: stored.conversation_ref, + turn_id: stored.turn_id, + reply_to_id: stored.reply_to_id, + thread_id: stored.thread_id, + durability: stored.durability, + payload: stored.payload, + } +} + +/// Project a queued delivery into its persisted DTO. +#[must_use] +pub fn stored_delivery_from_queued(entry: &QueuedDelivery) -> StoredDelivery { + StoredDelivery { + intent: stored_intent_from_channel(&entry.intent), + enqueued_ms: to_stored_ms(entry.enqueued_at_unix_ms), + retry_count: entry.retry_count, + last_attempt_ms: entry.last_attempt_at_unix_ms.map(to_stored_ms), + last_error: entry.last_error.clone(), + platform_send_started_ms: entry.platform_send_started_at_unix_ms.map(to_stored_ms), + recovery_state: entry.recovery_state, + partial_receipt: entry.partial_receipt.clone(), + } +} + +/// Rebuild a queued delivery from its persisted DTO. +#[must_use] +pub fn queued_from_stored_delivery(stored: StoredDelivery) -> QueuedDelivery { + let intent = channel_from_stored_intent(stored.intent); + QueuedDelivery { + id: intent.idempotency_key.clone(), + intent, + enqueued_at_unix_ms: from_stored_ms(stored.enqueued_ms), + retry_count: stored.retry_count, + last_attempt_at_unix_ms: stored.last_attempt_ms.map(from_stored_ms), + last_error: stored.last_error, + platform_send_started_at_unix_ms: stored.platform_send_started_ms.map(from_stored_ms), + recovery_state: stored.recovery_state, + partial_receipt: stored.partial_receipt, + } +} + +/// Project a terminal delivery result into its persisted DTO. +#[must_use] +pub fn stored_result_from_terminal(result: &DeliveryTerminalResult) -> StoredResult { + StoredResult { + id: result.id.clone(), + intent_fingerprint: result.intent_fingerprint.clone(), + completed_ms: to_stored_ms(result.completed_at_unix_ms), + receipt: result.receipt.clone(), + } +} + +/// Rebuild a terminal delivery result from its persisted DTO. +#[must_use] +pub fn terminal_result_from_stored(stored: StoredResult) -> DeliveryTerminalResult { + DeliveryTerminalResult { + id: stored.id, + intent_fingerprint: stored.intent_fingerprint, + completed_at_unix_ms: from_stored_ms(stored.completed_ms), + receipt: stored.receipt, + } +} + +#[cfg(test)] +#[path = "records_tests.rs"] +mod tests; diff --git a/src/storage/records_tests.rs b/src/storage/records_tests.rs new file mode 100644 index 000000000..2c67a02cd --- /dev/null +++ b/src/storage/records_tests.rs @@ -0,0 +1,66 @@ +use super::*; +use tinychannels_bus::channel::{ChannelRef, ConversationKind, ConversationRef, MessageReceipt}; +use tinychannels_bus::module_delivery::{DeliveryQueueEntry, DeliveryTerminalResult}; + +#[test] +fn stored_delivery_round_trip_preserves_v22_scope_and_partial_receipt() { + let receipt = MessageReceipt { + primary_platform_message_id: Some("part-1".into()), + platform_message_ids: vec!["part-1".into()], + ..MessageReceipt::default() + }; + let entry = DeliveryQueueEntry { + intent: ChannelOutboundIntent { + turn_id: Some("turn-42".into()), + channel_ref: Some(ChannelRef { + id: "telegram".into(), + account_id: Some("account".into()), + }), + conversation_ref: Some(ConversationRef { + kind: ConversationKind::Thread, + id: "room".into(), + thread_id: Some("thread".into()), + ..ConversationRef::default() + }), + ..ChannelOutboundIntent::default() + }, + partial_receipt: Some(receipt.clone()), + ..DeliveryQueueEntry::default() + }; + + let stored = stored_delivery_from_queued(&entry); + let restored = queued_from_stored_delivery(stored); + + assert_eq!(restored, entry); + assert_eq!(restored.intent.channel_ref, entry.intent.channel_ref); + assert_eq!( + restored.intent.conversation_ref, + entry.intent.conversation_ref + ); + assert_eq!(restored.partial_receipt, Some(receipt)); + assert_eq!(restored.intent.turn_id.as_deref(), Some("turn-42")); +} + +#[test] +fn stored_terminal_result_round_trip_preserves_receipt() { + let result = DeliveryTerminalResult { + id: "key".into(), + intent_fingerprint: "digest".into(), + completed_at_unix_ms: 42, + receipt: Some(MessageReceipt { + primary_platform_message_id: Some("message".into()), + ..MessageReceipt::default() + }), + }; + + let restored = terminal_result_from_stored(stored_result_from_terminal(&result)); + + assert_eq!(restored, result); +} + +#[test] +fn persisted_millisecond_conversions_saturate_at_representation_boundaries() { + assert_eq!(to_stored_ms(u64::MAX), i64::MAX); + assert_eq!(from_stored_ms(-1), 0); + assert_eq!(from_stored_ms(42), 42); +} diff --git a/src/storage/schema.rs b/src/storage/schema.rs index c1307ee5b..e83ed7812 100644 --- a/src/storage/schema.rs +++ b/src/storage/schema.rs @@ -1,4 +1,62 @@ -//! The `channels` storage declaration lives in `tinychannels_bus::storage`; -//! this path re-exports it so existing callers keep resolving. +//! Storage driver declarations adapted from TinyChannels' pure bus DTOs. +//! +//! The bus contract describes collection shapes with serde-only data types. +//! This implementation layer converts those declarations to TinyStorageDrivers +//! types where storage setup is performed. -pub use tinychannels_bus::storage::schema::*; +use tinystoragedrivers_types::{ + CollectionSpec as DriverCollectionSpec, IndexSpec as DriverIndexSpec, +}; + +pub use tinychannels_bus::storage::schema::{ + COLLECTIONS, DELIVERY_FAILED, DELIVERY_PENDING, DELIVERY_RESULTS, LEGACY_DELIVERY_FAILED, + LEGACY_DELIVERY_PENDING, LEGACY_DELIVERY_RESULTS, LEGACY_REMOTE_BINDINGS, LEGACY_REMOTE_BUSY, + LEGACY_REMOTE_META, LEGACY_WHATSAPP_DEVICE, LEGACY_WHATSAPP_IDENTITY, + LEGACY_WHATSAPP_LID_MAPPING, LEGACY_WHATSAPP_PREKEY, LEGACY_WHATSAPP_SENDER_KEY, + LEGACY_WHATSAPP_SESSION, LEGACY_WHATSAPP_SIGNED_PREKEY, LEGACY_WHATSAPP_SYNC_KEY, + MEDIA_BLOB_PREFIX, MEDIA_METADATA, MEDIA_METADATA_VERSION, MIGRATION_SOURCE_COLLECTIONS, + MIGRATIONS, REMOTE_BINDINGS, REMOTE_BUSY, REMOTE_META, SCHEMA_VERSION, WHATSAPP_COLLECTIONS, + WHATSAPP_DEVICE, WHATSAPP_IDENTITY, WHATSAPP_LID_MAPPING, WHATSAPP_PREKEY, WHATSAPP_SENDER_KEY, + WHATSAPP_SESSION, WHATSAPP_SIGNED_PREKEY, WHATSAPP_SYNC_KEY, +}; +pub use tinychannels_bus::storage::schema::{ + CollectionSpec as CollectionSchema, IndexSpec as IndexSchema, SearchSpec as SearchSchema, +}; + +/// Return the storage-driver declaration for media metadata. +#[must_use] +pub fn media_metadata_spec() -> DriverCollectionSpec { + to_driver_spec(tinychannels_bus::storage::schema::media_metadata_spec()) +} + +/// Adapt the owner inventory to the storage driver's setup API. +#[must_use] +pub fn collections() -> Vec { + tinychannels_bus::storage::schema::collections() + .into_iter() + .map(to_driver_spec) + .collect() +} + +fn to_driver_spec(spec: CollectionSchema) -> DriverCollectionSpec { + let mut driver = DriverCollectionSpec::new(spec.name); + for index in spec.indexes { + let driver_index = DriverIndexSpec::new(index.name, index.fields); + driver = driver.index(if index.unique { + driver_index.unique() + } else { + driver_index + }); + } + if let Some(ttl_field) = spec.ttl_field { + driver = driver.ttl(ttl_field); + } + if let Some(search) = spec.search { + driver = driver.searchable(search.fields); + } + driver +} + +#[cfg(test)] +#[path = "schema_tests.rs"] +mod tests; diff --git a/src/storage/schema_tests.rs b/src/storage/schema_tests.rs new file mode 100644 index 000000000..0a6f8c6cd --- /dev/null +++ b/src/storage/schema_tests.rs @@ -0,0 +1,58 @@ +use super::*; + +#[test] +fn collections_is_pure_and_declares_each_owned_collection_once() { + let specs = collections(); + let names: Vec<_> = specs.iter().map(|spec| spec.name.as_str()).collect(); + assert_eq!(names, COLLECTIONS); + assert!(specs.iter().all(|spec| spec.indexes.is_empty())); + assert!(specs.iter().all(|spec| spec.ttl_field.is_none())); + assert!(specs.iter().all(|spec| spec.search.is_none())); + assert_eq!(media_metadata_spec().name, MEDIA_METADATA); +} + +#[test] +fn schema_conversion_preserves_index_constraints_expiry_and_search_paths() { + let declaration: CollectionSchema = serde_json::from_value(serde_json::json!({ + "name": "channels.media_metadata", + "indexes": [ + { + "name": "account_media", + "fields": ["scope.account_id", "media_id"], + "unique": true + }, + { + "name": "recent_uploads", + "fields": ["created_ms"], + "unique": false + } + ], + "ttl_field": "lifetime.expires_ms", + "search": {"fields": ["caption", "metadata.description"]} + })) + .unwrap(); + + let driver = to_driver_spec(declaration); + + assert_eq!(driver.name, MEDIA_METADATA); + assert_eq!( + driver.indexes, + vec![ + DriverIndexSpec { + name: "account_media".into(), + fields: vec!["scope.account_id".into(), "media_id".into()], + unique: true, + }, + DriverIndexSpec { + name: "recent_uploads".into(), + fields: vec!["created_ms".into()], + unique: false, + }, + ] + ); + assert_eq!(driver.ttl_field.as_deref(), Some("lifetime.expires_ms")); + assert_eq!( + driver.search.unwrap().fields, + vec!["caption", "metadata.description"] + ); +} diff --git a/vendor/tinybus b/vendor/tinybus index 6ce06bc67..61dce96aa 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit 6ce06bc674e4f4d718aa51e643ef62c1f372e270 +Subproject commit 61dce96aac5d20d8b6fc7cfe27daef559c7d54c9 diff --git a/wiki b/wiki index f11fcec08..cc6c73569 160000 --- a/wiki +++ b/wiki @@ -1 +1 @@ -Subproject commit f11fcec0892c4a7b58e3c1295152c31a16ac6272 +Subproject commit cc6c73569d99ca408278d7b66a8180833a2e6f1f