diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..00ae6b2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,40 @@ +# Please see the documentation for all configuration options: +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "daily" + reviewers: + - "warpdotdev/tech-leads" + # Only send security updates, not general version updates. + open-pull-requests-limit: 0 + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + reviewers: + - "warpdotdev/tech-leads" + cooldown: + # Don't update to any action release that is less than two weeks old. + default-days: 14 + groups: + # Group all non-major updates of official actions together - they're lower-risk. + official-actions: + applies-to: version-updates + patterns: + - "actions/*" + update-types: + - "minor" + - "patch" + # Group all non-major updates of Namespace actions together - they're lower-risk. + namespace-actions: + applies-to: version-updates + patterns: + - "namespacelabs/*" + - "namespace-actions/*" + update-types: + - "minor" + - "patch" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..087c10c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + +jobs: + ci: + name: CI + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup SSH keys for private crates + uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 + with: + ssh-private-key: ${{ secrets.WARP_MACHINE_USER_SSH_PRIVATE_KEY }} + + - name: Run cargo fmt + run: cargo fmt --check + + - name: Run cargo clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Install cargo nextest + uses: taiki-e/install-action@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18 + with: + tool: nextest + + - name: Run tests + run: cargo nextest run --workspace --no-tests=warn diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..578badf --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target + +# IDE things +.idea/* + +# DS_STORE +.DS_STORE diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9b916dd --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,905 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byte-unit" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d" +dependencies = [ + "rust_decimal", + "schemars", + "serde", + "utf8-width", +] + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +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 = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rust_decimal" +version = "1.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "session-sharing-protocol" +version = "0.0.0" +dependencies = [ + "byte-unit", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[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 = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..86e1b1f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "session-sharing-protocol" +edition = "2024" +publish = false + +[dependencies] +byte-unit = { version = "5.1.7", features = ["serde"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +uuid = { version = "1.4", features = ["serde", "v4"] } diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..d6eb8fb --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.88.0" +components = ["rustfmt", "clippy"] +profile = "minimal" diff --git a/src/common/agent_prompt.rs b/src/common/agent_prompt.rs new file mode 100644 index 0000000..40465f3 --- /dev/null +++ b/src/common/agent_prompt.rs @@ -0,0 +1,116 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::BlockId; + +/// An opaque id to track agent prompt requests from viewers. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct AgentPromptRequestId(String); + +impl AgentPromptRequestId { + pub fn new() -> Self { + Self(Uuid::new_v4().to_string()) + } +} + +impl Default for AgentPromptRequestId { + fn default() -> Self { + Self::new() + } +} + +/// A set of reasons for which an agent prompt request might fail. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum AgentPromptFailureReason { + /// The viewer does not have sufficient permissions to send agent prompts. + InsufficientPermissions, + + /// The conversation ID provided is invalid or doesn't exist. + InvalidConversation, + + // There is a long running command that is already in progress. + CommandInProgress, +} + +/// Represents an AI agent attachment that can be sent with a prompt. +/// This is a simplified version for the protocol - the sharer will reconstruct +/// the full attachment from the block ID. +/// +/// TODO: Add support for image attachments. Images are currently handled as +/// AIAgentContext::Image (contextual info) rather than AIAgentAttachment in the client, +/// so we need to decide whether to treat viewer-attached images as context or attachments +/// before adding them to the protocol. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum AgentAttachment { + /// A reference to a terminal block by ID. + /// The sharer will resolve this to the actual block content. + BlockReference { block_id: BlockId }, + + /// Plain text attachment (e.g., clipboard content). + PlainText { content: String }, + + /// A reference to a file that has been uploaded to GCS. + /// The host fetches download URLs directly from warp-server using the attachment ID. + FileReference { + attachment_id: String, + file_name: String, + }, +} + +/// An optional server conversation token for continuing an existing agent conversation. +/// The id provided here is 1:1 with the ServerConversationToken used on the client/server (not AIAgentConversationId). +/// If no token is provided, a new conversation will be started. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Copy)] +pub struct ServerConversationToken(Uuid); + +impl ServerConversationToken { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + pub fn from_uuid(uuid: Uuid) -> Self { + Self(uuid) + } + + pub fn as_uuid(&self) -> Uuid { + self.0 + } +} + +impl Default for ServerConversationToken { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Display for ServerConversationToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::str::FromStr for ServerConversationToken { + type Err = uuid::Error; + + fn from_str(s: &str) -> Result { + Ok(Self(Uuid::parse_str(s)?)) + } +} + +/// The data for an agent prompt request from a viewer. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AgentPromptRequest { + /// Unique identifier for this request. + pub id: AgentPromptRequestId, + + /// The server conversation token to continue. If None, start a new conversation. + /// This is the server_conversation_token that links viewer and sharer conversations. + pub server_conversation_token: Option, + + /// The user's prompt/query. + pub prompt: String, + + /// Optional attachments (blocks, files, etc.) referenced in the prompt. + #[serde(default)] + pub attachments: Vec, +} diff --git a/src/common/command_execution.rs b/src/common/command_execution.rs new file mode 100644 index 0000000..2ecbd28 --- /dev/null +++ b/src/common/command_execution.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// An opaque id to track command execution requests. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CommandExecutionRequestId(String); +impl CommandExecutionRequestId { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self(Uuid::new_v4().to_string()) + } +} + +/// A set of reasons for which a command execution request might fail. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum CommandExecutionFailureReason { + /// The viewer does not have sufficient permissions to run commands. + InsufficientPermissions, + + /// The buffer for which the command execution was requested is old. + /// Specifically, there is either a newer buffer or a command + /// is in-progress for the given buffer. + StaleBuffer, +} diff --git a/src/common/control_action.rs b/src/common/control_action.rs new file mode 100644 index 0000000..e149a88 --- /dev/null +++ b/src/common/control_action.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::common::ServerConversationToken; + +/// A unique id to track control action requests. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ControlActionRequestId(String); + +impl ControlActionRequestId { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self(Uuid::new_v4().to_string()) + } + + /// Returns the underlying opaque id string. + pub fn id(&self) -> &str { + &self.0 + } +} + +/// Higher-level control messages that don't correspond 1:1 to terminal actions/inputs +/// (or imply a warp-specific action outside of their normal terminal use). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum ControlAction { + /// Request that a shared-session AI conversation be cancelled by the sharer. + CancelConversation { + server_conversation_token: ServerConversationToken, + }, +} + +/// Reasons a control action request from a viewer might fail. +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +pub enum ControlActionFailureReason { + /// The viewer does not have permission to perform this control action. + InsufficientPermissions, + /// The session no longer exists. + SessionNotFound, + /// There is no sharer currently connected to handle the action. + SharerUnavailable, + /// Unexpected, something went wrong in the server. + InternalServerError, +} diff --git a/src/common/feature_support.rs b/src/common/feature_support.rs new file mode 100644 index 0000000..b9e810c --- /dev/null +++ b/src/common/feature_support.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; + +/// Client feature support declaration. +/// Clients include this in their init payloads to declare which protocol features they support. +/// This allows the server to adapt messages for backward compatibility with older clients. +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct FeatureSupport { + /// Whether the client supports agent view and has it enabled. + #[serde(default)] + pub supports_agent_view: bool, + /// Unused in favor of supports_full_role_for_real. Clients set this to true before they were actually ready. + #[serde(default)] + pub supports_full_role: bool, + /// Whether the client supports the "Full" role ACL. + #[serde(default)] + pub supports_full_role_for_real: bool, +} diff --git a/src/common/input.rs b/src/common/input.rs new file mode 100644 index 0000000..62526d0 --- /dev/null +++ b/src/common/input.rs @@ -0,0 +1,128 @@ +use super::{BlockId, ParticipantId}; +use byte_unit::Byte; +use serde::{Deserialize, Serialize}; + +/// The replica ID that a participant's CRDT-compliant input +/// buffer must use. This must be unique across a session. +/// +/// The sharer is allowed to choose their own replica ID. +#[derive(Clone, Default, Debug, Serialize, Deserialize)] +pub struct InputReplicaId(String); + +impl From for InputReplicaId { + fn from(value: String) -> Self { + Self(value) + } +} + +impl std::fmt::Display for InputReplicaId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// A monotonically increasing sequence number to identify sequential edits for a given buffer. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)] +pub struct InputOperationSeqNo(usize); + +impl InputOperationSeqNo { + pub fn zero() -> Self { + Self(0) + } + + pub fn advance(&mut self) { + self.0 += 1; + } + + pub fn as_usize(&self) -> usize { + self.0 + } +} + +impl From for InputOperationSeqNo { + fn from(value: usize) -> Self { + Self(value) + } +} + +/// A [`BufferId`] identifies an instance of the buffer in a session. +/// For example, suppose a session starts with buffer_id=B1. +/// When a command is executed and the buffer is reset, the +/// buffer_id=B2, where B1 != B2. +/// +/// Today, a [`BufferId`] masquerades as a [`BlockId`]. +#[derive(Clone, Debug, Default, Deserialize, Hash, Serialize, Eq, PartialEq)] +pub struct BufferId(String); + +impl std::fmt::Display for BufferId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for BufferId { + fn from(value: String) -> Self { + Self(value) + } +} + +impl From for BufferId { + fn from(value: BlockId) -> Self { + Self(value.to_string()) + } +} + +impl From for BlockId { + fn from(value: BufferId) -> Self { + value.0.into() + } +} + +/// A CRDT-compliant operation. +/// For now, this is a arbitrary payload that clients should know +/// how to serialize / deserialize. Eventually, this will be a +/// strongly-typed data structure. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CrdtOperation(pub Vec); + +/// A unique identifier for an input operation. Specifically, +/// this uniquely identifies an operation for a specific buffer, +/// for a given participant. +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +pub struct InputOperationId { + /// The participant that made the change. + pub participant_id: ParticipantId, + + /// The ID of the buffer that this operation was applied to. + pub buffer_id: BufferId, + + /// A monotonically increasing sequence number to identify sequential edits + /// for a specific buffer. + pub op_no: InputOperationSeqNo, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct InputUpdate { + pub id: InputOperationId, + + /// A single input operation consists of a batch + /// of updates. + pub ops: Vec, +} + +impl InputUpdate { + pub fn num_bytes(&self) -> Byte { + self.ops + .iter() + .map(|op| op.0.len() as u64) + .fold(0, u64::saturating_add) + .into() + } +} + +/// A set of reasons why a request to edit the input might fail. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum InputUpdateFailureReason { + /// The viewer does not have sufficient permissions. + InsufficientPermissions, +} diff --git a/src/common/mod.rs b/src/common/mod.rs new file mode 100644 index 0000000..e4d755f --- /dev/null +++ b/src/common/mod.rs @@ -0,0 +1,39 @@ +//! Common types used by both sharer and viewer. + +mod agent_prompt; +mod command_execution; +mod control_action; +mod feature_support; +mod input; +mod ordered_terminal_events; +mod participant; +mod permissions; +mod presence; +mod prompt; +mod roles; +mod scrollback; +mod session_params; +mod team; +mod telemetry; +mod ui_state; +mod user; +mod write_to_pty; + +pub use agent_prompt::*; +pub use command_execution::*; +pub use control_action::*; +pub use feature_support::*; +pub use input::*; +pub use ordered_terminal_events::*; +pub use participant::*; +pub use permissions::*; +pub use presence::*; +pub use prompt::*; +pub use roles::*; +pub use scrollback::*; +pub use session_params::*; +pub use team::*; +pub use telemetry::*; +pub use ui_state::*; +pub use user::*; +pub use write_to_pty::*; diff --git a/src/common/ordered_terminal_events.rs b/src/common/ordered_terminal_events.rs new file mode 100644 index 0000000..7461391 --- /dev/null +++ b/src/common/ordered_terminal_events.rs @@ -0,0 +1,116 @@ +use super::{BlockId, ParticipantId}; +use byte_unit::Byte; +use serde::{Deserialize, Serialize}; + +/// AI metadata for correlating terminal blocks with agent commands. +/// This allows viewers in shared sessions to associate terminal command blocks +/// with the agent tool calls that triggered them. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AICommandMetadata { + /// The tool call ID from the Multi-Agent API protocol. + /// Corresponds to action_id on the sharer side. + pub tool_call_id: String, + + /// Whether this command is being monitored by an agent as a long-running command. + #[serde(default)] + pub is_agent_monitored: bool, +} + +/// Types of terminal events that need to be ordered against each other. +#[derive(Clone, Deserialize, Serialize)] +pub enum OrderedTerminalEventType { + /// Bytes read off the sharer's pty (session contents). + PtyBytesRead { + bytes: Vec, + }, + /// A command is beginning to execute. + CommandExecutionStarted { + /// The ID of the participant who ran the command. + participant_id: ParticipantId, + /// AI metadata if this command was executed by an agent. + #[serde(default)] + ai_metadata: Option, + }, + CommandExecutionFinished { + next_block_id: BlockId, + }, + /// The sharer's terminal was resized. + Resize { + window_size: WindowSize, + }, + /// The sharer received an AI agent response event. Response events include all information needed to reconstruct a conversation, including: + // * The start and end of individual requests + // * Incremental agent output + // * Echoed user messages and tool call results + /// See https://github.com/warpdotdev/warp-proto-apis/blob/6310871f081b5f44b2d4e3e5d8fdfa3008b750b0/apis/multi_agent/v1/response.proto#L16-L17 + AgentResponseEvent { + /// The ID of the participant who sent the query to initiate this agent response. + response_initiator: Option, + /// The base64-encoded MAA ResponseEvent protocol buffer message. + response_event: String, + /// For forked conversations, this is the original conversation token that the + /// conversation was forked from. Viewers use this to link the new server-assigned + /// conversation token to an existing conversation created during historical replay. + #[serde(default)] + forked_from_conversation_token: Option, + }, + /// Marks the start of historical agent conversation replay. + /// Viewers should use this to suppress live-conversation specific actions until replay ends + /// (e.g. the insertion of the ambient agent conversation tombstone). + AgentConversationReplayStarted, + /// Marks the end of historical agent conversation replay. + AgentConversationReplayEnded, +} + +/// Represents the size of a PTY. Mimics the winsize struct that +/// can be queried via [ioctl](https://man7.org/linux/man-pages/man2/ioctl_tty.2.html). +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)] +pub struct WindowSize { + pub num_rows: usize, + pub num_cols: usize, +} + +/// Override the Debug impl to avoid accidentally leaking sensitive +/// data in logs. +impl std::fmt::Debug for OrderedTerminalEventType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PtyBytesRead { .. } => f.write_str("PtyBytesRead"), + Self::CommandExecutionStarted { .. } => f.write_str("CommandExecutionStarted"), + Self::CommandExecutionFinished { .. } => f.write_str("CommandExecutionFinished"), + Self::Resize { .. } => f.write_str("Resize"), + Self::AgentResponseEvent { .. } => f.write_str("AgentResponseEvent"), + Self::AgentConversationReplayStarted => f.write_str("AgentConversationReplayStarted"), + Self::AgentConversationReplayEnded => f.write_str("AgentConversationReplayEnded"), + } + } +} + +impl OrderedTerminalEventType { + pub fn num_bytes(&self) -> Byte { + match &self { + OrderedTerminalEventType::PtyBytesRead { bytes } => bytes.len().into(), + OrderedTerminalEventType::AgentResponseEvent { response_event, .. } => { + response_event.len().into() + } + OrderedTerminalEventType::CommandExecutionStarted { .. } + | OrderedTerminalEventType::CommandExecutionFinished { .. } + | OrderedTerminalEventType::AgentConversationReplayStarted + | OrderedTerminalEventType::AgentConversationReplayEnded + | OrderedTerminalEventType::Resize { .. } => Byte::from_u64(0), + } + } +} + +/// Any terminal event where strict ordering against other terminal events is important. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct OrderedTerminalEvent { + pub event_no: usize, + pub event_type: OrderedTerminalEventType, +} + +impl OrderedTerminalEvent { + pub fn num_bytes(&self) -> Byte { + self.event_type.num_bytes() + } +} diff --git a/src/common/participant.rs b/src/common/participant.rs new file mode 100644 index 0000000..0397755 --- /dev/null +++ b/src/common/participant.rs @@ -0,0 +1,154 @@ +use super::{InputReplicaId, Role, Selection}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub enum ParticipantType { + Sharer, + Viewer { role: Role }, +} + +/// An ID for a shared session participant that is unique across all participants across all shared sessions. +/// If a viewer joins a shared session multiple times from the same machine, they are treated as separate participants with their own ParticipantId. +/// A participant reconnecting should keep the same participant ID. +#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct ParticipantId(String); + +impl From for ParticipantId { + fn from(value: String) -> Self { + ParticipantId(value) + } +} + +impl ParticipantId { + pub fn new() -> ParticipantId { + ParticipantId(Uuid::new_v4().to_string()) + } +} + +impl Default for ParticipantId { + fn default() -> Self { + ParticipantId::new() + } +} + +impl std::fmt::Display for ParticipantId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} + +/// Mostly static information about a participant. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct ProfileData { + pub firebase_uid: String, + pub display_name: String, + + /// If None, the client should render an avatar themselves. + pub photo_url: Option, + pub email: Option, + + pub input_replica_id: InputReplicaId, +} + +/// Contains information about a shared session participant. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct ParticipantInfo { + pub id: ParticipantId, + pub profile_data: ProfileData, + pub selection: Selection, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct Sharer { + pub info: ParticipantInfo, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct Viewer { + pub info: ParticipantInfo, + pub role: Role, + /// Whether or not this viewer is still part of the session. + pub is_present: bool, +} + +/// Information about a viewer that is still part of the session. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct PresentViewer { + pub info: ParticipantInfo, + /// The maximum access level this viewer has been given. + pub max_acl: Role, +} + +/// Information about a viewer that is no longer a part of the session. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct AbsentViewer { + pub info: ParticipantInfo, +} + +/// Information about a user who is a direct guest on the session. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct Guest { + pub profile_data: ProfileData, + /// The direct access level that this guest has been given. + pub direct_acl: Role, +} + +/// Information about a non-Warp user who has been invited to the session. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct PendingGuest { + pub email: String, + /// The direct access level that this guest has been given. + pub direct_acl: Role, +} + +/// Information about the full list of all participants in a shared session. +/// +/// To derive the session's direct guests, all of the users with direct acls +/// across `present_viewers`, `absent_viewers`, and `non_viewer_guests` must be +/// accumulated. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct ParticipantList { + pub sharer: Sharer, + + /// Legacy field kept for backwards compatibility. After the ACL transition + /// we should be using `present_viewers`, `absent_viewers`, and + /// `non_viewer_guests` and can remove this field. + pub viewers: Vec, + + /// Viewers that are currently on the session. + pub present_viewers: Vec, + /// Viewers that are no longer on the session. + pub absent_viewers: Vec, + /// Users that have a direct ACL on the session. + pub guests: Vec, + /// Non-Warp users who have been invited to the session. + pub pending_guests: Vec, +} + +impl ParticipantList { + /// Downgrades all `Role::Full` fields to `Role::Executor`. + /// Used for backward compatibility with clients that don't support the Full role. + pub fn downgrade_full_roles(&mut self) { + for viewer in &mut self.viewers { + viewer.role.downgrade_full(); + } + for viewer in &mut self.present_viewers { + viewer.max_acl.downgrade_full(); + } + for guest in &mut self.guests { + guest.direct_acl.downgrade_full(); + } + for guest in &mut self.pending_guests { + guest.direct_acl.downgrade_full(); + } + } +} + +/// Information received from fetching and processing the participant list. +pub struct ParticipantListInfo { + /// The full list of participants in a shared session. + pub list: ParticipantList, + /// The list of present participants who do not have access to the session. + pub viewers_without_access: Vec, +} diff --git a/src/common/permissions.rs b/src/common/permissions.rs new file mode 100644 index 0000000..8007909 --- /dev/null +++ b/src/common/permissions.rs @@ -0,0 +1,65 @@ +//! Permission-related types shared between the sharer and viewer protocols. + +use super::{Role, TeamAclData}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub enum LinkAccessLevelUpdateResponse { + Ok { role: Option }, + Error, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] +pub enum AddGuestsResponse { + Success, + Error(FailedToAddGuestsReason), +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] +pub enum FailedToAddGuestsReason { + /// Unexpected, something went wrong in the server. + Invalid, + /// One or more of the emails did not correspond with Warp users. + NotWarpUsers, + /// One or more of the guests has already been added to the session. + GuestAlreadyAdded, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] +pub enum RemoveGuestResponse { + Success, + Error(FailedToRemoveGuestReason), +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] +pub enum FailedToRemoveGuestReason { + /// Unexpected, something went wrong in the server. + Invalid, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] +pub enum UpdatePendingUserRoleResponse { + Success, + Error(FailedToUpdatePendingUserRoleReason), +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] +pub enum FailedToUpdatePendingUserRoleReason { + /// Unexpected, something went wrong in the server. + Invalid, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum TeamAccessLevelUpdateResponse { + Success { + team_uid: String, + team_acl: Option, + }, + Error(FailedToUpdateTeamAccessLevelReason), +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] +pub enum FailedToUpdateTeamAccessLevelReason { + /// Unexpected, something went wrong in the server. + Invalid, +} diff --git a/src/common/presence.rs b/src/common/presence.rs new file mode 100644 index 0000000..83852e0 --- /dev/null +++ b/src/common/presence.rs @@ -0,0 +1,102 @@ +use super::ParticipantId; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub enum GridType { + Prompt, + /// Right side prompt + Rprompt, + Output, + /// Combined prompt/command grid, used for same-line prompt + PromptAndCommand, +} + +/// A point in a grid. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct Point { + pub row: usize, + pub col: usize, +} + +/// A point in a grid within a block. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct BlockPoint { + pub block_id: BlockId, + pub grid_type: GridType, + pub point: Point, +} + +/// An ID for a block that is unique only within a single shared session. +#[derive(Clone, Debug, Default, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct BlockId(String); + +impl std::fmt::Display for BlockId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for BlockId { + fn from(value: String) -> Self { + Self(value) + } +} + +/// What a shared session participant has selected. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +pub enum Selection { + #[default] + None, + Blocks { + block_ids: Vec, + }, + /// Start is always before end. + BlockText { + start: BlockPoint, + end: BlockPoint, + /// If true, the user selected from the end point to the start point (useful for knowing where the cursor should be) + is_reversed: bool, + }, + /// Start is always before end + AltScreenText { + start: Point, + end: Point, + /// If true, the user selected from the end point to the start point (useful for knowing where the cursor should be) + is_reversed: bool, + }, +} + +#[derive( + Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize, +)] +pub struct SelectionEventNo(usize); + +impl From for SelectionEventNo { + fn from(value: usize) -> Self { + Self(value) + } +} +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SelectionUpdate { + pub selection: Selection, + pub event_no: SelectionEventNo, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum PresenceUpdate { + Selection(Selection), + // other stuff in the future, like scroll state +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ParticipantPresenceUpdate { + pub participant_id: ParticipantId, + pub update: PresenceUpdate, +} + +/// One participant's current selection. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct ParticipantSelection { + pub id: ParticipantId, + pub selection: Selection, +} diff --git a/src/common/prompt.rs b/src/common/prompt.rs new file mode 100644 index 0000000..6955ce0 --- /dev/null +++ b/src/common/prompt.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; + +// The prompt for the active block. +#[derive(Clone, Default, Deserialize, PartialEq, Eq, Serialize)] +pub enum ActivePrompt { + /// Using the PS1 prompt, which is included in forwarded pty bytes. + #[default] + PS1, + /// JSON serialization of PromptSnapshot + WarpPrompt(String), +} + +/// Override the Debug impl to avoid accidentally leaking sensitive +/// data in logs. +impl std::fmt::Debug for ActivePrompt { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PS1 { .. } => f.write_str("ActivePrompt::PS1"), + Self::WarpPrompt(..) => f.write_str("ActivePrompt::WarpPrompt"), + } + } +} + +// An update to the active block's prompt. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ActivePromptUpdate { + pub active_prompt: ActivePrompt, + /// The event_no of the last OrderedTerminalEvent shared. + pub last_event_no: usize, +} diff --git a/src/common/roles.rs b/src/common/roles.rs new file mode 100644 index 0000000..46417fa --- /dev/null +++ b/src/common/roles.rs @@ -0,0 +1,86 @@ +use super::ParticipantId; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub enum Role { + Reader, + Executor, + /// Executor, and can change ACLs of others + Full, +} + +impl Role { + /// Returns true if this role has execution permissions. + pub fn can_execute(&self) -> bool { + matches!(self, Role::Executor | Role::Full) + } + + /// Downgrades `Full` to `Executor` for clients that don't support the Full role. + pub fn downgrade_full(&mut self) { + if *self == Role::Full { + *self = Role::Executor; + } + } +} + +impl Default for Role { + fn default() -> Self { + Self::Reader + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +/// Info about different types of ACLs for a user. +pub struct AccessLevels { + /// The maximum ACL given to the user, could be direct, link-based, etc. + pub max_acl: Role, + /// The direct ACL given to the user. + pub direct_acl: Option, +} + +/// An ID for a role request that is unique across all participants across all shared sessions. +#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct RoleRequestId(String); + +impl From for RoleRequestId { + fn from(value: String) -> Self { + RoleRequestId(value) + } +} + +impl RoleRequestId { + pub fn new() -> RoleRequestId { + RoleRequestId(Uuid::new_v4().to_string()) + } +} + +impl Default for RoleRequestId { + fn default() -> Self { + RoleRequestId::new() + } +} + +impl std::fmt::Display for RoleRequestId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum RoleRequestRejectedReason { + RejectedBySharer, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum RoleRequestResponse { + Approved { new_role: Role }, + Rejected { reason: RoleRequestRejectedReason }, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct PendingRoleRequest { + participant_id: ParticipantId, + request_id: RoleRequestId, + role: Role, +} diff --git a/src/common/scrollback.rs b/src/common/scrollback.rs new file mode 100644 index 0000000..e66e33e --- /dev/null +++ b/src/common/scrollback.rs @@ -0,0 +1,65 @@ +use byte_unit::Byte; +use serde::{Deserialize, Serialize}; + +/// Scrollback is the set of session contents that weren't shared live +/// but are still part of the shared session. +#[derive(Clone, Deserialize, Serialize)] +pub struct Scrollback { + /// The blocks that make up the scrollback. Clients are expected + /// to be able to serialize and deserialize accordingly. + pub blocks: Vec, + + /// True iff the session is in alt-screen mode + /// at time of share. + pub is_alt_screen_active: bool, +} + +impl Scrollback { + pub fn num_bytes(&self) -> Byte { + self.blocks + .iter() + .map(|b| b.num_bytes().as_u64()) + .fold(0, u64::saturating_add) + .into() + } + + /// Returns true if the scrollback size exceeds |size_bytes|. + pub fn exceeds_size_bytes(&self, size_bytes: Byte) -> bool { + self.num_bytes() > size_bytes + } +} + +/// Override the Debug impl to avoid accidentally leaking sensitive +/// data in logs. +impl std::fmt::Debug for Scrollback { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Scrollback {{ num_blocks: {}, is_alt_screen_active: {} }}", + self.blocks.len(), + self.is_alt_screen_active + ) + } +} + +/// An individual scrollback block. +#[derive(Clone, Deserialize, Serialize)] +pub struct ScrollbackBlock { + /// The raw contents of the block. Clients are expected to be able to + /// serialize and deserialize from [`SerializedBlock`] in the Warp client. + pub raw: Vec, +} + +impl ScrollbackBlock { + pub fn num_bytes(&self) -> Byte { + self.raw.len().into() + } +} + +/// Override the Debug impl to avoid accidentally leaking sensitive +/// data in logs. +impl std::fmt::Debug for ScrollbackBlock { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "ScrollbackBlock {{ num_bytes: {} }}", self.num_bytes()) + } +} diff --git a/src/common/session_params.rs b/src/common/session_params.rs new file mode 100644 index 0000000..b8a16a5 --- /dev/null +++ b/src/common/session_params.rs @@ -0,0 +1,99 @@ +use serde::{Deserialize, Serialize}; + +use uuid::Uuid; + +/// The canonical identifier for a shared session. +/// A [`SessionId`] on its own cannot be used to access +/// a shared session; you need the corresponding [`SessionSecret`]. +/// TODO: consider making the internal type a plain old String. +#[derive(Debug, Hash, Serialize, Deserialize, Eq, PartialEq, Clone, Copy)] +#[serde(transparent)] +pub struct SessionId(Uuid); +impl SessionId { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self(Uuid::new_v4()) + } +} + +impl std::fmt::Display for SessionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// The `warp` server framework uses [`FromStr`] to deserialize +/// the string from the route. +impl std::str::FromStr for SessionId { + type Err = uuid::Error; + fn from_str(s: &str) -> Result { + Uuid::from_str(s).map(SessionId) + } +} + +/// The secret for a shared session. +/// A shared session cannot be accessed without its secret. +/// The client should treat this as some opaque string. +#[derive(Hash, Serialize, Deserialize, Eq, PartialEq, Clone, Default)] +#[serde(transparent)] +pub struct SessionSecret(String); +impl SessionSecret { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self(Uuid::new_v4().to_string()) + } +} + +/// Override the Display impl for the secret to return a mask. +/// This makes it harder to leak the secret by accident. +impl std::fmt::Display for SessionSecret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "***") + } +} + +/// Override the Debug impl for the secret to return a mask. +/// This makes it harder to leak the secret by accident. +impl std::fmt::Debug for SessionSecret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "***") + } +} + +/// The `warp` server framework uses [`FromStr`] to deserialize +/// the string from the route. +impl std::str::FromStr for SessionSecret { + type Err = core::convert::Infallible; + fn from_str(s: &str) -> Result { + String::from_str(s).map(SessionSecret) + } +} + +/// The parameters needed to attempt to join a shared session. +/// This is different from [`viewer::InitPayload`] which +/// is the state that a viewer must pass up _after_ successfully +/// joining a shared session. +#[derive(Clone)] +pub struct JoinSessionLinkArgs { + pub session_id: SessionId, + pub session_secret: SessionSecret, +} + +impl JoinSessionLinkArgs { + // TODO: ideally, the protocol should just generate the full + // link for the client to consume. This will make more sense + // once we move away from app URIs. + pub fn to_join_route(&self) -> String { + format!( + "/sessions/join/{}?pwd={}", + self.session_id, + self.secret_to_string(), + ) + } + + /// Returns the [`SessionSecret`] as a [`String`] for joining purposes. + pub fn secret_to_string(&self) -> String { + // We can't use the [`SessionSecret`]'s display because it's overriden to be masked. + self.session_secret.0.to_string() + } +} diff --git a/src/common/team.rs b/src/common/team.rs new file mode 100644 index 0000000..3858d7f --- /dev/null +++ b/src/common/team.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; + +use super::Role; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TeamAclData { + pub acl: Role, + pub uid: String, + pub name: String, +} diff --git a/src/common/telemetry.rs b/src/common/telemetry.rs new file mode 100644 index 0000000..868937d --- /dev/null +++ b/src/common/telemetry.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Context about the end-client that we want to attach to telemetry events. +/// +/// This is an opaque blob that the client chooses because we want to respect +/// whatever context it wants to declare rather than a custom schema. +/// Since we use Rudderstack, it should still respect the named fields +/// here: https://www.rudderstack.com/docs/event-spec/standard-events/common-fields/#contextual-fields +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TelemetryContext(pub Value); diff --git a/src/common/ui_state.rs b/src/common/ui_state.rs new file mode 100644 index 0000000..a069e31 --- /dev/null +++ b/src/common/ui_state.rs @@ -0,0 +1,255 @@ +use serde::{Deserialize, Serialize}; + +use crate::common::ServerConversationToken; + +/// The active base model selection for agent mode. +/// This represents the UI state of which model is selected in the model picker chip. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct SelectedAgentModel(String); + +impl SelectedAgentModel { + pub fn new(model_id: impl Into) -> Self { + Self(model_id.into()) + } + + pub fn model_id(&self) -> &str { + &self.0 + } +} + +/// The selected conversation for agent mode. +/// When agent view is enabled, this represents the conversation that is currently expanded. +/// When agent view is disabled, this represents the conversation the next query will follow up in. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Default)] +pub enum SelectedConversation { + /// An existing conversation identified by a server token + ExistingConversation(ServerConversationToken), + /// The next query will start a new conversation + /// (when agent view is enabled, this looks like an empty expanded view). + #[default] + NewConversation, + /// No conversation selected + /// (when agent view is enabled, this means that no agent view is expanded). + NoConversation, +} + +impl SelectedConversation { + pub fn new(server_token: Option) -> Self { + match server_token { + Some(token) => Self::ExistingConversation(token), + None => Self::NewConversation, + } + } + + pub fn server_token(&self) -> Option<&ServerConversationToken> { + match self { + Self::ExistingConversation(token) => Some(token), + Self::NewConversation | Self::NoConversation => None, + } + } + + pub fn is_new_conversation(&self) -> bool { + matches!(self, Self::NewConversation) + } + + pub fn is_no_conversation(&self) -> bool { + matches!(self, Self::NoConversation) + } +} + +/// The input type for the universal developer input. +#[derive(Clone, Default, Deserialize, Serialize, PartialEq, Eq, Debug, Copy)] +pub enum InputType { + /// The user input is a shell command. + #[default] + Shell, + + /// The user input is a natural language query to AI. + AI, +} + +/// The input mode for the universal developer input +/// (i.e. the input type and whether the input is locked in said type) +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Default)] +pub struct InputMode { + pub input_type: InputType, + pub is_locked: bool, +} + +impl InputMode { + pub fn new(input_type: InputType, is_locked: bool) -> Self { + Self { + input_type, + is_locked, + } + } +} + +/// Whether a CLI agent (e.g. Claude Code, Gemini CLI) is active in the +/// terminal. Synced during shared sessions so viewers see the agent footer. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Default)] +pub enum CLIAgentSessionState { + /// A CLI agent is running. + Active { + /// Serialized `CLIAgent` enum value (e.g. "Claude", "Gemini", "Codex"). + cli_agent: String, + /// Whether the CLI agent rich input composer is open. + is_rich_input_open: bool, + }, + /// No CLI agent is running (or the previous one ended). + #[default] + Inactive, +} + +/// How the agent is interacting with the current long running command (if at all). +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub enum LongRunningCommandAgentInteractionState { + /// The agent is not interacting with any long running command. + NotInteracting, + /// The user started a long running command and tagged the agent into it. + TaggedIn, + /// The agent started and is controlling a long running command. + InControl, +} + +/// The combined state container for universal developer input context. +/// This includes model selection, input mode, and selected conversation. +#[derive(Clone, Debug, Deserialize, Serialize, Default, PartialEq, Eq)] +pub struct UniversalDeveloperInputContext { + /// Which agent model is selected as the primary model. + pub selected_model: Option, + + /// The input mode for the universal developer input. + pub input_mode: Option, + + /// The selected conversation (identified by server token) for the next agent query. + pub selected_conversation: Option, + + /// How the agent is interacting with the current long running command (if at all). + pub long_running_command_agent_interaction_state: + Option, + + /// Whether auto-approve is enabled for agent actions. + pub auto_approve_agent_actions: Option, + + /// Whether a CLI agent is active in this terminal. + #[serde(default)] + pub cli_agent_session: CLIAgentSessionState, +} + +/// Update message for universal developer input context - only contains fields that changed. +#[derive(Clone, Debug, Deserialize, Serialize, Default)] +pub struct UniversalDeveloperInputContextUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_model: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub input_mode: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_conversation: Option, + + /// How the agent is interacting with the current long running command (if at all). + #[serde(skip_serializing_if = "Option::is_none")] + pub long_running_command_agent_interaction_state: + Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_approve_agent_actions: Option, + + /// Whether a CLI agent is active. `None` = no change. + #[serde(skip_serializing_if = "Option::is_none")] + pub cli_agent_session: Option, +} + +impl UniversalDeveloperInputContextUpdate { + /// Returns true if this update would actually change the given cached context. + pub fn changes_cached_context(&self, cached: &UniversalDeveloperInputContext) -> bool { + // We destructure here to ensure that, when new fields are added, we check said fields. + let UniversalDeveloperInputContextUpdate { + selected_model: updated_selected_model, + input_mode: updated_input_mode, + selected_conversation: updated_selected_conversation, + auto_approve_agent_actions: updated_auto_approve_agent_actions, + long_running_command_agent_interaction_state: + updated_long_running_command_agent_interaction_state, + cli_agent_session: updated_cli_agent_session, + } = self; + let UniversalDeveloperInputContext { + selected_model: cached_selected_model, + input_mode: cached_input_mode, + selected_conversation: cached_selected_conversation, + auto_approve_agent_actions: cached_auto_approve_agent_actions, + long_running_command_agent_interaction_state: + cached_long_running_command_agent_interaction_state, + cli_agent_session: cached_cli_agent_session, + } = cached; + + // If any of the fields are present and different from the cached context, return true + // (as the update will change the cached context) + (updated_selected_model.is_some() + && updated_selected_model.as_ref() != cached_selected_model.as_ref()) + || (updated_input_mode.is_some() + && updated_input_mode.as_ref() != cached_input_mode.as_ref()) + || (updated_selected_conversation.is_some() + && updated_selected_conversation != cached_selected_conversation) + || (updated_auto_approve_agent_actions.is_some() + && updated_auto_approve_agent_actions != cached_auto_approve_agent_actions) + || (updated_long_running_command_agent_interaction_state.is_some() + && updated_long_running_command_agent_interaction_state + != cached_long_running_command_agent_interaction_state) + || (updated_cli_agent_session.is_some() + && updated_cli_agent_session.as_ref() != Some(cached_cli_agent_session)) + } + + /// Merges this update into the current context, returning the new merged state. + pub fn merge_into( + self, + current: UniversalDeveloperInputContext, + ) -> UniversalDeveloperInputContext { + let UniversalDeveloperInputContextUpdate { + selected_model: updated_selected_model, + input_mode: updated_input_mode, + selected_conversation: updated_selected_conversation, + auto_approve_agent_actions: updated_auto_approve_agent_actions, + long_running_command_agent_interaction_state: + updated_long_running_command_agent_interaction_state, + cli_agent_session: updated_cli_agent_session, + } = self; + let UniversalDeveloperInputContext { + selected_model: current_selected_model, + input_mode: current_input_mode, + selected_conversation: current_selected_conversation, + auto_approve_agent_actions: current_auto_approve_agent_actions, + long_running_command_agent_interaction_state: + current_long_running_command_agent_interaction_state, + cli_agent_session: current_cli_agent_session, + } = current; + + UniversalDeveloperInputContext { + selected_model: updated_selected_model.or(current_selected_model), + input_mode: updated_input_mode.or(current_input_mode), + selected_conversation: updated_selected_conversation.or(current_selected_conversation), + auto_approve_agent_actions: updated_auto_approve_agent_actions + .or(current_auto_approve_agent_actions), + long_running_command_agent_interaction_state: + updated_long_running_command_agent_interaction_state + .or(current_long_running_command_agent_interaction_state), + cli_agent_session: updated_cli_agent_session.unwrap_or(current_cli_agent_session), + } + } +} + +impl From for UniversalDeveloperInputContextUpdate { + fn from(context: UniversalDeveloperInputContext) -> Self { + Self { + selected_model: context.selected_model, + input_mode: context.input_mode, + selected_conversation: context.selected_conversation, + auto_approve_agent_actions: context.auto_approve_agent_actions, + long_running_command_agent_interaction_state: context + .long_running_command_agent_interaction_state, + cli_agent_session: Some(context.cli_agent_session), + } + } +} diff --git a/src/common/user.rs b/src/common/user.rs new file mode 100644 index 0000000..fc84902 --- /dev/null +++ b/src/common/user.rs @@ -0,0 +1,46 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, Deserialize, Serialize)] +/// Contains information for identifying the end-user. +/// +/// Different [`UserID`]'s might correspond to the same end-user; +/// we use the [`UserID`] to translate to a canonical user. +pub struct UserID { + /// Randomly generated ID for the user, which exists whether or not they are logged in. + pub anonymous_id: String, + + /// The client's access token. This is either: + /// * A short-lived firebase ID token (not refresh token). + /// * A Warp API key. + /// + /// [`Some`] iff we know who the end-user is (i.e. they're logged in). + #[serde(rename = "firebase_id_token")] + pub access_token: Option, +} + +impl Default for UserID { + fn default() -> Self { + Self { + anonymous_id: Uuid::new_v4().to_string(), + access_token: None, + } + } +} + +/// A newtype for a firebase uid. +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Clone)] +#[serde(transparent)] +pub struct FirebaseUid(String); + +impl From for FirebaseUid { + fn from(value: String) -> Self { + Self(value) + } +} + +impl From for String { + fn from(value: FirebaseUid) -> Self { + value.0 + } +} diff --git a/src/common/write_to_pty.rs b/src/common/write_to_pty.rs new file mode 100644 index 0000000..a25b6f5 --- /dev/null +++ b/src/common/write_to_pty.rs @@ -0,0 +1,42 @@ +use super::ParticipantId; +use serde::{Deserialize, Serialize}; + +/// A monotonically increasing sequence number to identify sequential writes to the pty. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)] +pub struct WriteToPtySeqNo(usize); + +impl WriteToPtySeqNo { + pub fn zero() -> Self { + Self(0) + } + + pub fn advance(&mut self) { + self.0 += 1; + } + + pub fn as_usize(&self) -> usize { + self.0 + } +} + +impl From for WriteToPtySeqNo { + fn from(value: usize) -> Self { + Self(value) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct WriteToPtyRequestId { + pub participant_id: ParticipantId, + pub op_no: WriteToPtySeqNo, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum WriteToPtyFailureReason { + /// The viewer does not have sufficient permissions to write to pty. + InsufficientPermissions, + + /// The buffer for which the write to pty was requested is old. + /// Specificaly, there is a new buffer and the command is no longer in-progress. + StaleBuffer, +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..631d6f7 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,10 @@ +//! The session-sharing protocol. +//! All messages defined here are shared across the client and server, +//! and are serialized/deserialized to/from JSON. +//! +//! When modifying them, changes need to be backward-compatible. This usually means +//! defining a default value for every new field added that is used during +//! deserialization when the field is missing. +pub mod common; +pub mod sharer; +pub mod viewer; diff --git a/src/sharer.rs b/src/sharer.rs new file mode 100644 index 0000000..513e4a5 --- /dev/null +++ b/src/sharer.rs @@ -0,0 +1,603 @@ +//! The message types that are communicated between the +//! server and a sharer client. +//! +//! When a client wants to create a shared session, the client +//! will make a request against /sessions/create. The client +//! must then send an [`Initialize`] message with the relevant data +//! to start the shared session. If successful, the server +//! will acknowledge the creation of the shared +//! session via the [`SessionInitialized`] message. +//! +//! Remember to annotate #[serde(default)] to every new field added for backward compatibility, +//! since old clients may not specify new fields expected by the server. + +use crate::common::{ + ActivePrompt, ActivePromptUpdate, AgentPromptFailureReason, AgentPromptRequest, + AgentPromptRequestId, BlockId, BufferId, CommandExecutionFailureReason, + CommandExecutionRequestId, ControlAction, ControlActionFailureReason, ControlActionRequestId, + FeatureSupport, InputOperationId, InputReplicaId, InputUpdate, InputUpdateFailureReason, + OrderedTerminalEvent, ParticipantId, ParticipantList, ParticipantPresenceUpdate, Role, + RoleRequestId, RoleRequestResponse, Selection, SelectionUpdate, SessionId, SessionSecret, + TelemetryContext, UniversalDeveloperInputContext, UniversalDeveloperInputContextUpdate, UserID, + WindowSize, WriteToPtyFailureReason, WriteToPtyRequestId, +}; + +use super::common::Scrollback; +use byte_unit::Byte; +use serde::{Deserialize, Deserializer, Serialize}; +use uuid::Uuid; + +/// Possible reasons why the server might gracefully terminate +/// a shared session. +#[derive(Clone, Serialize, Deserialize, Debug)] +pub enum SessionTerminatedReason { + /// Unknown error occurred. Session cannot continue. + InternalServerError { + /// Details about what happened. This should + /// 1. only be provided to the sharer client, + /// 2. not necessarily be user-facing, and + /// 3. clients should _not_ try to match on the exact message + details: String, + }, + /// The session exceeded its size limit. + ExceededSizeLimit, + /// The user does not have any more quota remaining. + NoUserQuotaRemaining { + // This is left as an empty struct to make it + // easier to add fields (e.g. next refresh time) + // in the future in a backwards-compatible way. + }, +} + +impl SessionTerminatedReason { + pub fn internal_server_error(details: impl Into) -> Self { + Self::InternalServerError { + details: details.into(), + } + } + pub fn internal_server_error_without_details() -> Self { + Self::internal_server_error(String::new()) + } +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] +/// Client-side reasons for ending the shared session. +pub enum SessionEndedReason { + /// The session was ended gracefully. + EndedBySharer, + /// The sharer was idle for too long. + InactivityLimitReached, + /// The session exceeded its size limit. + // TODO: remove as part of quota enforcement work + ExceededSizeLimit, +} + +#[derive(Deserialize, Serialize, Debug)] +pub enum ReconnectionFailedReason { + /// Unexpected, means something went wrong in the server. + Invalid, + /// The session with the specified ID does not exist. + SessionNotFound, + /// The specified password was incorrect. + WrongPassword, + /// The specified reconnection token was incorrect. + WrongReconnectionToken, + /// The firebase ID of the sharer was missing or doesn't match the original one. + WrongFirebaseUid, + /// The sharer does not have any remaining quota. + NoUserQuotaRemaining, + /// The session is not accessible. + SessionNotAccessible, +} + +#[derive(Default, Debug, Deserialize, Serialize, Clone, Copy)] +pub enum RoleUpdateReason { + #[default] + UpdatedBySharer, + InactivityLimitReached, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Copy)] +pub enum QuotaType { + BytesUsed, + SessionsCreated, +} + +/// The reasons we might fail to initialize a new session. +#[derive(Clone, Serialize, Deserialize, Debug)] +pub enum FailedToInitializeSessionReason { + /// The scrollback exceeds the user's quota. + ScrollbackTooLarge { + // This is left as an empty struct to make it + // easier to add fields (e.g. remaining scrollback size) + // in the future in a backwards-compatible way. + }, + /// The sharer does not have any remaining quota. + NoUserQuotaRemaining { quota_type: QuotaType }, + /// The sharer could not be attributed to a Warp user. + UserNotFound, + /// Something unexpectedly went wrong. + InternalServerError { + /// Details about what happened. This should + /// not necessarily be user-facing, and clients should + /// _not_ try to match on the exact message. + details: String, + }, +} + +impl FailedToInitializeSessionReason { + pub fn internal_server_error_without_details() -> Self { + FailedToInitializeSessionReason::InternalServerError { + details: String::new(), + } + } +} + +// Permission response types are now in common::permissions. +// Re-exported here for backward compatibility. +pub use crate::common::{ + AddGuestsResponse, FailedToAddGuestsReason, FailedToRemoveGuestReason, + FailedToUpdatePendingUserRoleReason, FailedToUpdateTeamAccessLevelReason, + LinkAccessLevelUpdateResponse, RemoveGuestResponse, TeamAccessLevelUpdateResponse, + UpdatePendingUserRoleResponse, +}; + +#[derive(Clone, Debug, Serialize, Default)] +pub enum SessionSourceType { + /// The session was started by a user directly. + #[default] + User, + /// The session was started in the course of spinning up an ambient agent. + AmbientAgent { + #[serde(default)] + task_id: Option, + }, +} + +/// Internal helper that mirrors all wire representations of SessionSourceType +/// (both legacy and new) so we don't recursively call SessionSourceType's +/// custom Deserialize impl. +#[derive(Deserialize)] +#[serde(untagged)] +enum SessionSourceTypeWire { + /// Legacy representation: "User" or "AmbientAgent". + Legacy(LegacySessionSourceType), + /// New representation: externally tagged AmbientAgent with fields, e.g. + /// { "AmbientAgent": { "task_id": "..." } }. + New { + #[serde(rename = "AmbientAgent")] + ambient_agent: AmbientAgentFields, + }, +} + +#[derive(Deserialize)] +struct AmbientAgentFields { + #[serde(default)] + task_id: Option, +} + +impl From for SessionSourceType { + fn from(value: SessionSourceTypeWire) -> Self { + match value { + SessionSourceTypeWire::Legacy(LegacySessionSourceType::User) => SessionSourceType::User, + SessionSourceTypeWire::Legacy(LegacySessionSourceType::AmbientAgent) => { + SessionSourceType::AmbientAgent { task_id: None } + } + SessionSourceTypeWire::New { + ambient_agent: AmbientAgentFields { task_id }, + } => SessionSourceType::AmbientAgent { task_id }, + } + } +} + +impl<'de> Deserialize<'de> for SessionSourceType { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wire = SessionSourceTypeWire::deserialize(deserializer)?; + Ok(SessionSourceType::from(wire)) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub enum LegacySessionSourceType { + #[default] + User, + AmbientAgent, +} + +impl From<&SessionSourceType> for LegacySessionSourceType { + fn from(value: &SessionSourceType) -> Self { + match value { + SessionSourceType::User => LegacySessionSourceType::User, + SessionSourceType::AmbientAgent { .. } => LegacySessionSourceType::AmbientAgent, + } + } +} + +/// Configures the lifetime of the session after sharing ends. +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default)] +pub enum Lifetime { + /// The session is deleted immediately when sharing ends. + #[default] + Ephemeral, + /// The session persists after sharing ends. + /// + /// It is not specified how long a lingering session is available for after it ends. + /// Currently, all session contents expire after one week, but this is a server implementation + /// detail that clients must not rely on. In the future, we may expose a lifetime option that + /// includes a client-provided TTL. + Lingering, +} + +/// The initial state that the sharer must supply when starting +/// a shared session. +#[derive(Debug, Deserialize, Serialize)] +pub struct InitPayload { + pub scrollback: Scrollback, + + pub active_prompt: ActivePrompt, + + pub window_size: WindowSize, + + pub user_id: UserID, + + /// What the sharer currently has selected for presence. + pub selection: Selection, + + pub init_block_id: BlockId, + + pub input_replica_id: InputReplicaId, + + pub telemetry_context: Option, + + #[serde(default)] + pub lifetime: Lifetime, + + /// The universal developer input context state. + #[serde(default)] + pub universal_developer_input_context: Option, + + /// The source type for this shared session (i.e. user or ambient agent). + #[serde(default)] + pub source_type: SessionSourceType, + + /// Client feature support declaration. + #[serde(default)] + pub feature_support: FeatureSupport, +} + +/// The reconnection token for a shared session. +/// A sharer must specify this to reconnect to the session and resume sharing. +/// The client should treat this as some opaque string. +#[derive(Hash, Serialize, Deserialize, Eq, PartialEq, Clone)] +#[serde(transparent)] +pub struct ReconnectToken(String); +impl ReconnectToken { + pub fn new() -> Self { + Self::default() + } +} + +impl Default for ReconnectToken { + fn default() -> Self { + Self(Uuid::new_v4().to_string()) + } +} + +/// Override the Display impl for the token to return a mask. +/// This makes it harder to leak the token by accident. +impl std::fmt::Display for ReconnectToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "***") + } +} + +/// Override the Debug impl for the token to return a mask. +/// This makes it harder to leak the token by accident. +impl std::fmt::Debug for ReconnectToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "***") + } +} + +/// The `warp` server framework uses [`FromStr`] to deserialize +/// the string from the route. +impl std::str::FromStr for ReconnectToken { + type Err = core::convert::Infallible; + fn from_str(s: &str) -> Result { + String::from_str(s).map(ReconnectToken) + } +} + +/// Payload sharer must supply to reconnect to an existing shared session +/// when the websocket was terminated by the server. +#[derive(Debug, Deserialize, Serialize)] +pub struct ReconnectPayload { + // TODO: Remove in favour of ACLs + pub session_secret: SessionSecret, + pub reconnect_token: ReconnectToken, + + pub user_id: UserID, + + /// The ID of the latest block when reconnecting. + /// This allows the sharer to catch up on any + /// missed input updates while they were disconnected. + pub latest_block_id: BlockId, + + /// What the sharer currently has selected for presence. + pub selection: Selection, + + /// Client feature support declaration. + #[serde(default)] + pub feature_support: FeatureSupport, +} + +/// The possible messages sent from server to client (sharer). +#[derive(Deserialize, Serialize)] +pub enum DownstreamMessage { + /// The server sends this message when the session was successfully created. + SessionInitialized { + session_id: SessionId, + // TODO: Remove in favour of ACLs + session_secret: SessionSecret, + reconnect_token: ReconnectToken, + /// The ID assigned to the sharer + sharer_id: ParticipantId, + /// The Firebase UID assigned to the sharer. + sharer_firebase_uid: String, + }, + + /// The server denied the initialization request. No further messages will be processed. + FailedToInitializeSession { + reason: FailedToInitializeSessionReason, + }, + + /// The session was terminated. No further messages + /// will be processed. + SessionTerminated { reason: SessionTerminatedReason }, + + /// The server accepted the reconnection request. + SessionReconnected { + /// The last event no received by the server. + /// The sharer can use this to update the server with any newer events created while disconnected. + last_received_event_no: Option, + participant_list: ParticipantList, + }, + + /// The server denied the reconnection request. No further messages will be processed. + FailedToReconnect { reason: ReconnectionFailedReason }, + + /// The server sends this to confirm it has fully processed events up to the latest_processed_event_no, + /// and the sharer can safely remove them from memory. + EventsProcessedAck { latest_processed_event_no: usize }, + + /// Sent when the list of participants in the shared session changes. + ParticipantListUpdated(ParticipantList), + + /// Sent when a participant's presence changes. + ParticipantPresenceUpdated(ParticipantPresenceUpdate), + + /// The participant (identified by `participant_id`) requested the `role` role. + RoleRequested { + participant_id: ParticipantId, + request_id: RoleRequestId, + role: Role, + }, + + /// The participant (identified by `participant_id`) has cancelled their role request. + RoleRequestCancelled { + participant_id: ParticipantId, + request_id: RoleRequestId, + }, + + /// A participant's (identified by `participant_id`) role was updated. + ParticipantRoleChanged { + participant_id: ParticipantId, + role: Role, + }, + + /// A participant requested a control action (e.g. cancel conversation). + ControlActionRequested { + participant_id: ParticipantId, + request_id: ControlActionRequestId, + action: ControlAction, + }, + + /// The input was updated by a participant. + /// When we receive our own update, we can treat it as an ack. + InputUpdated(InputUpdate), + + /// The rejection was successfully applied (does not need to be retried by the client). + InputUpdateRejectedAck { id: InputOperationId }, + + /// A participant requested that the given `command` be run in the given buffer. + CommandExecutionRequested { + id: CommandExecutionRequestId, + participant_id: ParticipantId, + buffer_id: BufferId, + command: String, + }, + + /// A participant requested to write to the pty, specifically for a long running command. + WriteToPtyRequested { + id: WriteToPtyRequestId, + bytes: Vec, + }, + + /// A participant requested to send an agent prompt. + AgentPromptRequested { + id: AgentPromptRequestId, + participant_id: ParticipantId, + request: AgentPromptRequest, + }, + + /// The sharer's link access level update request was responded to. + LinkAccessLevelUpdateResponse(LinkAccessLevelUpdateResponse), + + /// The request to add guests was responded to. + AddGuestsResponse(AddGuestsResponse), + + /// The request to remove a guest was responded to. + RemoveGuestResponse(RemoveGuestResponse), + + /// The request to update a pending user role was responded to. + UpdatePendingUserRoleResponse(UpdatePendingUserRoleResponse), + + /// The sharer's team access level update request was responsed to. + TeamAccessLevelUpdateResponse(TeamAccessLevelUpdateResponse), + + /// Update to the universal developer input context from sharer or editor viewers. + UniversalDeveloperInputContextUpdated(UniversalDeveloperInputContextUpdate), + + /// A viewer reported its terminal size. + /// Used for remote-control sessions where the viewer's viewport should drive the PTY size. + ViewerTerminalSizeReported { + participant_id: ParticipantId, + window_size: WindowSize, + }, + + /// A response to a [`UpstreamMessage::Ping`]. + /// Used to demonstrate that the server is still alive. + Pong { data: Vec }, +} + +impl DownstreamMessage { + pub fn from_json(json: &str) -> serde_json::Result { + serde_json::from_str(json) + } + + pub fn to_json(&self) -> serde_json::Result { + serde_json::to_string(self) + } +} + +/// The possible messages sent from client (sharer) to server. +#[derive(Debug, Deserialize, Serialize)] +pub enum UpstreamMessage { + /// The client sends this message to start a shared session. + /// supplying any necessary initial state. + /// TODO: add size info, etc. + Initialize(InitPayload), + + /// A heartbeat message to demonstrate that the + /// client is still alive. + Ping { data: Vec }, + + /// The client sends this message to explicitly end a session + /// and notify viewers before the websocket closes. + EndSession { reason: SessionEndedReason }, + + /// Update to the sharer's active prompt. + UpdateActivePrompt(ActivePromptUpdate), + + /// Update to the universal developer input context (model selection, etc.). + UpdateUniversalDeveloperInputContext(UniversalDeveloperInputContextUpdate), + + /// Sent when there is any ordered terminal event. + OrderedTerminalEvent(OrderedTerminalEvent), + + /// Sent to reconnect to the server after disconnection. + Reconnect(ReconnectPayload), + + /// Sent when the sharer changes what they have selected. + UpdateSelection(SelectionUpdate), + + /// Changes the participant's (identified by `participant_id`) role. + UpdateRole { + participant_id: ParticipantId, + role: Role, + }, + + /// Changes the user's role (applied to all participants with the same UID). + UpdateUserRole { user_uid: String, role: Role }, + + /// Changes the pending user's role (applied to all participants with the same UID). + UpdatePendingUserRole { email: String, role: Role }, + + /// Responds to the participant's (identified by `participant_id`) role request. + RespondToRoleRequest { + participant_id: ParticipantId, + request_id: RoleRequestId, + response: RoleRequestResponse, + }, + + /// Updates all participants' roles to be [Role::Reader]. + UpdateAllRolesToReader { reason: RoleUpdateReason }, + + /// The sharer updated the input. + UpdateInput(InputUpdate), + + /// The given operation should be undone on all participants. + RejectInputUpdate { + id: InputOperationId, + reason: InputUpdateFailureReason, + }, + + /// The given command execution request was denied for the specified `reason`. + RejectCommandExecutionRequest { + id: CommandExecutionRequestId, + participant_id: ParticipantId, + reason: CommandExecutionFailureReason, + }, + + /// The given write to pty request was denied for the specified `reason`. + RejectWriteToPtyRequest { + id: WriteToPtyRequestId, + reason: WriteToPtyFailureReason, + }, + + /// The given agent prompt request was denied for the specified `reason`. + RejectAgentPromptRequest { + id: AgentPromptRequestId, + participant_id: ParticipantId, + reason: AgentPromptFailureReason, + }, + + /// The given control action request was denied for the specified `reason`. + RejectControlActionRequest { + participant_id: ParticipantId, + request_id: ControlActionRequestId, + reason: ControlActionFailureReason, + }, + + /// The sharer updated the session's link permissions. + UpdateLinkAccessLevel { role: Option }, + + /// The sharer updated the session's team permissions. + UpdateTeamAccessLevel { + team_uid: String, + role: Option, + }, + + /// The sharer added users as session guests by email. + AddGuests { emails: Vec, role: Role }, + + /// The sharer removed a user as a session guest. + RemoveGuest { user_uid: String }, + + /// The sharer removed a pending user as a session guest. + RemovePendingGuest { email: String }, +} + +impl UpstreamMessage { + pub fn from_json(json: &str) -> serde_json::Result { + serde_json::from_str(json) + } + + pub fn to_json(&self) -> serde_json::Result { + serde_json::to_string(self) + } + + pub fn num_bytes(&self) -> Byte { + match self { + UpstreamMessage::Initialize(init_payload) => init_payload.scrollback.num_bytes(), + UpstreamMessage::OrderedTerminalEvent(ordered_terminal_event) => { + ordered_terminal_event.num_bytes() + } + UpstreamMessage::UpdateInput(input_update) => input_update.num_bytes(), + _ => Byte::from_u64(0), + } + } +} diff --git a/src/viewer.rs b/src/viewer.rs new file mode 100644 index 0000000..16fbfd2 --- /dev/null +++ b/src/viewer.rs @@ -0,0 +1,431 @@ +//! The message types that are communicated between the +//! server and a viewer client. +//! +//! When a client wants to join a shared session, the client +//! will make a request against /sessions/join/:uuid. The client +//! must then send an [`Initialize`] message with the relevant data +//! to join the shared session. If successful, the server +//! will acknowledge the joining of the shared +//! session via the [`JoinedSuccessfully`] message. +//! +//! To reconnect to a shared session, the viewer can use the same +//! join endpoint, setting the init payload appropriately. +//! The server will acknowledge the rejoining via the +//! [`RejoinedSuccessfully`] message. + +use crate::{ + common::{ + ActivePrompt, ActivePromptUpdate, AgentAttachment, AgentPromptFailureReason, + AgentPromptRequest, AgentPromptRequestId, BlockId, BufferId, CommandExecutionFailureReason, + CommandExecutionRequestId, ControlAction, ControlActionFailureReason, FeatureSupport, + InputOperationId, InputReplicaId, InputUpdate, InputUpdateFailureReason, + LinkAccessLevelUpdateResponse, OrderedTerminalEvent, ParticipantId, ParticipantList, + ParticipantPresenceUpdate, Role, RoleRequestId, RoleRequestResponse, Scrollback, + SelectionUpdate, TeamAccessLevelUpdateResponse, TeamAclData, TelemetryContext, + UniversalDeveloperInputContext, UniversalDeveloperInputContextUpdate, UserID, WindowSize, + WriteToPtyFailureReason, WriteToPtyRequestId, + }, + sharer::{self, LegacySessionSourceType, SessionSourceType}, +}; +use byte_unit::Byte; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] +/// Sent by sharer client or server +/// when the shared session has been ended. +pub enum SessionEndedReason { + /// Unexpected, means something went wrong in the server. + InternalServerError, + /// The session was ended gracefully. + EndedBySharer, + /// The sharer was idle for too long. + InactivityLimitReached, + + /// DEPRECATED + ExceededSizeLimit, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum FailedToJoinReason { + /// Unexpected, means something went wrong in the server. + Invalid, + SessionNotFound, + WrongPassword, + InternalServerError, + MaxNumberOfParticipantsReached, + SessionNotAccessible, +} + +#[derive(Default, Debug, Deserialize, Serialize, Clone, Copy)] +pub enum RoleUpdatedReason { + #[default] + UpdatedBySharer, + InactivityLimitReached, +} + +impl From for RoleUpdatedReason { + fn from(value: sharer::RoleUpdateReason) -> Self { + match value { + sharer::RoleUpdateReason::UpdatedBySharer => RoleUpdatedReason::UpdatedBySharer, + sharer::RoleUpdateReason::InactivityLimitReached => { + RoleUpdatedReason::InactivityLimitReached + } + } + } +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] +pub enum ViewerRemovedReason { + LostAccess, +} + +/// The initial state that the viewer must supply when joining or rejoining +/// a shared session. +#[derive(Debug, Deserialize, Serialize)] +pub struct InitPayload { + /// The ID previously assigned to this viewer when joining. + /// Should be specified if the viewer is rejoining. + pub viewer_id: Option, + pub user_id: UserID, + /// If the viewer is reconnecting, they should specify the last event no received. + /// The server will only send events after this event no. + pub last_received_event_no: Option, + + /// The ID of the last block the viewer has seen. + /// Should only be specified when re-joining. + pub latest_block_id: Option, + + pub telemetry_context: Option, + + /// Client feature support declaration. + #[serde(default)] + pub feature_support: FeatureSupport, +} + +/// The possible messages sent from server to client (viewer). +#[derive(Serialize, Deserialize, Clone)] +pub enum DownstreamMessage { + /// The server sends this message when the session was successfully joined. + /// TODO: add initial state to pass to viewer (e.g. size info) + JoinedSuccessfully { + scrollback: Box, + /// The sharer's active prompt. + active_prompt: ActivePrompt, + /// The latest event no of the session the viewer will be catching up to. + /// If None, there are no events to catch up to. + latest_event_no: Option, + window_size: WindowSize, + + participant_list: Box, + + /// The ID assigned to this viewer + viewer_id: ParticipantId, + /// The Firebase UID assigned to this viewer. + viewer_firebase_uid: String, + + /// The block ID of the first block after scrollback. + /// The viewer can use this to identify buffer updates for + /// the first block. + init_block_id: BlockId, + + input_replica_id: InputReplicaId, + + /// The universal developer input context (model selection, etc.). + #[serde(default)] + universal_developer_input_context: Option, + + /// The legacy source type for this shared session (i.e. user or ambient agent). + #[serde(default)] + #[deprecated(note = "please use `detailed_source_type` instead")] + source_type: LegacySessionSourceType, + + /// The detailed source type for this shared session. + #[serde(default)] + detailed_source_type: SessionSourceType, + }, + + /// The server sends this message when the session was successfully rejoined. + RejoinedSuccessfully { + participant_list: Box, + }, + + /// Sent when the viewer fails to join the shared session. + /// The client should not expect any more messages after this. + FailedToJoin { reason: FailedToJoinReason }, + + /// Sent when the shared session has been ended. + /// The client should not expect any more messages after this. + SessionEnded { reason: SessionEndedReason }, + + /// Update to the sharer's active prompt. + ActivePromptUpdated(ActivePromptUpdate), + + /// Update to the universal developer input context (model selection, etc.) from sharer or editor viewers. + UniversalDeveloperInputContextUpdated(UniversalDeveloperInputContextUpdate), + + /// Sent when there is any ordered terminal event. + /// These messages are only sent _after_ [`DownstreamMessage::JoinedSuccessfully`]. + OrderedTerminalEvent(OrderedTerminalEvent), + + /// Sent when the list of participants in the shared session changes. + ParticipantListUpdated(ParticipantList), + + /// Sent when a participant's presence changes. + ParticipantPresenceUpdated(ParticipantPresenceUpdate), + + /// The server has acknowledged the role request and sent it to the sharer. + /// There can only be at most one role request in flight per participant. + RoleRequestInFlight(RoleRequestId), + + /// The viewer's role request was responded to. + RoleRequestResponse(RoleRequestResponse), + + /// A participant's (identified by `participant_id`) role was updated. + ParticipantRoleChanged { + participant_id: ParticipantId, + reason: RoleUpdatedReason, + role: Role, + }, + + /// The input was updated by a participant. + /// When we receive our own update, we can treat it as an ack. + InputUpdated(InputUpdate), + + /// An input operation was rejected and should be undone. + InputUpdateRejected { + id: InputOperationId, + reason: InputUpdateFailureReason, + }, + + /// The server has acknowledged the command execution request and sent it to the sharer. + /// There can only be at most one command execution request in flight per participant. + CommandExecutionRequestInFlight(CommandExecutionRequestId), + + /// The viewer's command execution request failed. + /// Note: there is no "success" response; that is implicitly handled + /// by the fact that the command is executed. + CommandExecutionRequestFailed { + id: CommandExecutionRequestId, + reason: CommandExecutionFailureReason, + }, + + /// The viewer's write to pty request failed. + WriteToPtyRequestFailed { reason: WriteToPtyFailureReason }, + + /// The server has acknowledged the agent prompt request and sent it to the sharer. + AgentPromptRequestInFlight(AgentPromptRequestId), + + /// The viewer's agent prompt request failed. + /// Note: there is no "success" response; that is implicitly handled + /// by the fact that agent response events start streaming. + AgentPromptRequestFailed { reason: AgentPromptFailureReason }, + + /// The viewer's control action request failed. + ControlActionRequestFailed { reason: ControlActionFailureReason }, + + /// The viewer was removed from the session by the sharer. + ViewerRemoved { reason: ViewerRemovedReason }, + + /// Deprecated: superseded by [`DownstreamMessage::LinkAccessLevelUpdateResponse`]. + /// Kept temporarily for backward compatibility with older clients. Remove once + /// all clients handle `LinkAccessLevelUpdateResponse`. + LinkAccessLevelUpdated { role: Option }, + + /// Deprecated: superseded by [`DownstreamMessage::TeamAccessLevelUpdateResponse`]. + /// Kept temporarily for backward compatibility with older clients. Remove once + /// all clients handle `TeamAccessLevelUpdateResponse`. + TeamAccessLevelUpdated { + /// The UID of the updated team. + team_uid: String, + /// The ACL of the updated team. None if team has no ACL. + team_acl: Option, + }, + + /// The viewer's link access level update request was responded to. + LinkAccessLevelUpdateResponse(crate::common::LinkAccessLevelUpdateResponse), + + /// The request to add guests was responded to. + AddGuestsResponse(crate::common::AddGuestsResponse), + + /// The request to remove a guest was responded to. + RemoveGuestResponse(crate::common::RemoveGuestResponse), + + /// The request to update a pending user role was responded to. + UpdatePendingUserRoleResponse(crate::common::UpdatePendingUserRoleResponse), + + /// The viewer's team access level update request was responded to. + TeamAccessLevelUpdateResponse(crate::common::TeamAccessLevelUpdateResponse), + + /// A response to a [`UpstreamMessage::Ping`]. + /// Used to demonstrate that the server is still alive. + Pong { data: Vec }, +} + +impl DownstreamMessage { + pub fn from_json(json: &str) -> serde_json::Result { + serde_json::from_str(json) + } + + pub fn to_json(&self) -> serde_json::Result { + serde_json::to_string(self) + } + + /// Downgrades all `Role::Full` fields to `Role::Executor`. + /// Used for backward compatibility with clients that don't support the Full role. + #[allow(deprecated)] + pub fn downgrade_full_roles(&mut self) { + match self { + Self::JoinedSuccessfully { + participant_list, .. + } => participant_list.downgrade_full_roles(), + Self::RejoinedSuccessfully { participant_list } => { + participant_list.downgrade_full_roles() + } + Self::ParticipantListUpdated(list) => list.downgrade_full_roles(), + Self::ParticipantRoleChanged { role, .. } => role.downgrade_full(), + Self::RoleRequestResponse(RoleRequestResponse::Approved { new_role }) => { + new_role.downgrade_full() + } + Self::LinkAccessLevelUpdated { role: Some(role) } => { + role.downgrade_full(); + } + Self::TeamAccessLevelUpdated { + team_acl: Some(team_acl), + .. + } => { + team_acl.acl.downgrade_full(); + } + Self::LinkAccessLevelUpdateResponse(LinkAccessLevelUpdateResponse::Ok { + role: Some(role), + }) => { + role.downgrade_full(); + } + Self::TeamAccessLevelUpdateResponse(TeamAccessLevelUpdateResponse::Success { + team_acl: Some(team_acl), + .. + }) => { + team_acl.acl.downgrade_full(); + } + _ => {} + } + } +} + +/// The possible messages sent from client (viewer) to server. +#[derive(Debug, Serialize, Deserialize)] +pub enum UpstreamMessage { + /// The client sends this message to join the shared session. + Initialize(InitPayload), + + /// A heartbeat message to demonstrate that the + /// client is still alive. + Ping { data: Vec }, + + /// Sent when the viewer changes what they have selected. + UpdateSelection(SelectionUpdate), + + /// The viewer is requesting a new role. + RequestRole(Role), + + /// The viewer no longer wants to change roles. + CancelRoleRequest(RoleRequestId), + + /// The viewer updated their input. + /// This is an optimistic update and thus was already applied on the viewer's client. + UpdateInput(InputUpdate), + + /// The viewer is requesting the sharer to execute the provided command + /// in the given buffer. + ExecuteCommand { + buffer_id: BufferId, + command: String, + }, + + /// The viewer is requesting to write to the pty, + /// specifically to a long running command. + WriteToPty { + request_id: WriteToPtyRequestId, + bytes: Vec, + }, + + /// The viewer is requesting to send an agent prompt. + /// If there's an existing in-flight request for the same conversation, + /// it will be cancelled and replaced with this new request. + SendAgentPrompt(AgentPromptRequest), + + /// The viewer (with Editor role) is updating the universal developer input context. + UpdateUniversalDeveloperInputContext(UniversalDeveloperInputContextUpdate), + + /// The viewer is requesting a one-off control action to be applied to the shared session. + SendControlAction(ControlAction), + + /// The viewer has reauthenticated. + Reauthenticated { user_id: UserID }, + + /// The viewer updated the session's link permissions. + UpdateLinkAccessLevel { role: Option }, + + /// The viewer updated the session's team permissions. + UpdateTeamAccessLevel { + team_uid: String, + role: Option, + }, + + /// The viewer added users as session guests by email. + AddGuests { emails: Vec, role: Role }, + + /// The viewer removed a user as a session guest. + RemoveGuest { user_uid: String }, + + /// The viewer removed a pending user as a session guest. + RemovePendingGuest { email: String }, + + /// The viewer changed a user's role. + UpdateUserRole { user_uid: String, role: Role }, + + /// The viewer changed a pending user's role. + UpdatePendingUserRole { email: String, role: Role }, + + /// The viewer is reporting its terminal size to the sharer. + /// Used for remote-control sessions where the viewer's viewport should drive the PTY size. + ReportTerminalSize { window_size: WindowSize }, +} + +impl UpstreamMessage { + pub fn from_json(json: &str) -> serde_json::Result { + serde_json::from_str(json) + } + + pub fn to_json(&self) -> serde_json::Result { + serde_json::to_string(self) + } + + pub fn num_bytes(&self) -> Byte { + match self { + UpstreamMessage::UpdateInput(input_update) => input_update.num_bytes(), + UpstreamMessage::ExecuteCommand { command, .. } => command.len().into(), + UpstreamMessage::WriteToPty { bytes, .. } => bytes.len().into(), + UpstreamMessage::SendAgentPrompt(request) => { + // Count prompt length + attachments + let prompt_bytes: Byte = request.prompt.len().into(); + let attachments_bytes: Byte = request + .attachments + .iter() + .map(|att| match att { + AgentAttachment::PlainText { content } => content.len(), + // Block's are already included in the shared session thus far, + // so we do not have to count them again here. + AgentAttachment::BlockReference { .. } => 0, + // FileReference is just IDs — the actual data is in GCS. + AgentAttachment::FileReference { .. } => 0, + }) + .sum::() + .into(); + prompt_bytes + .add(attachments_bytes) + .unwrap_or(u64::MAX.into()) + } + _ => Byte::from_u64(0), + } + } +}